mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user