feat: manage shared/group account settings from Accounts page

This commit is contained in:
Linus Rath
2026-06-14 17:05:29 +02:00
parent 848ed9774d
commit 4c6c1aab60
16 changed files with 577 additions and 103 deletions
+62 -7
View File
@@ -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: <name>" 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 && (
<button
type="button"
onClick={() => {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ml-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
)}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
@@ -697,8 +744,16 @@ export default function SettingsPage() {
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
+71 -3
View File
@@ -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<SharedAccount[]>(
() => (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 (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
@@ -197,6 +227,44 @@ export function AccountSettings() {
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
@@ -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<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
@@ -205,9 +207,13 @@ export function AddressBookManagementSettings() {
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{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]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
{/* Contact categories come from the active account's contacts, not the
shared account, so hide them while scoped to a shared account. */}
{!managedAccountId && (
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
)}
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
@@ -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 (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{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) {
+44 -5
View File
@@ -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<Mailbox[]>([]);
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<FilterRule | undefined>();
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) => {
+5 -3
View File
@@ -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) {
+26 -11
View File
@@ -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<Mailbox[]> { return [...this.data.mailboxes]; }
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
@@ -603,9 +603,9 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Vacation ──────────────────────────────────────────────────
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async getVacationResponse(_accountId?: string): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
async setVacationResponse(updates: Partial<VacationResponse>, _accountId?: string): Promise<void> {
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<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScripts(_accountId?: string): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScriptContent(blobId: string): Promise<string> {
async getSieveScriptContent(blobId: string, _accountId?: string): Promise<string> {
return this.data.sieveContent[blobId] ?? '';
}
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise<SieveScript> {
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<void> {
async updateSieveScript(scriptId: string, content: string, activate?: boolean, _accountId?: string): Promise<void> {
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<void> {
async deleteSieveScript(scriptId: string, _accountId?: string): Promise<void> {
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 };
}
+15 -11
View File
@@ -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<Mailbox[]>;
getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
@@ -239,8 +239,8 @@ export interface IJMAPClient {
deleteIdentity(identityId: string): Promise<void>;
// ── Vacation ──────────────────────────────────────────────────
getVacationResponse(): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
getVacationResponse(accountId?: string): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string;
@@ -294,15 +294,19 @@ export interface IJMAPClient {
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
// ── Accounts (primary + shared/group) ────────────────────────
getSharedAccounts(): SharedAccount[];
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
getSieveScripts(): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
deleteSieveScript(scriptId: string): Promise<void>;
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
// ── Files (WebDAV / FileNode) ─────────────────────────────────
getFilesAccountId(): string;
+106 -45
View File
@@ -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<Mailbox[]> {
async getMailboxes(accountId?: string): Promise<Mailbox[]> {
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<VacationResponse> {
async getVacationResponse(accountId?: string): Promise<VacationResponse> {
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<VacationResponse>): Promise<void> {
async setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void> {
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<SieveScript[]> {
async getSieveScripts(accountId?: string): Promise<SieveScript[]> {
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<string> {
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
async getSieveScriptContent(blobId: string, accountId?: string): Promise<string> {
// 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<string> {
private async uploadSieveBlob(content: string, accountId?: string): Promise<string> {
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<SieveScript> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript> {
const targetAccountId = accountId || this.getSieveAccountId();
const blobId = await this.uploadSieveBlob(content, targetAccountId);
const setArgs: Record<string, unknown> = {
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<void> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.getSieveAccountId();
const blobId = await this.uploadSieveBlob(content, targetAccountId);
const setArgs: Record<string, unknown> = {
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<void> {
const accountId = this.getSieveAccountId();
async deleteSieveScript(scriptId: string, accountId?: string): Promise<void> {
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<void> {
const accountId = this.getSieveAccountId();
async activateSieveScript(scriptId: string, accountId?: string): Promise<void> {
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<void> {
const accountId = this.getSieveAccountId();
async deactivateSieveScript(accountId?: string): Promise<void> {
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());
+19
View File
@@ -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;
+9
View File
@@ -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",
+84
View File
@@ -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> = {}): 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');
});
});
});
@@ -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();
});
});
+42 -9
View File
@@ -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<void>;
fetchFilters: (client: IJMAPClient, accountId?: string) => Promise<void>;
selectAccount: (client: IJMAPClient, accountId: string) => Promise<void>;
saveFilters: (client: IJMAPClient) => Promise<void>;
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
@@ -44,16 +53,23 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((set, get) => ({
rawScript: '',
vacationSettings: null,
externalRequires: [],
availableAccounts: [],
selectedAccountId: null,
}),
}));
+32
View File
@@ -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: <name>" 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<ManagedAccountStore>()((set) => ({
managedAccountId: null,
managedAccount: null,
setManagedAccount: (account) =>
set({ managedAccountId: account?.id ?? null, managedAccount: account }),
clear: () => set({ managedAccountId: null, managedAccount: null }),
}));
+6 -6
View File
@@ -13,7 +13,7 @@ interface VacationStore {
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise<void>;
updateVacationResponse: (client: IJMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
@@ -21,7 +21,7 @@ interface VacationStore {
subject?: string;
textBody?: string;
htmlBody?: string | null;
}) => Promise<void>;
}, accountId?: string) => Promise<void>;
setSupported: (supported: boolean) => void;
clearState: () => void;
}
@@ -38,10 +38,10 @@ export const useVacationStore = create<VacationStore>()((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<VacationStore>()((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,