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..b7f947ca5 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, canCreateEventsIn } 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; @@ -261,8 +272,18 @@ export function EventModal({ if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; const defaultCal = calendars.find(c => c.isDefault); - return defaultCal?.id || calendars[0]?.id || ""; + // Never default new events into a subscription / read-only calendar (#762). + return defaultCal?.id + || calendars.find(c => canCreateEventsIn(c, isSubscriptionCalendar))?.id + || calendars[0]?.id || ""; }); + + // Calendars offered as create/move targets: writable, non-subscription, plus + // the event's current calendar so an in-place edit never blanks the select. + const selectableCalendars = useMemo( + () => calendars.filter(c => canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId), + [calendars, isSubscriptionCalendar, calendarId] + ); const [recurrence, setRecurrence] = useState(() => { if (!event?.recurrenceRules?.length) return "none"; const rule = event.recurrenceRules[0]; @@ -601,12 +622,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 +655,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 +963,7 @@ export function EventModal({ {/* Action Bar */}
- {onDelete && ( + {onDelete && canEditBody && ( showDeleteConfirm ? (
{t("form.delete_confirm")} @@ -967,7 +988,7 @@ export function EventModal({ )}
- {!showDeleteConfirm && ( + {!showDeleteConfirm && canEditBody && (
)} - {calendars.length > 1 && ( + {selectableCalendars.length > 1 && (