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>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|