Merge pull request #770 from hildebrandttk/fix/calendar-alias-organizer-editable

fix(calendar): rights-first event editability, incl. alias organizers
This commit is contained in:
Linus Rath
2026-08-11 09:54:55 +02:00
committed by GitHub
7 changed files with 502 additions and 44 deletions
+21 -5
View File
@@ -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}
/>
</div>
@@ -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}
/>
)}
+35 -27
View File
@@ -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({
</div>
{/* Quick Note */}
{!isAttendeeMode && (
{canEditBody && (
<div className="px-4 py-2 border-t border-border">
{noteExpanded ? (
<div className="space-y-2">
@@ -533,7 +537,7 @@ export function EventDetailPopover({
)}
{/* RSVP Bar (for attendees) */}
{isAttendeeMode && onRsvp && userParticipantId && (
{rsvpMode && onRsvp && userParticipantId && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
@@ -608,10 +612,12 @@ export function EventDetailPopover({
</div>
) : (
<>
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
{canEditBody && (
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -623,15 +629,17 @@ export function EventDetailPopover({
{t("events.duplicate")}
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 text-xs text-red-600 dark:text-red-400"
title={t("events.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
{canEditBody && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 text-xs text-red-600 dark:text-red-400"
title={t("events.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</>
)}
</div>
+33 -12
View File
@@ -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<RecurrenceOption>(() => {
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 */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-1">
{onDelete && (
{onDelete && canEditBody && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
@@ -967,7 +988,7 @@ export function EventModal({
</Button>
)}
</div>
{!showDeleteConfirm && (
{!showDeleteConfirm && canEditBody && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
@@ -1154,7 +1175,7 @@ export function EventModal({
</div>
)}
{calendars.length > 1 && (
{selectableCalendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select
@@ -1162,7 +1183,7 @@ export function EventModal({
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{calendars.map((cal) => (
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
+262
View File
@@ -0,0 +1,262 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
import {
getEventEditability,
canCreateEventsIn,
eventHasNoOwner,
type EventEditability,
type EditabilityContext,
} from '@/lib/calendar-editability';
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
'@type': 'Event',
id: 'ev1',
uid: 'uid-ev1',
calendarIds: { cal1: true },
title: 'Test Event',
description: '',
descriptionContentType: 'text/plain',
start: '2026-03-01T10:00:00',
duration: 'PT1H',
timeZone: 'UTC',
showWithoutTime: false,
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
keywords: null,
categories: null,
color: null,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
utcStart: null,
utcEnd: null,
isDraft: false,
isOrigin: true,
sequence: 0,
created: '2026-03-01T09:00:00Z',
updated: '2026-03-01T09:00:00Z',
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
...overrides,
};
}
function makeRights(overrides: Partial<CalendarRights> = {}): CalendarRights {
return {
mayReadFreeBusy: false,
mayReadItems: false,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayShare: false,
mayDelete: false,
...overrides,
};
}
const ALL_RIGHTS = makeRights({
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
});
const READ_ONLY = makeRights({ mayReadFreeBusy: true, mayReadItems: true });
const WRITE_OWN = makeRights({
mayReadFreeBusy: true, mayReadItems: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true,
});
const RSVP_ONLY = makeRights({
mayReadFreeBusy: true, mayReadItems: true, mayUpdatePrivate: true, mayRSVP: true,
});
const SELF = 'alice@example.com';
const ALIAS = 'info@example.com';
const OTHER = 'carol@example.com';
const owner = (email: string): Partial<CalendarParticipant> => ({
'@type': 'Participant', name: email, email, roles: { owner: true, attendee: true },
sendTo: { imip: `mailto:${email}` }, participationStatus: 'accepted', kind: 'individual',
});
const attendee = (email: string): Partial<CalendarParticipant> => ({
'@type': 'Participant', name: email, email, roles: { attendee: true },
sendTo: { imip: `mailto:${email}` }, participationStatus: 'needs-action', kind: 'individual',
});
/** Build a context whose single calendar `cal1` carries the given rights. */
function ctx(
rights: CalendarRights | null,
addresses: string[] = [SELF],
subscriptions: string[] = [],
): EditabilityContext {
const calendarsById = new Map(rights ? [['cal1', { myRights: rights }]] : []);
return {
calendarsById,
userCalendarAddresses: addresses,
isSubscriptionCalendar: (id) => subscriptions.includes(id),
};
}
describe('canCreateEventsIn (issue #762)', () => {
const sub = (id: string) => id === 'sub';
it('allows a writable, non-subscription calendar', () => {
expect(canCreateEventsIn({ id: 'c', myRights: ALL_RIGHTS }, sub)).toBe(true);
expect(canCreateEventsIn({ id: 'c', myRights: WRITE_OWN }, sub)).toBe(true);
});
it('rejects a read-only calendar', () => {
expect(canCreateEventsIn({ id: 'c', myRights: READ_ONLY }, sub)).toBe(false);
expect(canCreateEventsIn({ id: 'c', myRights: RSVP_ONLY }, sub)).toBe(false);
});
it('rejects an iCal subscription even when its Stalwart rights are writable', () => {
expect(canCreateEventsIn({ id: 'sub', myRights: ALL_RIGHTS }, sub)).toBe(false);
});
it('is permissive when myRights is absent (local/birthday calendars)', () => {
expect(canCreateEventsIn({ id: 'c', myRights: undefined as never }, sub)).toBe(true);
});
});
describe('eventHasNoOwner', () => {
it('is true for a plain event with no participants/organizer', () => {
expect(eventHasNoOwner(makeEvent())).toBe(true);
});
it('is false when an owner-role participant exists', () => {
expect(eventHasNoOwner(makeEvent({ participants: { a: owner(SELF) } as never }))).toBe(false);
});
it('is false when an event-level organizer address exists', () => {
expect(eventHasNoOwner(makeEvent({ organizerCalendarAddress: `mailto:${OTHER}` }))).toBe(false);
});
it('is false when a reply destination exists', () => {
expect(eventHasNoOwner(makeEvent({ replyTo: { imip: `mailto:${OTHER}` } }))).toBe(false);
});
it('is true when only non-owner participants exist', () => {
expect(eventHasNoOwner(makeEvent({ participants: { b: attendee(OTHER) } as never }))).toBe(true);
});
});
describe('getEventEditability - calendar rights gate', () => {
it('mayWriteAll -> editable even when the user is not the organizer', () => {
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
expect(getEventEditability(ev, ctx(ALL_RIGHTS))).toBe('editable');
});
it('read-only calendar -> read-only even for an event the user organizes', () => {
const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
participants: { a: owner(SELF) } as never });
expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
});
it('mayWriteOwn + user is organizer -> editable', () => {
const ev = makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never });
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
});
it('mayWriteOwn + user is a mere attendee -> rsvp-only (cannot edit body)', () => {
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('rsvp-only');
});
it('mayWriteOwn + ownerless event -> editable', () => {
const ev = makeEvent({ participants: { b: attendee(SELF) } as never });
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
});
it('RSVP-only rights + participant -> rsvp-only', () => {
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('rsvp-only');
});
it('RSVP-only rights + user is NOT a participant -> read-only', () => {
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
participants: { a: owner(OTHER), b: attendee('dave@example.com') } as never });
expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('read-only');
});
it('no write / no rsvp rights -> read-only', () => {
const ev = makeEvent({ participants: { a: owner(SELF) } as never });
expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
});
});
describe('getEventEditability - subscription calendars', () => {
it('an event in a client-side iCal subscription is read-only regardless of rights/organizer', () => {
const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
participants: { a: owner(SELF) } as never });
// Even if the (irrelevant) rights map says all-writable, the subscription wins.
expect(getEventEditability(ev, ctx(ALL_RIGHTS, [SELF], ['cal1']))).toBe('read-only');
});
});
describe('getEventEditability - unknown calendar rights', () => {
it('own origin copy is editable when rights are not yet loaded', () => {
expect(getEventEditability(makeEvent({ isOrigin: true }), ctx(null))).toBe('editable');
});
it('a delivered (non-origin) copy stays read-only until rights load', () => {
expect(getEventEditability(makeEvent({ isOrigin: false }), ctx(null))).toBe('read-only');
});
});
describe('getEventEditability - alias over-permissiveness regression', () => {
it('organizer == account alias in a READ-ONLY shared calendar -> read-only (not editable)', () => {
// The alias branch would have matched isOrganizer and opened the editor; the
// rights gate must keep it read-only.
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${ALIAS}`,
participants: { a: owner(ALIAS) } as never });
expect(getEventEditability(ev, ctx(READ_ONLY, [SELF, ALIAS]))).toBe('read-only');
});
it('organizer == account alias in a WRITE-OWN calendar -> editable (legitimate alias edit)', () => {
const ev = makeEvent({ organizerCalendarAddress: `mailto:${ALIAS}`,
participants: { a: owner(ALIAS) } as never });
expect(getEventEditability(ev, ctx(WRITE_OWN, [SELF, ALIAS]))).toBe('editable');
});
});
describe('getEventEditability - multi-calendar membership', () => {
it('requires the right on EVERY calendar (a read-only member blocks edit)', () => {
const ev = makeEvent({ calendarIds: { cal1: true, cal2: true },
participants: { a: owner(SELF) } as never });
const context: EditabilityContext = {
calendarsById: new Map([
['cal1', { myRights: ALL_RIGHTS }],
['cal2', { myRights: READ_ONLY }],
]),
userCalendarAddresses: [SELF],
isSubscriptionCalendar: () => false,
};
expect(getEventEditability(ev, context)).toBe('read-only');
});
});
describe('getEventEditability - matrix sanity', () => {
const cases: Array<[string, CalendarRights, boolean, EventEditability]> = [
['writeAll/organizer', ALL_RIGHTS, true, 'editable'],
['writeAll/attendee', ALL_RIGHTS, false, 'editable'],
['writeOwn/organizer', WRITE_OWN, true, 'editable'],
['writeOwn/attendee', WRITE_OWN, false, 'rsvp-only'],
['rsvpOnly/attendee', RSVP_ONLY, false, 'rsvp-only'],
['readOnly/organizer', READ_ONLY, true, 'read-only'],
['readOnly/attendee', READ_ONLY, false, 'read-only'],
];
it.each(cases)('%s -> %s', (_label, rights, userIsOrganizer, expected) => {
const ev = userIsOrganizer
? makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never })
: makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
expect(getEventEditability(ev, ctx(rights))).toBe(expected);
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import {
isOrganizer,
collectUserCalendarAddresses,
getUserParticipantId,
getUserStatus,
getParticipantList,
@@ -179,6 +180,56 @@ describe('isOrganizer', () => {
event.organizerCalendarAddress = 'mailto:someoneelse@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
// Regression: an event organized under one of the account's ALIAS addresses
// is still the user's own event and must be recognised as such (otherwise the
// calendar UI flips it to a read-only invite). The primary address alone does
// not match; the alias has to be part of the user's address list.
it('does NOT match an alias organizer when only the primary address is known', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:info@example.com'; // an account alias
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
it('matches an alias organizer once the alias is included in the address list', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:info@example.com';
const addresses = collectUserCalendarAddresses(['alice@example.com'], ['info@example.com']);
expect(isOrganizer(event, addresses)).toBe(true);
});
it('matches an alias owner participant once the alias is included', () => {
const event = makeEvent({
org: { ...orgParticipant, email: 'info@example.com', sendTo: { imip: 'mailto:info@example.com' } },
});
const addresses = collectUserCalendarAddresses(['alice@example.com'], ['INFO@example.com']);
expect(isOrganizer(event, addresses)).toBe(true);
});
});
describe('collectUserCalendarAddresses', () => {
it('merges identity and alias addresses', () => {
expect(collectUserCalendarAddresses(['alice@example.com'], ['info@example.com', 'sales@example.com']))
.toEqual(['alice@example.com', 'info@example.com', 'sales@example.com']);
});
it('de-duplicates case-insensitively, keeping the first-seen casing', () => {
expect(collectUserCalendarAddresses(['Alice@Example.com'], ['alice@example.com', 'INFO@example.com']))
.toEqual(['Alice@Example.com', 'INFO@example.com']);
});
it('drops empty, undefined and whitespace-only entries', () => {
expect(collectUserCalendarAddresses(['alice@example.com', '', ' ', undefined, null], []))
.toEqual(['alice@example.com']);
});
it('trims surrounding whitespace', () => {
expect(collectUserCalendarAddresses([' alice@example.com '], [])).toEqual(['alice@example.com']);
});
it('returns an empty array when given no addresses', () => {
expect(collectUserCalendarAddresses([], [])).toEqual([]);
});
});
describe('getUserParticipantId', () => {
+76
View File
@@ -0,0 +1,76 @@
import type { CalendarEvent, Calendar, CalendarRights } from '@/lib/jmap/types';
import { isOrganizer, getUserParticipantId } from '@/lib/calendar-participants';
export type EventEditability = 'editable' | 'rsvp-only' | 'read-only';
export interface EditabilityContext {
/** Viewer's calendars by id; only their `myRights` is read. */
calendarsById: ReadonlyMap<string, Pick<Calendar, 'myRights'>>;
/** Identities + aliases; only refines owner/participant detection, never the gate. */
userCalendarAddresses: string[];
/** Client-side external iCal (webcal/ICS) subscriptions - always read-only. */
isSubscriptionCalendar: (calendarId: string) => boolean;
}
/**
* Whether new events may be created in / moved into this calendar: writable and
* not a (read-only) iCal subscription. Bulwark subscriptions are real Stalwart
* calendars with writable myRights, so the flag must be checked too (issue #762).
*/
export function canCreateEventsIn(
calendar: Pick<Calendar, 'id' | 'myRights'>,
isSubscriptionCalendar?: (calendarId: string) => boolean,
): boolean {
if (isSubscriptionCalendar?.(calendar.id)) return false;
const r = calendar.myRights;
return !r || r.mayWriteAll || r.mayWriteOwn;
}
/** Ownerless (plain, non-scheduled) events are editable under `mayWriteOwn`. */
export function eventHasNoOwner(event: CalendarEvent): boolean {
if (event.organizerCalendarAddress) return false;
if (event.replyTo && Object.keys(event.replyTo).length > 0) return false;
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (p.roles?.owner) return false;
}
}
return true;
}
/**
* Editability gated on the calendar's `myRights` FIRST, then identity - so
* read-only/subscription calendars stay read-only and an alias organizer can't
* over-open events the user can't write. (Stalwart: organizer identity does not
* confer calendar write; the ACL does.)
*/
export function getEventEditability(
event: CalendarEvent,
ctx: EditabilityContext,
): EventEditability {
const calIds = Object.keys(event.calendarIds ?? {});
// Subscriptions have no write path and myRights can't describe them.
if (calIds.some((id) => ctx.isSubscriptionCalendar(id))) return 'read-only';
const rights = calIds
.map((id) => ctx.calendarsById.get(id)?.myRights)
.filter((r): r is CalendarRights => Boolean(r));
// Rights not loaded: allow only the user's own origin copy for now.
if (rights.length === 0) return event.isOrigin ? 'editable' : 'read-only';
// Every calendar must grant the right (a read-only member blocks edit).
const may = (k: keyof CalendarRights) => rights.every((r) => r[k]);
const isOwner =
isOrganizer(event, ctx.userCalendarAddresses) || eventHasNoOwner(event);
if (may('mayWriteAll')) return 'editable';
if (may('mayWriteOwn') && isOwner) return 'editable';
const isParticipant =
getUserParticipantId(event, ctx.userCalendarAddresses) != null;
if (may('mayRSVP') && isParticipant) return 'rsvp-only';
return 'read-only';
}
+24
View File
@@ -62,6 +62,30 @@ export function getEventOrganizerEmails(event: CalendarEvent): string[] {
return emails.filter(Boolean);
}
/**
* Merge the user's calendar addresses (identities + account aliases) so
* isOrganizer() recognises alias-organized events as the user's own. Identities
* alone carry only one address each, so an alias organizer looked foreign.
* De-duplicated case-insensitively (first casing kept); blanks dropped.
*/
export function collectUserCalendarAddresses(
...groups: Array<ReadonlyArray<string | null | undefined>>
): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const group of groups) {
for (const raw of group) {
const trimmed = raw?.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(trimmed);
}
}
return out;
}
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
if (userEmails.length === 0) return false;
const lower = userEmails.map(e => e.toLowerCase());