From 3898d0c1cb6cd050b4690a644182eed2338f4c9a Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:06:05 +0200 Subject: [PATCH 1/2] fix(calendar): rights-first event editability, incl. alias organizers Event editability was inferred from identity (isOrigin + organizer match) and never from the calendar's write rights. That opened events in read-only shared / iCal-subscription calendars for editing, and once the address list was widened with account aliases any alias-organized event became editable - even in calendars the user cannot write. - lib/calendar-editability.ts: getEventEditability() -> editable | rsvp-only | read-only, gating on the calendar's myRights (mayWriteAll / mayWriteOwn / mayRSVP) first, then organizer/participant identity. Subscriptions are read-only. Aliases now only refine the write-own / RSVP branches, so they can no longer over-open events (Stalwart: organizer identity does not confer calendar write; the ACL does). - collectUserCalendarAddresses(): merge identities + account aliases so an alias organizer is recognised as the user's own; calendar-app fetches the principal aliases and passes isSubscriptionCalendar through. - event-detail-popover / event-modal: derive Edit/Delete/RSVP/read-only from the resolver (three render states); read-only is new - no edit, no RSVP. Tests: calendar-editability (26 cases over rights x role x subscription x origin, incl. the alias-in-read-only regression) + calendar-participants. tsc + eslint clean. --- components/calendar/calendar-app.tsx | 26 +- components/calendar/event-detail-popover.tsx | 62 ++--- components/calendar/event-modal.tsx | 29 ++- lib/__tests__/calendar-editability.test.ts | 243 +++++++++++++++++++ lib/__tests__/calendar-participants.test.ts | 51 ++++ lib/calendar-editability.ts | 62 +++++ lib/calendar-participants.ts | 24 ++ 7 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 lib/__tests__/calendar-editability.test.ts create mode 100644 lib/calendar-editability.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 211a641f8..f674984c0 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; +import { useAccountSecurityStore } from "@/stores/account-security-store"; import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; @@ -59,7 +60,7 @@ import { ShareCollectionDialog } from "@/components/settings/share-collection-di import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; -import { getUserParticipantId } from "@/lib/calendar-participants"; +import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors"; import { debug } from "@/lib/debug"; @@ -106,7 +107,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar, removeCalendar, clearCalendarEvents, - refreshAllSubscriptions, icalSubscriptions, + refreshAllSubscriptions, icalSubscriptions, isSubscriptionCalendar, } = useCalendarStore(); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); @@ -119,9 +120,21 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const contacts = useContactStore((s) => s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; - const currentUserEmails = useMemo(() => - identities.map(id => id.email).filter(Boolean), - [identities] + // Aliases live on the principal, not on identities; fetch them so an + // alias-organized event is recognised as the user's own (see isOrganizer). + const accountEmails = useAccountSecurityStore((s) => s.emails); + const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal); + const principalFetchedRef = useRef(false); + useEffect(() => { + if (principalFetchedRef.current) return; + principalFetchedRef.current = true; + if (accountEmails.length > 0) return; // already loaded elsewhere + void fetchPrincipal(); + }, [accountEmails, fetchPrincipal]); + + const currentUserEmails = useMemo( + () => collectUserCalendarAddresses(identities.map(id => id.email), accountEmails), + [identities, accountEmails] ); const [showEventModal, setShowEventModal] = useState(false); @@ -1553,6 +1566,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} + isSubscriptionCalendar={isSubscriptionCalendar} isMobile={false} /> @@ -1662,6 +1676,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }} onMouseLeave={handleHoverLeave} currentUserEmails={currentUserEmails} + isSubscriptionCalendar={isSubscriptionCalendar} timeFormat={timeFormat} isMobile={isMobile} /> @@ -1682,6 +1697,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { onRsvp={handleRsvp} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} currentUserEmails={currentUserEmails} + isSubscriptionCalendar={isSubscriptionCalendar} isMobile={true} /> )} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index bfc613bd4..d0b0389bf 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -15,11 +15,11 @@ import { parseDuration, getEventColor } from "./event-card"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { buildRecurrenceSummary } from "./recurrence-editor"; import { - isOrganizer, getUserParticipantId, getUserStatus, getParticipantList, } from "@/lib/calendar-participants"; +import { getEventEditability } from "@/lib/calendar-editability"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; interface EventDetailPopoverProps { @@ -35,6 +35,7 @@ interface EventDetailPopoverProps { onMouseEnter?: () => void; onMouseLeave?: () => void; currentUserEmails?: string[]; + isSubscriptionCalendar?: (calendarId: string) => boolean; timeFormat?: "12h" | "24h"; isMobile?: boolean; } @@ -120,6 +121,7 @@ export function EventDetailPopover({ onMouseEnter, onMouseLeave, currentUserEmails = [], + isSubscriptionCalendar, timeFormat = "24h", isMobile, }: EventDetailPopoverProps) { @@ -156,15 +158,17 @@ export function EventDetailPopover({ const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]); const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]); - const userIsOrganizer = useMemo(() => { - if (!event.participants) return true; - return isOrganizer(event, currentUserEmails); - }, [event, currentUserEmails]); - - const isAttendeeMode = useMemo(() => { - if (!event.participants) return false; - return !event.isOrigin && !userIsOrganizer; - }, [event, userIsOrganizer]); + // Gate affordances on calendar rights, not identity (see calendar-editability). + const editability = useMemo(() => { + const calendarsById = new Map(calendar ? [[calendar.id, calendar]] : []); + return getEventEditability(event, { + calendarsById, + userCalendarAddresses: currentUserEmails, + isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false), + }); + }, [event, calendar, currentUserEmails, isSubscriptionCalendar]); + const canEditBody = editability === "editable"; + const rsvpMode = editability === "rsvp-only"; const userParticipantId = useMemo( () => getUserParticipantId(event, currentUserEmails), @@ -195,14 +199,14 @@ export function EventDetailPopover({ const tag = target?.tagName?.toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select") return; if (target?.getAttribute("contenteditable") === "true") return; - if (e.key === "e" && !noteExpanded) { + if (e.key === "e" && !noteExpanded && canEditBody) { e.preventDefault(); onEdit(); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [onClose, onEdit, noteExpanded]); + }, [onClose, onEdit, noteExpanded, canEditBody]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -483,7 +487,7 @@ export function EventDetailPopover({ {/* Quick Note */} - {!isAttendeeMode && ( + {canEditBody && (
{noteExpanded ? (
@@ -533,7 +537,7 @@ export function EventDetailPopover({ )} {/* RSVP Bar (for attendees) */} - {isAttendeeMode && onRsvp && userParticipantId && ( + {rsvpMode && onRsvp && userParticipantId && (

{t("participants.rsvp_label")} @@ -608,10 +612,12 @@ export function EventDetailPopover({

) : ( <> - + {canEditBody && ( + + )} + {canEditBody && ( + + )} )}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 5e0cfc145..3ad964dcc 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -19,6 +19,7 @@ import { getStatusCounts, buildParticipantMap, } from "@/lib/calendar-participants"; +import { getEventEditability } from "@/lib/calendar-editability"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; @@ -48,6 +49,7 @@ interface EventModalProps { onClose: () => void; onPreviewChange?: (preview: PendingEventPreview | null) => void; currentUserEmails?: string[]; + isSubscriptionCalendar?: (calendarId: string) => boolean; isMobile?: boolean; } @@ -177,6 +179,7 @@ export function EventModal({ onClose, onPreviewChange, currentUserEmails = [], + isSubscriptionCalendar, isMobile = false, }: EventModalProps) { const t = useTranslations("calendar"); @@ -193,10 +196,18 @@ export function EventModal({ return isOrganizer(event, currentUserEmails); }, [event, currentUserEmails]); - const isAttendeeMode = useMemo(() => { - if (!event || !event.participants) return false; - return !event.isOrigin && !userIsOrganizer; - }, [event, userIsOrganizer]); + // Gate affordances on calendar rights, not identity (see calendar-editability). + const editability = useMemo(() => { + if (!event) return "editable" as const; + const calendarsById = new Map(calendars.map((c) => [c.id, c])); + return getEventEditability(event, { + calendarsById, + userCalendarAddresses: currentUserEmails, + isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false), + }); + }, [event, calendars, currentUserEmails, isSubscriptionCalendar]); + const canEditBody = editability === "editable"; + const rsvpMode = editability === "rsvp-only"; const userParticipantId = useMemo(() => { if (!event) return null; @@ -601,12 +612,12 @@ export function EventModal({ } if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); - if (!isAttendeeMode) handleSave(); + if (canEditBody) handleSave(); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [onClose, handleSave, isAttendeeMode, mode, isEdit]); + }, [onClose, handleSave, canEditBody, mode, isEdit]); useEffect(() => { const modal = modalRef.current; @@ -634,7 +645,7 @@ export function EventModal({ const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0); - if (isAttendeeMode && event) { + if (rsvpMode && event) { const startD = getEventStartDate(event); const endD = getEventEndDate(event); const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; @@ -942,7 +953,7 @@ export function EventModal({ {/* Action Bar */}
- {onDelete && ( + {onDelete && canEditBody && ( showDeleteConfirm ? (
{t("form.delete_confirm")} @@ -967,7 +978,7 @@ export function EventModal({ )}
- {!showDeleteConfirm && ( + {!showDeleteConfirm && canEditBody && (
)} - {calendars.length > 1 && ( + {selectableCalendars.length > 1 && (