From 1eaae8ec30173611947c6aac236aae0ed3b47575 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 11:33:37 +0100
Subject: [PATCH] fix: keep another account's event in that account when it's
edited or deleted
---
.../calendar-app-login-series-ux-pim.test.tsx | 93 +++++++++++++++++++
...vent-modal-login-calendars-ux-pim.test.tsx | 54 +++++++++++
components/calendar/calendar-app.tsx | 12 +--
components/calendar/event-modal.tsx | 11 ++-
4 files changed, 162 insertions(+), 8 deletions(-)
create mode 100644 components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx
create mode 100644 components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx
diff --git a/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx
new file mode 100644
index 000000000..10e31b832
--- /dev/null
+++ b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useTaskStore } from '@/stores/task-store';
+import { useAccountStore } from '@/stores/account-store';
+
+// pim-12 review: a series of another login was deleted with the active
+// login's addresses, so a meeting the user organizes as that login counted as
+// someone else's: no "attendees will be told" note, and truncating the series
+// didn't tell them. The event's own login's addresses decide now.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useIsMobile: () => false,
+ useIsDesktop: () => true,
+ useIsFullDesktop: () => true,
+ useIsTablet: () => false,
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
+vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
+vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
+vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
+vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
+
+import { CalendarApp } from '../calendar-app';
+
+vi.setConfig({ testTimeout: 30000 });
+
+const RIGHTS = {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+};
+const calendars = [{
+ id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
+ isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
+ defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
+}, {
+ id: 'login-b:bcal', originalId: 'bcal', localAccountId: 'login-b', name: 'B Personal', color: '#654321', isDefault: true,
+ sortOrder: 0, description: null, isSubscribed: true, isVisible: true, includeInAvailability: 'all',
+ defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
+}] as unknown as Calendar[];
+const series = {
+ id: 'login-b:e1', originalId: 'e1', localAccountId: 'login-b', uid: 'u1', '@type': 'Event', title: 'Team sync',
+ description: '', start: '2026-10-12T10:00:00', duration: 'PT1H', timeZone: null, showWithoutTime: false,
+ calendarIds: { 'login-b:bcal': true }, organizerCalendarAddress: 'mailto:b@example.org',
+ participants: {
+ org: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', roles: { owner: true }, participationStatus: 'accepted' },
+ att: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', roles: { attendee: true }, participationStatus: 'needs-action' },
+ },
+ alerts: null, recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], isOrigin: false, status: 'confirmed',
+} as unknown as CalendarEvent;
+const client = {
+ getAccountId: () => 'acc',
+ getMaxParticipantsPerEvent: () => null,
+ supportsCalendars: () => true,
+ queryCalendarEvents: vi.fn().mockResolvedValue([]),
+ getCalendarEvent: vi.fn().mockResolvedValue(null),
+ getCalendarTasks: vi.fn().mockResolvedValue([]),
+};
+
+beforeEach(() => {
+ useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc', username: 'me@example.com' } as never);
+ useAccountStore.setState({
+ accounts: [{ id: 'acc', email: 'me@example.com', username: 'me@example.com' }, { id: 'login-b', email: 'b@example.org', username: 'b@example.org' }],
+ activeAccountId: 'acc',
+ } as never);
+ useCalendarStore.setState({
+ calendars, events: [series], supportsCalendar: true, selectedCalendarIds: ['cal', 'login-b:bcal'], viewMode: 'week',
+ selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
+ fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
+ createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(),
+ } as never);
+ useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: false, proInterface: false } as never);
+ useTaskStore.setState({ tasks: [] } as never);
+});
+
+describe('CalendarApp series of another login', () => {
+ it("tells attendees when the user organizes it as that login", async () => {
+ render();
+ const chips = await screen.findAllByText('Team sync');
+ fireEvent.contextMenu(chips[0]);
+ fireEvent.click(await screen.findByText('events.delete'));
+ expect(await screen.findByText('participants.cancel_notification')).toBeInTheDocument();
+ });
+});
diff --git a/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx
new file mode 100644
index 000000000..63f1aac45
--- /dev/null
+++ b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { EventModal } from '../event-modal';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// With two logins the editor lists every login's calendars. Moving an existing
+// event into another login's calendar sent that calendar's raw id to the
+// event's own server, which either moved it into an unrelated calendar there
+// or failed. An existing event now moves only within its own login; a new
+// event can still go anywhere.
+
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+
+const rights = {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+};
+const calendars = [
+ { id: 'a-personal', name: 'A Personal', color: '#123456', isDefault: true, myRights: rights },
+ { id: 'a-work', name: 'A Work', color: '#123456', isDefault: false, myRights: rights },
+ { id: 'login-b:b', name: 'B Personal', color: '#654321', isDefault: true, myRights: rights, localAccountId: 'login-b', originalId: 'b' },
+] as unknown as Calendar[];
+
+const event = {
+ id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
+ duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { 'a-personal': true },
+ participants: null, alerts: null, recurrenceRules: null,
+} as unknown as CalendarEvent;
+
+function options(): string[] {
+ const select = document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement | null;
+ return select ? within(select).getAllByRole('option').map((o) => o.textContent ?? '') : [];
+}
+
+describe('EventModal calendar choice with several logins', () => {
+ it("offers only the event's own login's calendars when editing", () => {
+ render();
+ fireEvent.click(screen.getByText('events.edit'));
+ expect(options()).toEqual(['A Personal', 'A Work']);
+ });
+
+ it("keeps another login's event in that login", () => {
+ const other = { ...event, calendarIds: { 'login-b:b': true } } as CalendarEvent;
+ render();
+ fireEvent.click(screen.getByText('events.edit'));
+ // One calendar left: no select at all.
+ expect(options()).toEqual([]);
+ });
+
+ it('offers every login for a new event', () => {
+ render();
+ expect(options()).toEqual(['A Personal', 'A Work', 'B Personal']);
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 45f8a7f22..1263433fb 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -1365,7 +1365,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// One event: asked here, from every place Delete is offered (R3). A
// series gets the scope question above instead.
const title = eventToDelete?.title || t("events.no_title");
- const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails);
+ const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, userEmailsFor(eventToDelete));
const ok = await confirmAction({
title: t("delete_dialog.event_title"),
message: cancels
@@ -1385,7 +1385,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
} catch (error) {
toast.error(describeError(error, t("notifications.event_delete_error")));
}
- }, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]);
+ }, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, userEmailsFor, describeError, t]);
const submitRsvp = useCallback(async (
eventId: string,
@@ -1430,7 +1430,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
updates,
editor,
sendScheduling,
- userEmails: currentUserEmails,
+ userEmails: userEmailsFor(event),
resolveName: resolveContactName,
}, { client, updateEvent, createEvent, findMasterEvent });
done = saved !== "stopped";
@@ -1445,7 +1445,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const deleted = await deleteOccurrence(scope, {
event,
sendScheduling,
- userEmails: currentUserEmails,
+ userEmails: userEmailsFor(event),
}, { client, updateEvent, deleteEvent, findMasterEvent });
done = deleted !== "stopped";
if (!deleted) {
@@ -1468,7 +1468,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error")));
}
- }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
+ }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, userEmailsFor, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
// A drag or resize. A series asks which occurrences it covers, as the
// editor does; one event moves at once and can be moved back.
@@ -2688,7 +2688,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
note={pendingScopeAction?.type === "delete" && pendingScopeAction.sendScheduling
- && userOrganizes(pendingScopeAction.event, currentUserEmails)
+ && userOrganizes(pendingScopeAction.event, userEmailsFor(pendingScopeAction.event))
? t("participants.cancel_notification")
: undefined}
eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 4138a67cf..c2ee2538a 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -447,9 +447,16 @@ export function EventModal({
// Calendars offered as create/move targets: writable, non-subscription, plus
// the event's current calendar so an in-place edit never blanks the select.
+ // An existing event moves only within its own login: the update goes to
+ // that login's server, which doesn't know another login's calendar ids.
+ const eventLogin = useMemo(() => {
+ const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined;
+ return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined };
+ }, [event, calendars]);
const selectableCalendars = useMemo(
- () => calendars.filter(c => canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId),
- [calendars, isSubscriptionCalendar, calendarId]
+ () => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId)
+ && (!eventLogin.known || c.localAccountId === eventLogin.login)),
+ [calendars, isSubscriptionCalendar, calendarId, eventLogin]
);
const [recurrence, setRecurrence] = useState(() => {
if (!event?.recurrenceRules?.length) return "none";