fix(calendar): exclude subscription/read-only calendars from event creation (#762)

The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.

- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
  subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
  event's own calendar when editing), and new events never default into a
  subscription / read-only calendar.

+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.
This commit is contained in:
Stefan Hildebrandt
2026-08-10 12:05:44 +02:00
parent 3898d0c1cb
commit 06784581aa
3 changed files with 47 additions and 4 deletions
+14 -4
View File
@@ -19,7 +19,7 @@ import {
getStatusCounts,
buildParticipantMap,
} from "@/lib/calendar-participants";
import { getEventEditability } from "@/lib/calendar-editability";
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";
@@ -272,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];
@@ -1165,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
@@ -1173,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>
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import type { CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
import {
getEventEditability,
canCreateEventsIn,
eventHasNoOwner,
type EventEditability,
type EditabilityContext,
@@ -108,6 +109,24 @@ function ctx(
};
}
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);
+14
View File
@@ -12,6 +12,20 @@ export interface EditabilityContext {
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;