Files
Aether/frontend/src/components/ShareWithDialog.tsx
T
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

282 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import {
aclApi,
type AclEntry, type ShareRecipient, type SharePermission,
} from '../api/client'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
onClose: () => void
}
/** Freigabe an Personen und Gruppen innerhalb der Cloud.
*
* Bewusst getrennt vom öffentlichen Freigabelink: Der eine gibt einen
* anonymen Zugang ins Internet, der andere benennt Personen, die sich
* ohnehin anmelden. Ein Dialog für beides würde die Frage „wer kann das
* sehen?" verwischen – und genau die muss beim Teilen eindeutig sein. */
export default function ShareWithDialog({ resource, onClose }: Props) {
const overlay = useOverlayDismiss(onClose)
const [entries, setEntries] = useState<AclEntry[]>([])
const [recipients, setRecipients] = useState<ShareRecipient[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subject, setSubject] = useState('')
const [permission, setPermission] = useState<SharePermission>('read')
const [saving, setSaving] = useState(false)
useEffect(() => { load() }, [resource.id])
async function load() {
setLoading(true)
try {
const [list, people] = await Promise.all([
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
setEntries(list ?? [])
setRecipients(people ?? [])
} catch {
setError('Could not load sharing settings')
} finally {
setLoading(false)
}
}
async function grant() {
const chosen = recipients.find(r => r.id === subject)
if (!chosen) return
setSaving(true)
setError('')
try {
if (resource.type === 'calendar') {
await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission)
} else if (resource.type === 'folder') {
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
} else {
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
}
setSubject('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not share')
} finally {
setSaving(false)
}
}
async function revoke(entry: AclEntry) {
try {
await aclApi.revoke(entry.id)
setEntries(list => list.filter(e => e.id !== entry.id))
} catch {
setError('Could not remove access')
}
}
// Wer schon Zugriff hat, gehört nicht noch einmal in die Auswahlliste.
const alreadyShared = new Set(entries.map(e => e.subject_id))
const available = recipients.filter(r => !alreadyShared.has(r.id))
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 300,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16, width: 480, maxWidth: '100%', maxHeight: '85vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}>
{/* Kopf */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
Share with people
</h3>
<p style={{
margin: '3px 0 0', fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{resource.name}
</p>
</div>
{/* Hinzufügen */}
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={subject}
onChange={e => setSubject(e.target.value)}
style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
>
<option value="">Choose a person or group…</option>
{available.filter(r => r.type === 'user').length > 0 && (
<optgroup label="People">
{available.filter(r => r.type === 'user').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
{available.filter(r => r.type === 'group').length > 0 && (
<optgroup label="Groups">
{available.filter(r => r.type === 'group').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
</select>
<select
value={permission}
onChange={e => setPermission(e.target.value as SharePermission)}
style={{
padding: '8px 10px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
>
<option value="read">Can view</option>
<option value="write">Can edit</option>
</select>
<button
onClick={grant}
disabled={!subject || saving}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600,
cursor: !subject || saving ? 'default' : 'pointer',
opacity: !subject || saving ? 0.5 : 1,
}}
>
{saving ? '…' : 'Share'}
</button>
</div>
{resource.type === 'calendar' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
The calendar shows up in their app too — web and CalDAV alike.
With “can edit” they may add and change events; renaming or
deleting the calendar stays with you.
</p>
)}
{resource.type === 'folder' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Everything inside this folder is shared too, including anything added later.
Files stay in your storage and count towards your quota.
</p>
)}
{error && (
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
{/* Wer hat Zugriff */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
<div style={{
fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 10,
}}>
Who has access
</div>
{loading ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
Only you. Nobody else can see this.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.map(e => (
<div key={e.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 11px', borderRadius: 9,
border: '1px solid var(--color-border)',
}}>
<span style={{ color: 'var(--color-text-muted)', display: 'flex', flexShrink: 0 }}>
{e.subject_type === 'group' ? <GroupIcon /> : <PersonIcon />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{e.subject_name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{e.subject_type === 'group' ? 'Group' : 'Person'} ·{' '}
{e.permission === 'write' ? 'Can edit' : 'Can view'}
</div>
</div>
<button
onClick={() => revoke(e)}
style={{
padding: '4px 11px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
}}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
<div style={{
padding: '12px 22px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end',
}}>
<button
onClick={onClose}
style={{
padding: '8px 18px', borderRadius: 9,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', fontSize: 13, cursor: 'pointer',
}}
>
Done
</button>
</div>
</div>
</div>
)
}
function PersonIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a7 7 0 0114 0v1" />
</svg>
)
}
function GroupIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)
}