From b2d24670ff6f13dec1a1a5173512ee07018f7d1a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 16:39:02 +0200 Subject: [PATCH] fix: scope iCal subscriptions per JMAP account and fix refresh/clear --- .../calendar/calendar-sidebar-panel.tsx | 7 +- .../settings/calendar-management-settings.tsx | 24 ++++-- lib/utils.ts | 19 +++++ stores/calendar-store.ts | 84 +++++++++++++++++-- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 06a10256..e4b67207 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -48,7 +48,12 @@ export function CalendarSidebarPanel({ const tSub = useTranslations("calendar.subscription"); const tMgmt = useTranslations("calendar.management"); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); - const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); + const allSubs = useCalendarStore((s) => s.icalSubscriptions); + const currentAccountId = client?.getAccountId(); + const icalSubscriptions = useMemo( + () => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId), + [allSubs, currentAccountId], + ); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const timeFormat = useSettingsStore((s) => s.timeFormat); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 5cf4d417..2ef1af1e 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { useCalendarStore } from '@/stores/calendar-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -10,7 +10,7 @@ import { SettingsSection } from './settings-section'; import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react'; import { ShareCollectionDialog } from './share-collection-dialog'; import type { CalendarRights } from '@/lib/jmap/types'; -import { cn, formatDateTime } from '@/lib/utils'; +import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils'; import { ICalImportModal } from '@/components/calendar/ical-import-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { useSettingsStore } from '@/stores/settings-store'; @@ -155,7 +155,14 @@ export { CalendarColorPicker, CALENDAR_COLORS }; export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); - const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + // Subscriptions are persisted globally but scoped per JMAP account via + // accountId. Legacy entries with no accountId show in the active account. + const currentAccountId = client?.getAccountId(); + const icalSubscriptions = useMemo( + () => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId), + [allSubs, currentAccountId], + ); const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState>({}); const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState(null); @@ -652,9 +659,14 @@ export function CalendarManagementSettings() {
{sub.name} - - {sub.url} - + {(() => { + const safeUrl = redactUrlCredentials(sub.url); + return ( + + {safeUrl} + + ); + })()} {sub.lastRefreshed && ( {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} diff --git a/lib/utils.ts b/lib/utils.ts index 331e5f2b..d6bd9371 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,6 +8,25 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Strip embedded Basic-auth credentials from a URL for display. + * `https://user:pass@host/path` → `https://host/path`. Falls back to + * regex stripping if URL parsing fails. + */ +export function redactUrlCredentials(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + if (parsed.username || parsed.password) { + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } + return rawUrl; + } catch { + return rawUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]+@/, '$1'); + } +} + export function generateUUID(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index cb4bdeea..f4162593 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -11,6 +11,11 @@ import { generateUUID } from '@/lib/utils'; import { apiFetch } from '@/lib/browser-navigation'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; +// In-flight refresh dedup. Concurrent callers (auto-interval + +// manual refresh, two account-switch reloads, etc.) share the same +// promise instead of double-fetching and racing the diff/import phase. +const refreshInFlight = new Map>(); + export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks']; @@ -97,6 +102,11 @@ export interface ICalSubscription { id: string; url: string; calendarId: string; + // The JMAP account this subscription belongs to. Optional for back- + // compat with subs persisted before multi-account scoping landed — + // legacy entries with no accountId are shown only in whichever account + // the user has active (treated as floating). New subs always set it. + accountId?: string; name: string; color: string; refreshInterval: number; // minutes @@ -718,7 +728,7 @@ export const useCalendarStore = create()( const cal = get().calendars.find(c => c.id === calendarId); const realCalId = cal?.originalId || calendarId; const targetAccountId = cal?.accountId; - let totalDeleted = 0; + let totalRemoved = 0; // Loop to handle pagination (getCalendarEvents has a 1000 limit) let hasMore = true; while (hasMore) { @@ -728,13 +738,39 @@ export const useCalendarStore = create()( const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]); if (calendarEvents.length === 0) break; - const ids = calendarEvents.map(e => e.id); - const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId); - totalDeleted += destroyed.length; + // Separate events that live ONLY in this calendar (delete) from + // events also linked to other calendars (unlink only — don't + // cascade-delete the user's copy elsewhere). + const idsToDelete: string[] = []; + const eventsToUnlink: Array<{ id: string; calendarIds: Record }> = []; + for (const e of calendarEvents) { + const otherCalIds = { ...(e.calendarIds || {}) }; + delete otherCalIds[realCalId]; + if (Object.keys(otherCalIds).length === 0) { + idsToDelete.push(e.id); + } else { + eventsToUnlink.push({ id: e.id, calendarIds: otherCalIds }); + } + } - // If we couldn't destroy any events, stop to avoid infinite loop - if (destroyed.length === 0) { - debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length); + let removedThisPass = 0; + if (idsToDelete.length > 0) { + const { destroyed } = await client.batchDeleteCalendarEvents(idsToDelete, targetAccountId); + removedThisPass += destroyed.length; + } + for (const { id, calendarIds } of eventsToUnlink) { + try { + await client.updateCalendarEvent(id, { calendarIds } as Partial, undefined, targetAccountId); + removedThisPass++; + } catch (err) { + debug.warn('calendar', 'Failed to unlink event from cleared calendar:', err); + } + } + totalRemoved += removedThisPass; + + // If we couldn't remove anything, stop to avoid infinite loop + if (removedThisPass === 0) { + debug.warn('calendar', 'Could not clear any events, stopping. Remaining:', calendarEvents.length); break; } @@ -745,7 +781,7 @@ export const useCalendarStore = create()( set((state) => ({ events: state.events.filter(e => !e.calendarIds?.[calendarId]), })); - return totalDeleted; + return totalRemoved; } catch (error) { debug.error('Failed to clear calendar events:', error); set({ error: 'Failed to clear calendar events' }); @@ -788,6 +824,7 @@ export const useCalendarStore = create()( id: generateUUID(), url: normalizedUrl, calendarId: calendar.id, + accountId: client.getAccountId(), name, color, refreshInterval, @@ -882,10 +919,21 @@ export const useCalendarStore = create()( }, refreshICalSubscription: async (client, subscriptionId) => { + const existing = refreshInFlight.get(subscriptionId); + if (existing) return existing; + const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); if (!sub) return; - try { + // Skip if the subscription is scoped to a different JMAP account + // than the one this client is talking to — otherwise we'd create + // events in the wrong account / against a missing calendar. + if (sub.accountId && sub.accountId !== client.getAccountId()) { + debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name }); + return; + } + + const work = (async () => { const response = await apiFetch('/api/fetch-ical', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -970,17 +1018,30 @@ export const useCalendarStore = create()( s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s ), })); + })(); + + refreshInFlight.set(subscriptionId, work); + try { + await work; } catch (error) { debug.error('Failed to refresh iCal subscription:', sub.name, error); throw error; + } finally { + refreshInFlight.delete(subscriptionId); } }, refreshAllSubscriptions: async (client) => { const { icalSubscriptions } = get(); + const currentAccountId = client.getAccountId(); const now = Date.now(); for (const sub of icalSubscriptions) { + // Only refresh subs for the current account (or legacy untagged + // subs, which are treated as belonging to whichever account the + // user has active). + if (sub.accountId && sub.accountId !== currentAccountId) continue; + const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0; const intervalMs = sub.refreshInterval * 60 * 1000; @@ -995,9 +1056,14 @@ export const useCalendarStore = create()( }, clearState: () => { + // Preserve iCal subscriptions across the account-switch teardown. + // They're now scoped per-account via sub.accountId — wiping them + // here would lose them from localStorage on every switch. + const preservedSubs = get().icalSubscriptions; set({ ...initialState, selectedDate: new Date(), + icalSubscriptions: preservedSubs, }); import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => { useCalendarNotificationStore.getState().clearAll();