From 4c6c1aab606f7f559f0a6f57310c7c314a97ed4c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:05:29 +0200 Subject: [PATCH] feat: manage shared/group account settings from Accounts page --- app/(main)/[locale]/settings/page.tsx | 69 +++++++- components/settings/account-settings.tsx | 74 ++++++++- .../address-book-management-settings.tsx | 14 +- .../settings/calendar-management-settings.tsx | 7 +- components/settings/filter-settings.tsx | 49 +++++- components/settings/vacation-settings.tsx | 8 +- lib/demo/demo-client.ts | 37 +++-- lib/jmap/client-interface.ts | 26 +-- lib/jmap/client.ts | 151 ++++++++++++------ lib/jmap/types.ts | 19 +++ locales/en/common.json | 9 ++ stores/__tests__/filter-store.test.ts | 84 ++++++++++ .../__tests__/managed-account-store.test.ts | 38 +++++ stores/filter-store.ts | 51 ++++-- stores/managed-account-store.ts | 32 ++++ stores/vacation-store.ts | 12 +- 16 files changed, 577 insertions(+), 103 deletions(-) create mode 100644 stores/__tests__/managed-account-store.test.ts create mode 100644 stores/managed-account-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 8476e27f..db4f79ea 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -74,6 +74,7 @@ import { useEmailStore } from '@/stores/email-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { useSettingsStore } from '@/stores/settings-store'; +import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; @@ -385,6 +386,12 @@ export default function SettingsPage() { const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); const proInterface = useSettingsStore((s) => s.proInterface); + // When set, the settings panel is scoped to a shared/group account: a reduced + // tab list and a "Managing: " header. null = the user's own account. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + const managedAccount = useManagedAccountStore((s) => s.managedAccount); + const clearManagedAccount = useManagedAccountStore((s) => s.clear); + // Build a per-tab haystack for fulltext search and a list of sub-results // (individual settings) per tab. Sub-results come from translation entries // that have a `label`/`title` field, plus dynamic content (installed @@ -488,6 +495,10 @@ export default function SettingsPage() { return () => window.removeEventListener('settings-tab-change', handler); }, []); + // Leaving the settings panel drops any shared-account scope so it never + // leaks into the next visit or another session. + useEffect(() => () => clearManagedAccount(), [clearManagedAccount]); + useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } @@ -626,12 +637,28 @@ export default function SettingsPage() { ...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []), ]; + // In scoped (shared-account) mode, restrict to the account-relevant tabs the + // account actually advertises. Folders is intentionally excluded (mailbox CRUD + // is hardwired to the active account). Gated on both the per-account + // capability and the session-level support/feature flags. + const scopedTabIds: Tab[] = managedAccount + ? ([ + managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null, + managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null, + managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null, + managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null, + ].filter(Boolean) as Tab[]) + : []; + const visibleTabs = managedAccountId + ? tabs.filter((tab) => scopedTabIds.includes(tab.id)) + : tabs; + // Group tabs by category const groupedTabs = tabGroupOrder .map((group) => ({ group, label: t(`tab_groups.${group}`), - items: tabs.filter((tab) => tab.group === group), + items: visibleTabs.filter((tab) => tab.group === group), })) .filter((g) => g.items.length > 0); @@ -659,9 +686,13 @@ export default function SettingsPage() { .filter((g) => g.items.length > 0) : groupedTabs; - // If active tab is not in the visible list (e.g., feature disabled), fall back. - const isActiveVisible = tabs.some((tab) => tab.id === activeTab); - const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance'; + // If active tab is not in the visible list (e.g., feature disabled, or scoped + // mode hides it), fall back. In scoped mode fall back to the first scoped tab; + // otherwise the usual 'appearance' default. + const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab); + const effectiveActiveTab: Tab = isActiveVisible + ? activeTab + : (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance'); const handleTabSelect = (tabId: Tab) => { setActiveTab(tabId); @@ -676,10 +707,26 @@ export default function SettingsPage() { setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId }); }; - const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; + const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( <> + {managedAccountId && managedAccount && ( + + )} {effectiveActiveTab === 'account' && } {effectiveActiveTab === 'language' && } {effectiveActiveTab === 'notifications' && } @@ -697,8 +744,16 @@ export default function SettingsPage() { {effectiveActiveTab === 'security' && } {effectiveActiveTab === 'encryption' && } {effectiveActiveTab === 'content_senders' && } - {effectiveActiveTab === 'calendar' && <>
} - {effectiveActiveTab === 'contacts' && <>
} + {effectiveActiveTab === 'calendar' && ( + managedAccountId + ? + : <>
+ )} + {effectiveActiveTab === 'contacts' && ( + managedAccountId + ? + : <>
+ )} {effectiveActiveTab === 'files' && } {effectiveActiveTab === 'protocol_handlers' && } {effectiveActiveTab === 'sidebar_apps' && } diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index a332d463..0ac31894 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -1,11 +1,13 @@ "use client"; -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useMemo } from 'react'; import { useTranslations } from 'next-intl'; -import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react'; +import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useAccountStore, type AccountEntry } from '@/stores/account-store'; +import { useManagedAccountStore } from '@/stores/managed-account-store'; +import type { SharedAccount } from '@/lib/jmap/types'; import { SettingsSection, SettingItem } from './settings-section'; import { Avatar } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string { try { return new URL(serverUrl).hostname; } catch { return serverUrl; } } +// First scoped settings tab to land on for a shared account, by capability. +// Mirrors the scoped-tab gating in the settings page. null = nothing editable. +function firstScopedTab(caps: SharedAccount['capabilities']): string | null { + if (caps.sieve) return 'filters'; + if (caps.mail) return 'vacation'; + if (caps.calendars) return 'calendar'; + if (caps.contacts) return 'contacts'; + return null; +} + export function AccountSettings() { const t = useTranslations('settings.account'); const router = useRouter(); - const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore(); + const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore(); const activeAccountId = useAuthStore((s) => s.activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); + const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount); + + // Shared/group accounts delegated to this session (excludes the user's own + // primary account). These can be drilled into for scoped settings editing. + const sharedAccounts = useMemo( + () => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary), + [client], + ); const { quota } = useEmailStore(); const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); @@ -82,6 +102,16 @@ export function AccountSettings() { router.push(`/login?mode=add-account` as never); }, [router]); + // Enter scoped settings mode for a shared/group account: set the managed + // account, then steer the settings panel to its first editable tab via the + // existing 'settings-tab-change' event the page already listens for. + const handleManageShared = useCallback((account: SharedAccount) => { + const tab = firstScopedTab(account.capabilities); + if (!tab) return; + setManagedAccount(account); + window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab })); + }, [setManagedAccount]); + return (
@@ -197,6 +227,44 @@ export function AccountSettings() {
)} + + {/* Shared / group accounts delegated to this session. Clicking one drills + into a scoped settings view (filters, vacation, calendars, contacts). */} + {sharedAccounts.length > 0 && ( + +
+ {sharedAccounts.map((acc) => { + const editable = firstScopedTab(acc.capabilities) !== null; + return ( + + ); + })} +
+
+ )} ); } diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx index ef0c37b0..63935c33 100644 --- a/components/settings/address-book-management-settings.tsx +++ b/components/settings/address-book-management-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Book, Pencil, Share2, Tag, Users } from "lucide-react"; import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useManagedAccountStore } from "@/stores/managed-account-store"; import { toast } from "@/stores/toast-store"; import { SettingsSection } from "./settings-section"; import { cn } from "@/lib/utils"; @@ -71,6 +72,7 @@ export function AddressBookManagementSettings() { const tContacts = useTranslations("contacts"); const tSettings = useTranslations("settings.contacts"); const { client } = useAuthStore(); + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore(); const [editingId, setEditingId] = useState(null); const [editingKeyword, setEditingKeyword] = useState(null); @@ -205,9 +207,13 @@ export function AddressBookManagementSettings() { <>
- {personal.map(renderBook)} + {/* In scoped mode (managing a shared account) hide the user's own + address books and show only the managed account's group. */} + {(managedAccountId ? [] : personal).map(renderBook)} - {Array.from(sharedGroups.entries()).map(([accountId, group]) => ( + {Array.from(sharedGroups.entries()) + .filter(([accountId]) => !managedAccountId || accountId === managedAccountId) + .map(([accountId, group]) => (

@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {

+ {/* Contact categories come from the active account's contacts, not the + shared account, so hide them while scoped to a shared account. */} + {!managedAccountId && (
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
+ )} {sharingId && client && (() => { const book = addressBooks.find((b) => b.id === sharingId); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 873cc914..31b09af2 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -14,6 +14,7 @@ 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'; +import { useManagedAccountStore } from '@/stores/managed-account-store'; import { apiFetch } from '@/lib/browser-navigation'; import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors'; @@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS }; export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); const { client, serverUrl, username } = useAuthStore(); + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); 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. @@ -388,7 +390,10 @@ export function CalendarManagementSettings() { return (
- {calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => { + {calendars + .filter(cal => !isSubscriptionCalendar(cal.id)) + .filter(cal => !managedAccountId || cal.accountId === managedAccountId) + .map((cal) => { const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6'; if (editingId === cal.id) { diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index 8cdb95b2..d011952e 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import type { FilterRule } from "@/lib/jmap/sieve-types"; +import type { Mailbox } from "@/lib/jmap/types"; import { useVacationStore } from "@/stores/vacation-store"; +import { useManagedAccountStore } from "@/stores/managed-account-store"; import { Plus, GripVertical, @@ -157,7 +159,7 @@ export function FilterSettings() { const t = useTranslations("settings.filters"); const tNotifications = useTranslations("notifications"); const { client } = useAuthStore(); - const mailboxes = useEmailStore((s) => s.mailboxes); + const storeMailboxes = useEmailStore((s) => s.mailboxes); const expandedFilterView = useSettingsStore((s) => s.expandedFilterView); const updateSetting = useSettingsStore((s) => s.updateSetting); @@ -170,7 +172,7 @@ export function FilterSettings() { isOpaque, rawScript, vacationSettings, - fetchFilters, + selectAccount, saveFilters, addRule, updateRule, @@ -182,7 +184,41 @@ export function FilterSettings() { validateScript, } = useFilterStore(); - const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled; + // Scoped to a shared/group account when the settings panel is managing one. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + const isPrimaryAccount = !managedAccountId; + + // Folders offered as "move to" targets in the rule editor must belong to the + // account whose filters we're editing. For a shared account, fetch that + // account's mailboxes (the email store only holds the active account's). They + // are this account's own folders within its Sieve context, so present them as + // non-shared — otherwise the rule modal filters them out (it drops isShared + // mailboxes, which are normally other accounts' folders merged into the view). + const [scopedMailboxes, setScopedMailboxes] = useState([]); + useEffect(() => { + if (!client || !managedAccountId) { + setScopedMailboxes([]); + return; + } + let cancelled = false; + void client + .getMailboxes(managedAccountId) + .then((mbs) => { + if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false }))); + }) + .catch(() => { + if (!cancelled) setScopedMailboxes([]); + }); + return () => { cancelled = true; }; + }, [client, managedAccountId]); + + const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes; + + const vacationStoreEnabled = useVacationStore((s) => s.isEnabled); + // Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the + // "vacation active" banner only applies when editing the personal account. + const vacationEnabled = + isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled); const [editingRule, setEditingRule] = useState(); const [showRuleModal, setShowRuleModal] = useState(false); @@ -194,9 +230,12 @@ export function FilterSettings() { useEffect(() => { if (client && isSupported) { - void fetchFilters(client); + // Always pass a concrete account id (managed shared account, or the + // primary Sieve account) so switching never falls back to a stale + // previously-selected account. + void selectAccount(client, managedAccountId ?? client.getSieveAccountId()); } - }, [client, isSupported, fetchFilters]); + }, [client, isSupported, managedAccountId, selectAccount]); const handleToggle = useCallback( async (ruleId: string) => { diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index 19dd248b..c15c60f0 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { useVacationStore } from '@/stores/vacation-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useManagedAccountStore } from '@/stores/managed-account-store'; import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react'; import { toast } from '@/stores/toast-store'; @@ -19,6 +20,7 @@ export function VacationSettings() { const t = useTranslations('settings.vacation'); const tNotifications = useTranslations('notifications'); const { client } = useAuthStore(); + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const { isEnabled, fromDate, @@ -43,9 +45,9 @@ export function VacationSettings() { useEffect(() => { if (client && isSupported) { - void fetchVacationResponse(client); + void fetchVacationResponse(client, managedAccountId ?? undefined); } - }, [client, isSupported, fetchVacationResponse]); + }, [client, isSupported, managedAccountId, fetchVacationResponse]); useEffect(() => { setLocalEnabled(isEnabled); @@ -101,7 +103,7 @@ export function VacationSettings() { toDate: localToDate || null, subject: localSubject, textBody: localTextBody, - }); + }, managedAccountId ?? undefined); toast.success(tNotifications('vacation_saved')); } catch (error) { diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index edd0e93e..2288490d 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1,5 +1,5 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types'; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import { getDemoData, type DemoData } from './demo-data'; import { generateDemoId } from './demo-utils'; @@ -118,7 +118,7 @@ export class DemoJMAPClient implements IJMAPClient { // ── Mailboxes ───────────────────────────────────────────────── - async getMailboxes(): Promise { return [...this.data.mailboxes]; } + async getMailboxes(_accountId?: string): Promise { return [...this.data.mailboxes]; } async getAllMailboxes(): Promise { return [...this.data.mailboxes]; } async createMailbox(name: string, parentId?: string): Promise { @@ -603,9 +603,9 @@ export class DemoJMAPClient implements IJMAPClient { // ── Vacation ────────────────────────────────────────────────── - async getVacationResponse(): Promise { return { ...this.data.vacationResponse }; } + async getVacationResponse(_accountId?: string): Promise { return { ...this.data.vacationResponse }; } - async setVacationResponse(updates: Partial): Promise { + async setVacationResponse(updates: Partial, _accountId?: string): Promise { Object.assign(this.data.vacationResponse, updates); } @@ -849,19 +849,34 @@ export class DemoJMAPClient implements IJMAPClient { // ── Sieve / Filters ────────────────────────────────────────── + getSharedAccounts(): SharedAccount[] { + return [{ + id: 'demo-account', + name: 'Demo', + isPrimary: true, + capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true }, + }]; + } + getSieveAccountId(): string { return 'demo-account'; } - getSieveCapabilities(): SieveCapabilities | null { + getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] { + return this.getSharedAccounts() + .filter((a) => a.isPrimary || a.capabilities.sieve) + .map(({ id, name, isPrimary }) => ({ id, name, isPrimary })); + } + + getSieveCapabilities(_accountId?: string): SieveCapabilities | null { return { ...this.data.sieveCapabilities }; } - async getSieveScripts(): Promise { return [...this.data.sieveScripts]; } + async getSieveScripts(_accountId?: string): Promise { return [...this.data.sieveScripts]; } - async getSieveScriptContent(blobId: string): Promise { + async getSieveScriptContent(blobId: string, _accountId?: string): Promise { return this.data.sieveContent[blobId] ?? ''; } - async createSieveScript(name: string, content: string, activate?: boolean): Promise { + async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise { const blobId = generateDemoId('sieve-blob'); const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false }; this.data.sieveScripts.push(script); @@ -874,7 +889,7 @@ export class DemoJMAPClient implements IJMAPClient { return script; } - async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise { + async updateSieveScript(scriptId: string, content: string, activate?: boolean, _accountId?: string): Promise { const script = this.data.sieveScripts.find(s => s.id === scriptId); if (!script) return; const blobId = generateDemoId('sieve-blob'); @@ -890,11 +905,11 @@ export class DemoJMAPClient implements IJMAPClient { } } - async deleteSieveScript(scriptId: string): Promise { + async deleteSieveScript(scriptId: string, _accountId?: string): Promise { this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId); } - async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> { + async validateSieveScript(_content?: string, _accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> { return { isValid: true }; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index c2715d1e..6a55a8f5 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; /** @@ -71,7 +71,7 @@ export interface IJMAPClient { getQuota(): Promise<{ used: number; total: number } | null>; // ── Mailboxes ───────────────────────────────────────────────── - getMailboxes(): Promise; + getMailboxes(accountId?: string): Promise; getAllMailboxes(): Promise; createMailbox(name: string, parentId?: string): Promise; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise; @@ -239,8 +239,8 @@ export interface IJMAPClient { deleteIdentity(identityId: string): Promise; // ── Vacation ────────────────────────────────────────────────── - getVacationResponse(): Promise; - setVacationResponse(updates: Partial): Promise; + getVacationResponse(accountId?: string): Promise; + setVacationResponse(updates: Partial, accountId?: string): Promise; // ── Contacts ────────────────────────────────────────────────── getContactsAccountId(): string; @@ -294,15 +294,19 @@ export interface IJMAPClient { setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; + // ── Accounts (primary + shared/group) ──────────────────────── + getSharedAccounts(): SharedAccount[]; + // ── Sieve / Filters ────────────────────────────────────────── getSieveAccountId(): string; - getSieveCapabilities(): SieveCapabilities | null; - getSieveScripts(): Promise; - getSieveScriptContent(blobId: string): Promise; - createSieveScript(name: string, content: string, activate?: boolean): Promise; - updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise; - deleteSieveScript(scriptId: string): Promise; - validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>; + getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[]; + getSieveCapabilities(accountId?: string): SieveCapabilities | null; + getSieveScripts(accountId?: string): Promise; + getSieveScriptContent(blobId: string, accountId?: string): Promise; + createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise; + updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise; + deleteSieveScript(scriptId: string, accountId?: string): Promise; + validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>; // ── Files (WebDAV / FileNode) ───────────────────────────────── getFilesAccountId(): string; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 61f80133..501de5bb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -897,16 +897,17 @@ export class JMAPClient implements IJMAPClient { } } - async getMailboxes(): Promise { + async getMailboxes(accountId?: string): Promise { + const acctId = accountId || this.accountId; try { const response = await this.request([ - ["Mailbox/get", { accountId: this.accountId }, "0"] + ["Mailbox/get", { accountId: acctId }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; - debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`); + debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`); // Warn if response might be truncated const maxObjects = this.getMaxObjectsInGet(); @@ -940,9 +941,9 @@ export class JMAPClient implements IJMAPClient { unreadThreads: mb.unreadThreads ?? 0, myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS, isSubscribed: mb.isSubscribed ?? true, - accountId: this.accountId, - accountName: this.accounts[this.accountId]?.name || this.username, - isShared: false, + accountId: acctId, + accountName: this.accounts[acctId]?.name || this.username, + isShared: acctId !== this.accountId, }) as Mailbox); } @@ -2082,10 +2083,10 @@ export class JMAPClient implements IJMAPClient { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"]; } - async getVacationResponse(): Promise { + async getVacationResponse(accountId?: string): Promise { const response = await this.request([ ["VacationResponse/get", { - accountId: this.accountId, + accountId: accountId || this.accountId, ids: ["singleton"], }, "0"] ], this.vacationUsing()); @@ -2109,10 +2110,10 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to fetch vacation response: unexpected server response"); } - async setVacationResponse(updates: Partial): Promise { + async setVacationResponse(updates: Partial, accountId?: string): Promise { const response = await this.request([ ["VacationResponse/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, update: { "singleton": updates, }, @@ -3421,18 +3422,79 @@ export class JMAPClient implements IJMAPClient { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"]; } - getSieveCapabilities(): SieveCapabilities | null { - const sieveAccountId = this.getSieveAccountId(); + /** + * Accounts (primary + shared/group) visible in this session, each tagged with + * the capabilities it advertises. Unifies the per-feature enumeration that + * getCalendarCapableAccountIds()/getContactCapableAccountIds()/ + * getFilesCapableAccountIds() do: a non-primary account is included when it + * advertises a capability OR is a non-personal (shared/group) account, since + * Stalwart doesn't always advertise capabilities on those even when they + * support the feature. Primary account first; name from the session account + * (primary falls back to the username). Drives the settings "Shared with me" + * list and the scoped-settings tab gating. + */ + getSharedAccounts(): SharedAccount[] { + const primaryId = this.getSieveAccountId(); + const toEntry = (id: string, isPrimary: boolean): SharedAccount => { + const account = this.accounts[id]; + const caps = account?.accountCapabilities; + const shared = !account?.isPersonal; + return { + id, + name: account?.name || (isPrimary ? (this.username || id) : id), + isPrimary, + capabilities: { + mail: !!caps?.["urn:ietf:params:jmap:mail"] || shared, + sieve: !!caps?.["urn:ietf:params:jmap:sieve"] || shared, + calendars: !!caps?.["urn:ietf:params:jmap:calendars"] || shared, + contacts: !!caps?.["urn:ietf:params:jmap:contacts"] || shared, + filenode: !!caps?.["urn:ietf:params:jmap:filenode"] || shared, + }, + }; + }; + + const result = [toEntry(primaryId, true)]; + for (const id of Object.keys(this.accounts)) { + if (id === primaryId) continue; + const account = this.accounts[id]; + // Mirror the per-feature helpers: include any non-personal account, plus + // accounts that advertise at least one editable capability. + const caps = account.accountCapabilities; + const advertisesAny = + !!caps?.["urn:ietf:params:jmap:mail"] || + !!caps?.["urn:ietf:params:jmap:sieve"] || + !!caps?.["urn:ietf:params:jmap:calendars"] || + !!caps?.["urn:ietf:params:jmap:contacts"] || + !!caps?.["urn:ietf:params:jmap:filenode"]; + if (!account.isPersonal || advertisesAny) { + result.push(toEntry(id, false)); + } + } + return result; + } + + /** + * Sieve-capable accounts (primary + shared/group), for the filters UI. + * Thin wrapper over getSharedAccounts() filtered to the sieve capability. + */ + getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] { + return this.getSharedAccounts() + .filter((a) => a.isPrimary || a.capabilities.sieve) + .map(({ id, name, isPrimary }) => ({ id, name, isPrimary })); + } + + getSieveCapabilities(accountId?: string): SieveCapabilities | null { + const sieveAccountId = accountId || this.getSieveAccountId(); const accountInfo = this.accounts[sieveAccountId]; if (!accountInfo?.accountCapabilities) return null; const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"]; return (caps as SieveCapabilities) || null; } - async getSieveScripts(): Promise { + async getSieveScripts(accountId?: string): Promise { const response = await this.request([ ["SieveScript/get", { - accountId: this.getSieveAccountId(), + accountId: accountId || this.getSieveAccountId(), }, "0"] ], this.sieveUsing()); @@ -3442,21 +3504,26 @@ export class JMAPClient implements IJMAPClient { throw new Error('Failed to fetch Sieve scripts'); } - async getSieveScriptContent(blobId: string): Promise { - const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve'); + async getSieveScriptContent(blobId: string, accountId?: string): Promise { + // Blobs are scoped per account, so a shared/group account's script must be + // downloaded against that owner's accountId, not the primary one. + const url = this.getBlobDownloadUrl( + blobId, 'script.sieve', 'application/sieve', accountId || this.getSieveAccountId(), + ); const response = await this.authenticatedFetch(url, {}); if (!response.ok) throw new Error(`Failed to download script: ${response.status}`); return response.text(); } - private async uploadSieveBlob(content: string): Promise { + private async uploadSieveBlob(content: string, accountId?: string): Promise { if (!this.session?.uploadUrl) { throw new Error('Upload URL not available'); } + const targetAccountId = accountId || this.getSieveAccountId(); const uploadUrl = this.session.uploadUrl.replace( '{accountId}', - encodeURIComponent(this.getSieveAccountId()) + encodeURIComponent(targetAccountId) ); const response = await this.authenticatedFetch(uploadUrl, { @@ -3474,17 +3541,17 @@ export class JMAPClient implements IJMAPClient { const result = await response.json(); if (result.blobId) return result.blobId; - const blobInfo = result[this.getSieveAccountId()]; + const blobInfo = result[targetAccountId]; if (blobInfo?.blobId) return blobInfo.blobId; throw new Error('Invalid upload response: blobId not found'); } - async createSieveScript(name: string, content: string, activate?: boolean): Promise { - const blobId = await this.uploadSieveBlob(content); - const accountId = this.getSieveAccountId(); + async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise { + const targetAccountId = accountId || this.getSieveAccountId(); + const blobId = await this.uploadSieveBlob(content, targetAccountId); const setArgs: Record = { - accountId, + accountId: targetAccountId, create: { "new-script": { name, blobId } }, @@ -3505,7 +3572,7 @@ export class JMAPClient implements IJMAPClient { } const createdId = result.created?.["new-script"]?.id; if (createdId) { - const scripts = await this.getSieveScripts(); + const scripts = await this.getSieveScripts(targetAccountId); const script = scripts.find(s => s.id === createdId); if (script) return script; } @@ -3513,12 +3580,12 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to create sieve script"); } - async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise { - const blobId = await this.uploadSieveBlob(content); - const accountId = this.getSieveAccountId(); + async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise { + const targetAccountId = accountId || this.getSieveAccountId(); + const blobId = await this.uploadSieveBlob(content, targetAccountId); const setArgs: Record = { - accountId, + accountId: targetAccountId, update: { [scriptId]: { blobId } }, @@ -3542,12 +3609,10 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to update sieve script"); } - async deleteSieveScript(scriptId: string): Promise { - const accountId = this.getSieveAccountId(); - + async deleteSieveScript(scriptId: string, accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { - accountId, + accountId: accountId || this.getSieveAccountId(), destroy: [scriptId] }, "0"] ], this.sieveUsing()); @@ -3563,12 +3628,10 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to delete sieve script"); } - async activateSieveScript(scriptId: string): Promise { - const accountId = this.getSieveAccountId(); - + async activateSieveScript(scriptId: string, accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { - accountId, + accountId: accountId || this.getSieveAccountId(), onSuccessActivateScript: scriptId, }, "0"] ], this.sieveUsing()); @@ -3582,12 +3645,10 @@ export class JMAPClient implements IJMAPClient { } } - async deactivateSieveScript(): Promise { - const accountId = this.getSieveAccountId(); - + async deactivateSieveScript(accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { - accountId, + accountId: accountId || this.getSieveAccountId(), onSuccessActivateScript: null, }, "0"] ], this.sieveUsing()); @@ -3601,13 +3662,13 @@ export class JMAPClient implements IJMAPClient { } } - async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> { - const blobId = await this.uploadSieveBlob(content); - const accountId = this.getSieveAccountId(); + async validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> { + const targetAccountId = accountId || this.getSieveAccountId(); + const blobId = await this.uploadSieveBlob(content, targetAccountId); const response = await this.request([ ["SieveScript/validate", { - accountId, + accountId: targetAccountId, blobId, }, "0"] ], this.sieveUsing()); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c69d3be0..a14e9c3c 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -3,6 +3,25 @@ export interface EmailHeader { value: string; } +/** + * A session-visible account (the user's own primary account plus any + * shared/group accounts delegated to them), tagged with the capabilities it + * advertises. Produced by client.getSharedAccounts(); drives the settings + * "Shared with me" list and the scoped-settings tab gating. + */ +export interface SharedAccount { + id: string; + name: string; + isPrimary: boolean; + capabilities: { + mail: boolean; + sieve: boolean; + calendars: boolean; + contacts: boolean; + filenode: boolean; + }; +} + export interface Email { id: string; threadId: string; diff --git a/locales/en/common.json b/locales/en/common.json index 3b3bebc5..8ab1f8b7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1289,8 +1289,17 @@ "move_down": "Move down", "drag_handle": "Drag to reorder", "add": "Add account" + }, + "shared_accounts": { + "title": "Shared with me", + "description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.", + "shared_label": "Shared account" } }, + "scoped": { + "back": "Back to my account", + "managing": "Managing: {name}" + }, "security": { "title": "Account Security", "description": "Manage your password, two-factor authentication, and security settings", diff --git a/stores/__tests__/filter-store.test.ts b/stores/__tests__/filter-store.test.ts index d5b1c660..29f1276a 100644 --- a/stores/__tests__/filter-store.test.ts +++ b/stores/__tests__/filter-store.test.ts @@ -3,6 +3,13 @@ import { useFilterStore } from '../filter-store'; import type { FilterRule } from '@/lib/jmap/sieve-types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +// Minimal account plumbing every fetchFilters mock needs now that the store +// resolves a target Sieve account before fetching scripts. +const sieveAccountMock = { + getSieveAccountId: () => 'primary', + getSieveAccounts: () => [{ id: 'primary', name: 'Me', isPrimary: true }], +}; + const makeRule = (overrides: Partial = {}): FilterRule => ({ id: 'rule-1', name: 'Test Rule', @@ -153,6 +160,7 @@ describe('filter-store', () => { describe('fetchFilters', () => { it('parses external rules from scripts without metadata', async () => { const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }', @@ -165,6 +173,7 @@ describe('filter-store', () => { it('sets isOpaque for truly unparseable content', async () => { const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */', @@ -178,6 +187,7 @@ describe('filter-store', () => { const { generateScript } = await import('@/lib/sieve/generator'); const script = generateScript(rules); const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], getSieveScriptContent: async () => script, @@ -189,6 +199,7 @@ describe('filter-store', () => { it('should handle empty script list', async () => { const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [], getSieveScriptContent: async () => '', @@ -200,6 +211,7 @@ describe('filter-store', () => { it('should set error on failure', async () => { const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => { throw new Error('Network error'); }, }; @@ -313,6 +325,7 @@ describe('filter-store', () => { const inactiveRules = [makeRule({ name: 'Inactive' })]; const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [ { id: 's1', name: 'old', blobId: 'b1', isActive: false }, @@ -331,6 +344,7 @@ describe('filter-store', () => { it('should set sieveCapabilities from client', async () => { const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] }; const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => caps, getSieveScripts: async () => [], }; @@ -343,6 +357,7 @@ describe('filter-store', () => { const rules = [makeRule()]; const script = generateScript(rules); const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], getSieveScriptContent: async () => script, @@ -357,6 +372,7 @@ describe('filter-store', () => { const script = generateScript(rules); const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [ { id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true }, @@ -375,6 +391,7 @@ describe('filter-store', () => { it('should handle only vacation script present (no filter scripts)', async () => { const mockClient = { + ...sieveAccountMock, getSieveCapabilities: () => null, getSieveScripts: async () => [ { id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true }, @@ -384,5 +401,72 @@ describe('filter-store', () => { expect(useFilterStore.getState().activeScriptId).toBeNull(); expect(useFilterStore.getState().rules).toEqual([]); }); + + it('populates availableAccounts and defaults to the primary account', async () => { + const mockClient = { + getSieveAccountId: () => 'primary', + getSieveAccounts: () => [ + { id: 'primary', name: 'Me', isPrimary: true }, + { id: 'group', name: 'Sales', isPrimary: false }, + ], + getSieveCapabilities: () => null, + getSieveScripts: async () => [], + }; + await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient); + expect(useFilterStore.getState().availableAccounts).toHaveLength(2); + expect(useFilterStore.getState().selectedAccountId).toBe('primary'); + }); + }); + + describe('selectAccount', () => { + it('re-fetches scripts for the chosen account id', async () => { + const fetchedFor: (string | undefined)[] = []; + const mockClient = { + getSieveAccountId: () => 'primary', + getSieveAccounts: () => [ + { id: 'primary', name: 'Me', isPrimary: true }, + { id: 'group', name: 'Sales', isPrimary: false }, + ], + getSieveCapabilities: () => null, + getSieveScripts: async (accountId?: string) => { + fetchedFor.push(accountId); + return []; + }, + }; + // Seed some primary-account state to confirm it gets cleared on switch. + useFilterStore.setState({ rules: [makeRule()], activeScriptId: 'old', isOpaque: true }); + + await useFilterStore.getState().selectAccount(mockClient as unknown as IJMAPClient, 'group'); + + expect(useFilterStore.getState().selectedAccountId).toBe('group'); + expect(fetchedFor).toContain('group'); + expect(useFilterStore.getState().rules).toEqual([]); + expect(useFilterStore.getState().isOpaque).toBe(false); + }); + }); + + describe('account threading', () => { + it('passes the selected account id to update and validate', async () => { + const calls: Array<{ method: string; args: unknown[] }> = []; + const mockClient = { + updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); }, + createSieveScript: async (...args: unknown[]) => { calls.push({ method: 'createSieveScript', args }); return { id: 'x' }; }, + validateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'validateSieveScript', args }); return { isValid: true }; }, + }; + useFilterStore.setState({ + activeScriptId: 'existing-id', + rules: [makeRule()], + isOpaque: false, + selectedAccountId: 'group', + }); + + await useFilterStore.getState().saveFilters(mockClient as unknown as IJMAPClient); + await useFilterStore.getState().validateScript(mockClient as unknown as IJMAPClient, 'require "fileinto";'); + + const update = calls.find(c => c.method === 'updateSieveScript'); + const validate = calls.find(c => c.method === 'validateSieveScript'); + expect(update?.args[3]).toBe('group'); + expect(validate?.args[1]).toBe('group'); + }); }); }); diff --git a/stores/__tests__/managed-account-store.test.ts b/stores/__tests__/managed-account-store.test.ts new file mode 100644 index 00000000..f7912720 --- /dev/null +++ b/stores/__tests__/managed-account-store.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useManagedAccountStore } from '../managed-account-store'; +import type { SharedAccount } from '@/lib/jmap/types'; + +const sharedAccount: SharedAccount = { + id: 'group-1', + name: 'Sales', + isPrimary: false, + capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: false }, +}; + +describe('managed-account-store', () => { + beforeEach(() => { + useManagedAccountStore.getState().clear(); + }); + + it('defaults to no managed account (own account)', () => { + expect(useManagedAccountStore.getState().managedAccountId).toBeNull(); + expect(useManagedAccountStore.getState().managedAccount).toBeNull(); + }); + + it('setManagedAccount enters scoped mode for the account', () => { + useManagedAccountStore.getState().setManagedAccount(sharedAccount); + expect(useManagedAccountStore.getState().managedAccountId).toBe('group-1'); + expect(useManagedAccountStore.getState().managedAccount).toEqual(sharedAccount); + }); + + it('setManagedAccount(null) and clear() return to the own account', () => { + useManagedAccountStore.getState().setManagedAccount(sharedAccount); + useManagedAccountStore.getState().setManagedAccount(null); + expect(useManagedAccountStore.getState().managedAccountId).toBeNull(); + expect(useManagedAccountStore.getState().managedAccount).toBeNull(); + + useManagedAccountStore.getState().setManagedAccount(sharedAccount); + useManagedAccountStore.getState().clear(); + expect(useManagedAccountStore.getState().managedAccountId).toBeNull(); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index ec3eec32..165d5d66 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -5,6 +5,12 @@ import { parseScript } from '@/lib/sieve/parser'; import { generateScript } from '@/lib/sieve/generator'; import { debug } from '@/lib/debug'; +interface SieveAccount { + id: string; + name: string; + isPrimary: boolean; +} + interface FilterStore { rules: FilterRule[]; isLoading: boolean; @@ -17,9 +23,12 @@ interface FilterStore { rawScript: string; vacationSettings: VacationSieveConfig | null; externalRequires: string[]; + availableAccounts: SieveAccount[]; + selectedAccountId: string | null; setSupported: (supported: boolean) => void; - fetchFilters: (client: IJMAPClient) => Promise; + fetchFilters: (client: IJMAPClient, accountId?: string) => Promise; + selectAccount: (client: IJMAPClient, accountId: string) => Promise; saveFilters: (client: IJMAPClient) => Promise; validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>; addRule: (rule: FilterRule) => void; @@ -44,16 +53,23 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + availableAccounts: [], + selectedAccountId: null, setSupported: (supported) => set({ isSupported: supported }), - fetchFilters: async (client) => { + fetchFilters: async (client, accountId) => { set({ isLoading: true, error: null }); try { - const capabilities = client.getSieveCapabilities(); + const accounts = client.getSieveAccounts(); + const resolvedId = + accountId || get().selectedAccountId || client.getSieveAccountId(); + set({ availableAccounts: accounts, selectedAccountId: resolvedId }); + + const capabilities = client.getSieveCapabilities(resolvedId); set({ sieveCapabilities: capabilities }); - const allScripts = await client.getSieveScripts(); + const allScripts = await client.getSieveScripts(resolvedId); debug.log('filters', 'Sieve scripts fetched:', allScripts.length); // Skip the server-managed 'vacation' script (RFC 9661 §4) - it can only @@ -68,7 +84,7 @@ export const useFilterStore = create()((set, get) => ({ set({ activeScriptId: activeScript.id }); - const content = await client.getSieveScriptContent(activeScript.blobId); + const content = await client.getSieveScriptContent(activeScript.blobId, resolvedId); set({ rawScript: content }); const result = parseScript(content); @@ -101,10 +117,25 @@ export const useFilterStore = create()((set, get) => ({ } }, + selectAccount: async (client, accountId) => { + // Reset parsed state so one account's rules/script never leak into another + // before the re-fetch populates the new account's data. + set({ + selectedAccountId: accountId, + rules: [], + rawScript: '', + activeScriptId: null, + isOpaque: false, + vacationSettings: null, + externalRequires: [], + }); + await get().fetchFilters(client, accountId); + }, + saveFilters: async (client) => { set({ isSaving: true, error: null }); try { - const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get(); + const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, selectedAccountId } = get(); let content: string; if (isOpaque) { @@ -114,9 +145,9 @@ export const useFilterStore = create()((set, get) => ({ } if (activeScriptId) { - await client.updateSieveScript(activeScriptId, content, true); + await client.updateSieveScript(activeScriptId, content, true, selectedAccountId || undefined); } else { - const script = await client.createSieveScript('filters', content, true); + const script = await client.createSieveScript('filters', content, true, selectedAccountId || undefined); set({ activeScriptId: script.id }); } @@ -133,7 +164,7 @@ export const useFilterStore = create()((set, get) => ({ }, validateScript: async (client, content) => { - return client.validateSieveScript(content); + return client.validateSieveScript(content, get().selectedAccountId || undefined); }, addRule: (rule) => { @@ -204,5 +235,7 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + availableAccounts: [], + selectedAccountId: null, }), })); diff --git a/stores/managed-account-store.ts b/stores/managed-account-store.ts new file mode 100644 index 00000000..9732d78f --- /dev/null +++ b/stores/managed-account-store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; +import type { SharedAccount } from '@/lib/jmap/types'; + +/** + * Tracks which account the settings panel is currently scoped to. `null` means + * the user's own (primary) account — the default, full settings view. When set + * to a shared/group account, the settings panel enters "scoped mode": a reduced + * tab list and a "Managing: " header, and the account-scoped settings + * pages (filters, vacation, calendars, contacts) read `managedAccountId` to + * target that account. + * + * This is session-only navigation state (not persisted) so a shared-account + * context never leaks across reloads or logout. + */ +interface ManagedAccountStore { + managedAccountId: string | null; + managedAccount: SharedAccount | null; + + /** Enter scoped mode for `account`, or pass `null` to return to own account. */ + setManagedAccount: (account: SharedAccount | null) => void; + clear: () => void; +} + +export const useManagedAccountStore = create()((set) => ({ + managedAccountId: null, + managedAccount: null, + + setManagedAccount: (account) => + set({ managedAccountId: account?.id ?? null, managedAccount: account }), + + clear: () => set({ managedAccountId: null, managedAccount: null }), +})); diff --git a/stores/vacation-store.ts b/stores/vacation-store.ts index 69bca9de..4f240297 100644 --- a/stores/vacation-store.ts +++ b/stores/vacation-store.ts @@ -13,7 +13,7 @@ interface VacationStore { error: string | null; isSupported: boolean; - fetchVacationResponse: (client: IJMAPClient) => Promise; + fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise; updateVacationResponse: (client: IJMAPClient, updates: { isEnabled?: boolean; fromDate?: string | null; @@ -21,7 +21,7 @@ interface VacationStore { subject?: string; textBody?: string; htmlBody?: string | null; - }) => Promise; + }, accountId?: string) => Promise; setSupported: (supported: boolean) => void; clearState: () => void; } @@ -38,10 +38,10 @@ export const useVacationStore = create()((set) => ({ error: null, isSupported: false, - fetchVacationResponse: async (client) => { + fetchVacationResponse: async (client, accountId) => { set({ isLoading: true, error: null }); try { - const vacation = await client.getVacationResponse(); + const vacation = await client.getVacationResponse(accountId); set({ isEnabled: vacation.isEnabled, fromDate: vacation.fromDate, @@ -59,10 +59,10 @@ export const useVacationStore = create()((set) => ({ } }, - updateVacationResponse: async (client, updates) => { + updateVacationResponse: async (client, updates, accountId) => { set({ isSaving: true, error: null }); try { - await client.setVacationResponse(updates); + await client.setVacationResponse(updates, accountId); set((state) => ({ ...state, ...updates,