feat: manage shared/group account settings from Accounts page
This commit is contained in:
@@ -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
@@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user