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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 13:46:18 +02:00
1 parent 8fdde182b7
commit 5d90c0a2d2
21 files changed
+1031 -60

No files matched your search

+17
View File
@@ -355,12 +355,15 @@ export const aclApi = {
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
}
@@ -959,6 +962,20 @@ export interface CalendarItem {
timezone: string
ctag: number
created_at: string
/** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */
permission: 'read' | 'write'
/** Der Kalender gehört jemand anderem. */
shared: boolean
/** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne
* sichtbaren Absender ist ein Rätsel. */
owner_name?: string
/** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */
via_group?: string
}
/** Kurz: Darf ich diesen Kalender ändern? */
export function canEditCalendar(c: CalendarItem): boolean {
return c.permission !== 'read'
}
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
+13 -4
View File
@@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
resource: { type: 'folder' | 'file'; id: string; name: string }
resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string }
onClose: () => void
}
@@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setLoading(true)
try {
const [list, people] = await Promise.all([
resource.type === 'folder'
? aclApi.listForFolder(resource.id)
resource.type === 'folder' ? aclApi.listForFolder(resource.id)
: resource.type === 'calendar' ? aclApi.listForCalendar(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
@@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
setSaving(true)
setError('')
try {
if (resource.type === 'folder') {
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)
@@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) {
</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.
+54 -10
View File
@@ -3,7 +3,7 @@ import {
type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent,
} from 'react'
import {
calendarApi, APIError,
calendarApi, APIError, canEditCalendar,
type CalendarItem, type CalendarEvent, type EventInput,
} from '../api/client'
import PageHeader from '../components/PageHeader'
@@ -16,7 +16,8 @@ import {
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
} from '../components/calendar/dates'
import { useEventDrag } from '../components/calendar/useEventDrag'
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons'
import ShareWithDialog from '../components/ShareWithDialog'
type ViewMode = 'month' | 'week' | 'list'
@@ -109,6 +110,8 @@ export default function CalendarPage() {
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
/** Kalender, dessen Freigaben gerade verwaltet werden. */
const [shareCalendar, setShareCalendar] = useState<CalendarItem | null>(null)
const [deleteEvent, setDeleteEvent] = useState<
{ calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null)
@@ -182,6 +185,15 @@ export default function CalendarPage() {
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
/**
* Kalender, in die dieses Konto schreiben darf.
*
* Die Kalenderwahl im Termin-Dialog zeigt nur diese. Einen nur lesbaren
* Kalender dort anzubieten hieße, jemanden ein Formular ausfüllen zu lassen,
* das der Server danach ablehnt.
*/
const writableCalendars = useMemo(() => calendars.filter(canEditCalendar), [calendars])
function toggleVisible(id: string) {
setHidden(prev => {
const next = new Set(prev)
@@ -412,7 +424,10 @@ export default function CalendarPage() {
const on = !hidden.has(cal.id)
// Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen
// Termins ist keins – dorthin gibt es nichts zu verschieben.
const isTarget = !!drag && drag.ev.calendarId !== cal.id
// Ablegefläche nur, wo auch geschrieben werden darf. Ein Ziel
// anzubieten, das der Server danach ablehnt, ist schlechter als
// gar keines: Man hat den Termin schon losgelassen.
const isTarget = !!drag && drag.ev.calendarId !== cal.id && canEditCalendar(cal)
const isOver = isTarget && drag!.overId === cal.id
return (
<div key={cal.id} className="aether-cal-row"
@@ -442,9 +457,19 @@ export default function CalendarPage() {
opacity: on ? 1 : 0.45,
}}>
{cal.name}
{/* Fremde Kalender tragen ihren Absender. Zwei „Privat"
nebeneinander wären sonst nicht zu unterscheiden. */}
{cal.shared && cal.owner_name && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 11.5 }}>
{' · '}{cal.owner_name}{cal.permission === 'read' ? ' (nur lesen)' : ''}
</span>
)}
</span>
<CalMenu
own={!cal.shared}
canWrite={canEditCalendar(cal)}
onRename={() => setCalDialog({ calendar: cal })}
onShare={() => setShareCalendar(cal)}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
onImport={() => triggerImport(cal.id)}
onDelete={() => setDeleteCalendar(cal)}
@@ -485,7 +510,7 @@ export default function CalendarPage() {
background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.3)',
}}>
{calendars.filter(c => c.id !== drag.ev.calendarId).map(cal => (
{calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
<div key={cal.id} data-cal-drop={cal.id} style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
borderRadius: 10, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0,
@@ -529,7 +554,7 @@ export default function CalendarPage() {
<EventDialog
event={eventDialog.event}
defaultStart={eventDialog.start}
calendars={calendars}
calendars={writableCalendars}
calendarId={eventDialog.calId}
onSave={saveEvent}
onDelete={eventDialog.event ? () => setDeleteEvent({
@@ -547,6 +572,13 @@ export default function CalendarPage() {
{calDialog && (
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
)}
{shareCalendar && (
<ShareWithDialog
resource={{ type: 'calendar', id: shareCalendar.id, name: shareCalendar.name }}
onClose={() => setShareCalendar(null)}
/>
)}
{deleteCalendar && (
<ConfirmDialog
title="Kalender löschen"
@@ -583,8 +615,19 @@ const navBtnStyle: CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}
function CalMenu({ onRename, onExport, onImport, onDelete }: {
onRename: () => void; onExport: () => void; onImport: () => void; onDelete: () => void
/**
* Das Menü eines Kalenders.
*
* Bei einem **fremden** Kalender bleiben nur die Punkte stehen, die auch
* gehen: ansehen und ausgeben. Bearbeiten, Löschen, Freigeben gehören dem
* Eigentümer – ein Menüpunkt, der immer scheitert, ist schlimmer als keiner.
* Der Server lehnt sie ohnehin ab; hier geht es darum, gar nicht erst
* hinzuführen.
*/
function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrite }: {
onRename: () => void; onExport: () => void; onImport: () => void
onDelete: () => void; onShare: () => void
own: boolean; canWrite: boolean
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -609,10 +652,11 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
{own && <MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
{own && <MenuRow icon={<PeopleIcon />} label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
{canWrite && <MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />}
{own && <MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
</div>
)}
</div>