mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762)
This commit is contained in:
@@ -95,22 +95,22 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
|
||||
### Invitations / scheduling
|
||||
|
||||
- [ ] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity`
|
||||
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
|
||||
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
|
||||
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
|
||||
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
|
||||
|
||||
- [ ] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing`
|
||||
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
|
||||
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
|
||||
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
|
||||
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
|
||||
|
||||
- [ ] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial`
|
||||
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
|
||||
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
|
||||
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
|
||||
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
|
||||
|
||||
- [ ] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug`
|
||||
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
|
||||
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
|
||||
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
|
||||
- Fix hint: drop the `..` check.
|
||||
@@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
|
||||
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
|
||||
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
|
||||
|
||||
- [ ] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug`
|
||||
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
|
||||
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
|
||||
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
|
||||
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
|
||||
|
||||
@@ -532,8 +532,6 @@ export interface CalendarRights {
|
||||
mayRSVP?: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete?: boolean;
|
||||
// Legacy short flag still read by the sidebar drawer.
|
||||
mayWrite?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
visible: boolean;
|
||||
@@ -90,12 +91,8 @@ export function CalendarSidebarDrawer({
|
||||
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
|
||||
|
||||
const sharedCalendars = calendars.filter((cal) => cal.isShared);
|
||||
const myCalendars = calendars.filter(
|
||||
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
|
||||
);
|
||||
const subscribed = calendars.filter(
|
||||
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
|
||||
);
|
||||
const myCalendars = calendars.filter((cal) => !cal.isShared && isWritableCalendar(cal));
|
||||
const subscribed = calendars.filter((cal) => !cal.isShared && !isWritableCalendar(cal));
|
||||
|
||||
const sectionProps = {
|
||||
hiddenSet,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
getUserParticipantId,
|
||||
isOrganizer,
|
||||
} from '../../lib/calendar-participants';
|
||||
import { getEventEditability } from '../../lib/calendar-editability';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
@@ -56,6 +57,8 @@ interface EventDetailSheetProps {
|
||||
timeFormat?: TimeFormat;
|
||||
// Login address + identities + aliases; finds "me" among the participants.
|
||||
currentUserEmails?: string[];
|
||||
// Client-side iCal subscriptions are always read-only.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
onClose: () => void;
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
@@ -112,6 +115,7 @@ export function EventDetailSheet({
|
||||
calendars,
|
||||
timeFormat,
|
||||
currentUserEmails = [],
|
||||
isSubscriptionCalendar,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -180,7 +184,18 @@ export function EventDetailSheet({
|
||||
// aliases) and the caller wired an onRsvp handler.
|
||||
const myParticipantId = getUserParticipantId(event, currentUserEmails);
|
||||
const userIsOrganizer = isOrganizer(event, currentUserEmails);
|
||||
const canRsvp = Boolean(onRsvp && myParticipantId && !userIsOrganizer);
|
||||
// Gate affordances on calendar rights first, then identity: read-only /
|
||||
// subscription calendars never offer Edit/Delete, RSVP-only events offer
|
||||
// just the reply buttons.
|
||||
const editability = getEventEditability(event, {
|
||||
calendarsById: new Map(calendars.map((cal) => [cal.id, cal])),
|
||||
userCalendarAddresses: currentUserEmails,
|
||||
isSubscriptionCalendar: isSubscriptionCalendar ?? (() => false),
|
||||
});
|
||||
const canEdit = editability === 'editable';
|
||||
const canRsvp = Boolean(
|
||||
onRsvp && myParticipantId && !userIsOrganizer && editability !== 'read-only',
|
||||
);
|
||||
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
@@ -339,30 +354,32 @@ export function EventDetailSheet({
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{onEdit && (
|
||||
<ActionButton
|
||||
icon={<Pencil size={18} color={c.text} />}
|
||||
label="Edit"
|
||||
onPress={() => onEdit(event)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ActionButton
|
||||
icon={<Copy size={18} color={c.text} />}
|
||||
label="Duplicate"
|
||||
onPress={() => onDuplicate(event)}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
icon={<Trash2 size={18} color={c.error} />}
|
||||
label="Delete"
|
||||
onPress={() => onDelete(event)}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{(canEdit || onDuplicate) && (
|
||||
<View style={styles.actions}>
|
||||
{onEdit && canEdit && (
|
||||
<ActionButton
|
||||
icon={<Pencil size={18} color={c.text} />}
|
||||
label="Edit"
|
||||
onPress={() => onEdit(event)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ActionButton
|
||||
icon={<Copy size={18} color={c.text} />}
|
||||
label="Duplicate"
|
||||
onPress={() => onDuplicate(event)}
|
||||
/>
|
||||
)}
|
||||
{onDelete && canEdit && (
|
||||
<ActionButton
|
||||
icon={<Trash2 size={18} color={c.error} />}
|
||||
label="Delete"
|
||||
onPress={() => onDelete(event)}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
|
||||
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
@@ -82,6 +83,8 @@ interface EventModalProps {
|
||||
// Login address + identities + aliases; the first one is the organizer
|
||||
// address of invitations sent from this editor.
|
||||
currentUserEmails?: string[];
|
||||
// Client-side iCal subscriptions are read-only targets (#762).
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onClose: () => void;
|
||||
@@ -162,6 +165,7 @@ export function EventModal({
|
||||
defaultDate,
|
||||
defaultCalendarId,
|
||||
currentUserEmails = [],
|
||||
isSubscriptionCalendar,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
@@ -177,9 +181,11 @@ export function EventModal({
|
||||
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
|
||||
// New events land in the explicitly requested calendar, else the account's
|
||||
// default calendar, else the first one.
|
||||
// Never default new events into a subscription / read-only calendar (#762).
|
||||
const fallbackCalendarId =
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|
||||
|| calendars.find((cal) => canCreateEventsIn(cal, isSubscriptionCalendar))?.id
|
||||
|| calendars[0]?.id
|
||||
|| '';
|
||||
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
||||
@@ -227,12 +233,7 @@ export function EventModal({
|
||||
setAllDay(false);
|
||||
setStart(d);
|
||||
setEnd(addHours(d, 1));
|
||||
setCalendarId(
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars[0]?.id
|
||||
|| '',
|
||||
);
|
||||
setCalendarId(fallbackCalendarId);
|
||||
setAttendees([]);
|
||||
setRecurrence('none');
|
||||
setCustomRule(null);
|
||||
@@ -241,7 +242,7 @@ export function EventModal({
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars, currentUserEmails]);
|
||||
}, [visible, event, defaultDate, fallbackCalendarId, calendars, currentUserEmails]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (allDay) {
|
||||
@@ -349,6 +350,19 @@ export function EventModal({
|
||||
};
|
||||
|
||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||
// Calendars offered as create/move targets: writable, non-subscription, in
|
||||
// the same account as the event being edited (a cross-account move would
|
||||
// need delete + recreate), plus the event's current calendar so an
|
||||
// in-place edit never blanks the picker (#762).
|
||||
const selectableCalendars = React.useMemo(() => {
|
||||
const eventAccount = event ? (event.accountId ?? null) : null;
|
||||
return calendars.filter((c) => {
|
||||
if (c.id === calendarId) return true;
|
||||
if (!canCreateEventsIn(c, isSubscriptionCalendar)) return false;
|
||||
if (event && (c.accountId ?? null) !== eventAccount) return false;
|
||||
return true;
|
||||
});
|
||||
}, [calendars, calendarId, event, isSubscriptionCalendar]);
|
||||
const recurrenceLabel =
|
||||
recurrence === 'custom'
|
||||
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
|
||||
@@ -430,7 +444,7 @@ export function EventModal({
|
||||
</Pressable>
|
||||
{calendarOpen && (
|
||||
<View style={styles.popover}>
|
||||
{calendars.map((c) => (
|
||||
{selectableCalendars.map((c) => (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
style={[
|
||||
|
||||
@@ -12,6 +12,7 @@ import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
@@ -49,7 +50,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const writable = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
() => calendars.filter((cal) => isWritableCalendar(cal)),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
interface TasksSheetProps {
|
||||
@@ -82,7 +83,7 @@ export function TasksSheet({
|
||||
});
|
||||
|
||||
const writableCalendars = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
() => calendars.filter((cal) => isWritableCalendar(cal)),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type InvitationMethod,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses';
|
||||
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||
|
||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||
@@ -32,6 +34,7 @@ export function CalendarInvitationBanner({ email }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
// Login address + identities + aliases, so invitations addressed to an
|
||||
@@ -95,7 +98,15 @@ export function CalendarInvitationBanner({ email }: Props) {
|
||||
const me = findParticipantByEmail(event, currentUserEmails);
|
||||
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
|
||||
|
||||
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
||||
// Import into the account's default calendar; never into a shared calendar,
|
||||
// an iCal subscription (the next feed sync would delete the event) or a
|
||||
// read-only one. Fall back to the first calendar events may be created in.
|
||||
const isSubscriptionCalendar = (id: string) =>
|
||||
subscriptions.some((s) => s.calendarId === id);
|
||||
const candidates = calendars.filter(
|
||||
(cal) => !cal.isShared && canCreateEventsIn(cal, isSubscriptionCalendar),
|
||||
);
|
||||
const writableCalendar = candidates.find((cal) => cal.isDefault) ?? candidates[0];
|
||||
|
||||
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
|
||||
if (busy || !writableCalendar) return;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import {
|
||||
canCreateEventsIn,
|
||||
eventHasNoOwner,
|
||||
getEventEditability,
|
||||
isWritableCalendar,
|
||||
} from '../calendar-editability';
|
||||
|
||||
const base: CalendarEvent = {
|
||||
id: 'ev',
|
||||
uid: 'u',
|
||||
title: 'T',
|
||||
start: '2026-03-01T09:00:00',
|
||||
calendarIds: { 'cal-1': true },
|
||||
};
|
||||
|
||||
function ctx(rights: Calendar['myRights'] | undefined, subs: string[] = []) {
|
||||
return {
|
||||
calendarsById: new Map([['cal-1', { myRights: rights }]]),
|
||||
userCalendarAddresses: ['me@example.com', 'alias@example.com'],
|
||||
isSubscriptionCalendar: (id: string) => subs.includes(id),
|
||||
};
|
||||
}
|
||||
|
||||
describe('isWritableCalendar / canCreateEventsIn', () => {
|
||||
it('treats missing rights as writable and never reads mayWrite', () => {
|
||||
expect(isWritableCalendar({})).toBe(true);
|
||||
expect(isWritableCalendar({ myRights: { mayWriteOwn: true } })).toBe(true);
|
||||
expect(isWritableCalendar({ myRights: { mayReadItems: true } })).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes iCal subscriptions even when the calendar is writable (#762)', () => {
|
||||
const cal = { id: 'cal-1', myRights: { mayWriteAll: true } };
|
||||
expect(canCreateEventsIn(cal)).toBe(true);
|
||||
expect(canCreateEventsIn(cal, (id) => id === 'cal-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventEditability', () => {
|
||||
it('is read-only on a subscription calendar regardless of rights', () => {
|
||||
expect(getEventEditability(base, ctx({ mayWriteAll: true }, ['cal-1']))).toBe('read-only');
|
||||
});
|
||||
|
||||
it('is editable with mayWriteAll', () => {
|
||||
expect(getEventEditability(base, ctx({ mayWriteAll: true }))).toBe('editable');
|
||||
});
|
||||
|
||||
it('is editable with mayWriteOwn only for own/ownerless events (alias organizer counts)', () => {
|
||||
expect(getEventEditability(base, ctx({ mayWriteOwn: true }))).toBe('editable');
|
||||
const foreign = { ...base, organizerCalendarAddress: 'mailto:other@example.com' };
|
||||
expect(getEventEditability(foreign, ctx({ mayWriteOwn: true }))).toBe('read-only');
|
||||
const mine = { ...base, organizerCalendarAddress: 'mailto:alias@example.com' };
|
||||
expect(getEventEditability(mine, ctx({ mayWriteOwn: true }))).toBe('editable');
|
||||
});
|
||||
|
||||
it('is rsvp-only for a participant with mayRSVP but no write right', () => {
|
||||
const invited = {
|
||||
...base,
|
||||
organizerCalendarAddress: 'mailto:other@example.com',
|
||||
participants: { p1: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true } } },
|
||||
};
|
||||
expect(getEventEditability(invited, ctx({ mayRSVP: true }))).toBe('rsvp-only');
|
||||
expect(getEventEditability(invited, ctx({ mayReadItems: true }))).toBe('read-only');
|
||||
});
|
||||
|
||||
it('falls back on ownership when rights are not loaded', () => {
|
||||
expect(getEventEditability(base, ctx(undefined))).toBe('editable');
|
||||
expect(getEventEditability({ ...base, isShared: true }, ctx(undefined))).toBe('read-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventHasNoOwner', () => {
|
||||
it('detects plain events', () => {
|
||||
expect(eventHasNoOwner(base)).toBe(true);
|
||||
expect(eventHasNoOwner({ ...base, replyTo: { imip: 'mailto:x@y' } })).toBe(false);
|
||||
expect(eventHasNoOwner({ ...base, participants: { a: { roles: { owner: true } } } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayWrite: false,
|
||||
mayDelete: false,
|
||||
mayRSVP: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CalendarEvent, Calendar, CalendarRights } from '../api/types';
|
||||
import { isOrganizer, getUserParticipantId } from './calendar-participants';
|
||||
|
||||
// Port of the webmail's lib/calendar-editability.ts.
|
||||
|
||||
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 the viewer may write to this calendar at all. Stalwart never sends
|
||||
* a `mayWrite` flag; the RFC-style rights are `mayWriteAll` / `mayWriteOwn`.
|
||||
* Missing rights (older server, own calendars) count as writable.
|
||||
*/
|
||||
export function isWritableCalendar(calendar: Pick<Calendar, 'myRights'>): boolean {
|
||||
const r = calendar.myRights;
|
||||
return !r || !!r.mayWriteAll || !!r.mayWriteOwn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return isWritableCalendar(calendar);
|
||||
}
|
||||
|
||||
/** 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 (own calendars on servers that omit them): the user's
|
||||
// own account is writable; a shared calendar without rights is not.
|
||||
if (rights.length === 0) return event.isShared ? 'read-only' : 'editable';
|
||||
|
||||
// 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';
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID }
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
|
||||
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
@@ -324,9 +325,20 @@ export default function CalendarScreen() {
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// Client-side iCal subscriptions mirror a remote feed into a local
|
||||
// calendar; edits there would be wiped by the next sync, so they're
|
||||
// read-only targets everywhere (#762).
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const isSubscriptionCalendar = React.useCallback(
|
||||
(calendarId: string) => subscriptions.some((s) => s.calendarId === calendarId),
|
||||
[subscriptions],
|
||||
);
|
||||
|
||||
const isReadOnlyEvent = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
|
||||
[],
|
||||
(event: CalendarEvent) =>
|
||||
!!event.calendarIds?.[BIRTHDAY_CALENDAR_ID] ||
|
||||
Object.keys(event.calendarIds ?? {}).some(isSubscriptionCalendar),
|
||||
[isSubscriptionCalendar],
|
||||
);
|
||||
|
||||
// Editing a series member opens the editor directly; the this/future/all
|
||||
@@ -630,6 +642,7 @@ export default function CalendarScreen() {
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -655,6 +668,7 @@ export default function CalendarScreen() {
|
||||
calendars={eventCalendars}
|
||||
defaultDate={modalDate}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
|
||||
Reference in New Issue
Block a user