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
+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,