mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user