feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/ principal operations through Stalwart 0.16's schema-driven JMAP endpoint via a single passthrough (/api/account/stalwart/jmap). - New client helper `stalwartJmap` + typed `requireResult` - account-security-store rewritten against x:AccountPassword, x:AppPassword, x:AccountSettings, x:Account (with currentSecret for TOTP ops) - Client-side TOTP setup via `otpauth`; server-generated app password secrets shown once on create - Admin check switched to /api/account permissions (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet) - Removed sieve vacation-overwrite workaround (fixed upstream #1251) - Deleted old REST routes, StalwartClient, stale tests; added new tests for passthrough + store
This commit is contained in:
@@ -1,423 +1,336 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAccountSecurityStore } from '../account-security-store';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
function mockFetchResponse(status: number, body?: unknown): Response {
|
||||
return new Response(body ? JSON.stringify(body) : null, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
vi.mock('@/lib/stalwart/jmap-passthrough', () => ({
|
||||
stalwartJmap: vi.fn(),
|
||||
requireResult: <T,>(responses: Array<[string, unknown, string]>, method: string): T => {
|
||||
const match = responses.find(r => r[0] === method);
|
||||
if (!match) throw new Error(`Missing ${method}`);
|
||||
return match[1] as T;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: {
|
||||
getState: () => ({
|
||||
client: {
|
||||
getAccountId: () => 'acc-primary',
|
||||
hasAccountCapability: (cap: string) => cap === 'urn:stalwart:jmap',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAccountSecurityStore } from '../account-security-store';
|
||||
import { stalwartJmap } from '@/lib/stalwart/jmap-passthrough';
|
||||
|
||||
const mockedJmap = stalwartJmap as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function resetStore() {
|
||||
useAccountSecurityStore.getState().clearState();
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
isStalwart: null,
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'disabled',
|
||||
isLoadingCrypto: false,
|
||||
displayName: '',
|
||||
emails: [],
|
||||
quota: 0,
|
||||
roles: [],
|
||||
isLoadingPrincipal: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe('AccountSecurityStore', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe('account-security-store', () => {
|
||||
beforeEach(() => {
|
||||
useAccountSecurityStore.setState(defaultState);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
mockedJmap.mockReset();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe('probe', () => {
|
||||
it('sets isStalwart to true when probe succeeds', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true }));
|
||||
|
||||
const result = await useAccountSecurityStore.getState().probe();
|
||||
|
||||
expect(result).toBe(true);
|
||||
it('sets isStalwart=true when the account has the urn:stalwart:jmap capability', async () => {
|
||||
const ok = await useAccountSecurityStore.getState().probe();
|
||||
expect(ok).toBe(true);
|
||||
expect(useAccountSecurityStore.getState().isStalwart).toBe(true);
|
||||
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
|
||||
});
|
||||
|
||||
it('sets isStalwart to false when probe returns false', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false }));
|
||||
|
||||
const result = await useAccountSecurityStore.getState().probe();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
|
||||
});
|
||||
|
||||
it('sets isStalwart to false on network error', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
|
||||
|
||||
const result = await useAccountSecurityStore.getState().probe();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAuthInfo', () => {
|
||||
it('populates auth info on success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } })
|
||||
);
|
||||
it('reports TOTP enabled when AccountPassword singleton has otpUrl', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: { otpUrl: 'otpauth://totp/x' } }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.otpEnabled).toBe(true);
|
||||
expect(state.appPasswords).toEqual(['app1', 'app2']);
|
||||
expect(state.isLoadingAuth).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
|
||||
expect(useAccountSecurityStore.getState().appPasswords).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets defaults when data fields are missing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
|
||||
it('reports TOTP disabled when otpAuth is empty', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.otpEnabled).toBe(false);
|
||||
expect(state.appPasswords).toEqual([]);
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('sets error on HTTP failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500));
|
||||
it('resolves app password rows via a follow-up Get when query returns ids', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: ['p1'] }, '1'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AppPassword/get', {
|
||||
list: [{
|
||||
id: 'p1',
|
||||
description: 'Thunderbird',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: null,
|
||||
allowedIps: { '10.0.0.1': true },
|
||||
}],
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.isLoadingAuth).toBe(false);
|
||||
expect(state.error).toBe('HTTP 500');
|
||||
const pw = useAccountSecurityStore.getState().appPasswords[0];
|
||||
expect(pw).toMatchObject({
|
||||
id: 'p1',
|
||||
description: 'Thunderbird',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: null,
|
||||
allowedIps: ['10.0.0.1'],
|
||||
});
|
||||
expect(mockedJmap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sets error on network failure', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
it('records error on failure and clears loading flag', async () => {
|
||||
mockedJmap.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.isLoadingAuth).toBe(false);
|
||||
expect(state.error).toBe('Connection refused');
|
||||
expect(useAccountSecurityStore.getState().isLoadingAuth).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().error).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCryptoInfo', () => {
|
||||
it('populates crypto info on success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { data: { type: 'pgp' } })
|
||||
);
|
||||
it('reads encryption type from encryptionAtRest.@type', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountSettings/get', { list: [{ encryptionAtRest: { '@type': 'Aes256' } }] }, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchCryptoInfo();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.encryptionType).toBe('pgp');
|
||||
expect(state.isLoadingCrypto).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().encryptionType).toBe('Aes256');
|
||||
});
|
||||
|
||||
it('defaults to disabled when type is missing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
|
||||
it('defaults to Disabled when @type is missing or unknown', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountSettings/get', { list: [{ encryptionAtRest: null }] }, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchCryptoInfo();
|
||||
|
||||
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
|
||||
});
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchCryptoInfo();
|
||||
|
||||
expect(useAccountSecurityStore.getState().error).toBe('HTTP 403');
|
||||
expect(useAccountSecurityStore.getState().encryptionType).toBe('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPrincipal', () => {
|
||||
it('populates principal info on success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, {
|
||||
data: {
|
||||
description: 'John Doe',
|
||||
emails: ['john@example.com', 'doe@example.com'],
|
||||
quota: 5000000,
|
||||
roles: ['user', 'admin'],
|
||||
},
|
||||
})
|
||||
);
|
||||
it('combines primary name with enabled aliases and exposes quota/roles', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:Account/get', {
|
||||
list: [{
|
||||
name: 'user@example.com',
|
||||
description: 'Display User',
|
||||
aliases: {
|
||||
a1: { name: 'alias1@example.com', enabled: true },
|
||||
a2: { name: 'alias2@example.com', enabled: false },
|
||||
a3: { name: 'alias3@example.com', enabled: true },
|
||||
},
|
||||
quotas: { maxDiskQuota: 5_000_000 },
|
||||
roles: { '@type': 'User' },
|
||||
}],
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchPrincipal();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.displayName).toBe('John Doe');
|
||||
expect(state.emails).toEqual(['john@example.com', 'doe@example.com']);
|
||||
expect(state.quota).toBe(5000000);
|
||||
expect(state.roles).toEqual(['user', 'admin']);
|
||||
expect(state.isLoadingPrincipal).toBe(false);
|
||||
expect(state.displayName).toBe('Display User');
|
||||
expect(state.emails).toEqual(['user@example.com', 'alias1@example.com', 'alias3@example.com']);
|
||||
expect(state.quota).toBe(5_000_000);
|
||||
expect(state.roles).toEqual(['User']);
|
||||
});
|
||||
|
||||
it('handles single email string as array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, {
|
||||
data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] },
|
||||
})
|
||||
);
|
||||
it('swallows forbidden errors (non-admins cannot read their own Account) without setting error', async () => {
|
||||
mockedJmap.mockRejectedValueOnce(new Error('Forbidden: missing sysAccountGet permission'));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchPrincipal();
|
||||
|
||||
expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']);
|
||||
expect(useAccountSecurityStore.getState().isLoadingPrincipal).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('handles missing emails gracefully', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { data: { description: 'User' } })
|
||||
);
|
||||
it('records non-forbidden errors', async () => {
|
||||
mockedJmap.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchPrincipal();
|
||||
|
||||
expect(useAccountSecurityStore.getState().emails).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets defaults when fields are missing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchPrincipal();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.displayName).toBe('');
|
||||
expect(state.emails).toEqual([]);
|
||||
expect(state.quota).toBe(0);
|
||||
expect(state.roles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAll', () => {
|
||||
it('calls all three fetch methods in parallel', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } }))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } }));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAll();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.encryptionType).toBe('smime');
|
||||
expect(state.displayName).toBe('Test');
|
||||
expect(state.isLoadingAuth).toBe(false);
|
||||
expect(state.isLoadingCrypto).toBe(false);
|
||||
expect(state.isLoadingPrincipal).toBe(false);
|
||||
});
|
||||
|
||||
it('continues even if one fetch fails', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockFetchResponse(500)) // auth fails
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } }))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } }));
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAll();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.encryptionType).toBe('pgp');
|
||||
expect(state.displayName).toBe('OK');
|
||||
expect(useAccountSecurityStore.getState().error).toBe('network down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('sends POST with currentPassword and newPassword', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true }));
|
||||
it('calls x:AccountPassword/set with currentSecret and secret', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123');
|
||||
await useAccountSecurityStore.getState().changePassword('old', 'new');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }),
|
||||
}));
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
const calls = mockedJmap.mock.calls[0][0];
|
||||
expect(calls).toEqual([[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId: 'acc-primary',
|
||||
update: { singleton: { currentSecret: 'old', secret: 'new' } },
|
||||
},
|
||||
'0',
|
||||
]]);
|
||||
});
|
||||
|
||||
it('throws and sets error on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' }));
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().changePassword('wrong', 'newpass123')
|
||||
).rejects.toThrow('Current password is incorrect');
|
||||
it('propagates errors and records state', async () => {
|
||||
mockedJmap.mockRejectedValueOnce(new Error('forbidden'));
|
||||
|
||||
await expect(useAccountSecurityStore.getState().changePassword('x', 'y')).rejects.toThrow('forbidden');
|
||||
expect(useAccountSecurityStore.getState().error).toBe('forbidden');
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDisplayName', () => {
|
||||
it('sends PATCH and updates local state on success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
it('patches AccountSettings.description and updates local state', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountSettings/set', { updated: { singleton: null } }, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().updateDisplayName('New Name');
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.displayName).toBe('New Name');
|
||||
expect(state.isSaving).toBe(false);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
|
||||
});
|
||||
|
||||
it('throws and sets error on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' }));
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().updateDisplayName('Name')
|
||||
).rejects.toThrow('Server error');
|
||||
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().displayName).toBe('New Name');
|
||||
const args = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(args).toEqual({ accountId: 'acc-primary', update: { singleton: { description: 'New Name' } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableTotp', () => {
|
||||
it('sends enableOtpAuth and returns TOTP URL', async () => {
|
||||
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC';
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
|
||||
describe('enableTotp / disableTotp', () => {
|
||||
it('enableTotp sends currentSecret + otpAuth.otpUrl + otpCode', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
|
||||
]);
|
||||
|
||||
const result = await useAccountSecurityStore.getState().enableTotp();
|
||||
await useAccountSecurityStore.getState().enableTotp('pw', 'otpauth://totp/x?secret=S', '123456');
|
||||
|
||||
expect(result).toBe(totpUrl);
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ type: 'enableOtpAuth' }]);
|
||||
const args = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(args.update.singleton).toEqual({
|
||||
currentSecret: 'pw',
|
||||
otpAuth: { otpUrl: 'otpauth://totp/x?secret=S', otpCode: '123456' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws and preserves otpEnabled=false on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' }));
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().enableTotp()
|
||||
).rejects.toThrow('TOTP error');
|
||||
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableTotp', () => {
|
||||
it('sends disableOtpAuth and sets otpEnabled to false', async () => {
|
||||
it('disableTotp clears otpUrl', async () => {
|
||||
useAccountSecurityStore.setState({ otpEnabled: true });
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().disableTotp();
|
||||
await useAccountSecurityStore.getState().disableTotp('pw');
|
||||
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
const args = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(args.update.singleton).toEqual({ currentSecret: 'pw', otpAuth: { otpUrl: null } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAppPassword', () => {
|
||||
it('sends addAppPassword and refreshes auth info', async () => {
|
||||
// First call: POST addAppPassword
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
// Second call: fetchAuthInfo refresh
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } })
|
||||
);
|
||||
describe('createAppPassword', () => {
|
||||
it('returns the server-generated id and secret then refreshes auth info', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { created: { new: { id: 'p-new', secret: 'S3CR3T' } } }, '0'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret');
|
||||
const result = await useAccountSecurityStore.getState().createAppPassword('CLI', '2026-12-01T00:00:00Z');
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.appPasswords).toEqual(['Thunderbird']);
|
||||
expect(state.isSaving).toBe(false);
|
||||
expect(result).toEqual({ id: 'p-new', secret: 'S3CR3T' });
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]);
|
||||
const createArgs = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(createArgs.create.new).toEqual({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z' });
|
||||
expect(mockedJmap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' }));
|
||||
it('throws with server-provided description when notCreated is returned', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { notCreated: { new: { type: 'invalidProperties', description: 'description too short' } } }, '0'],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().addAppPassword('App', 'pass')
|
||||
).rejects.toThrow('Server down');
|
||||
useAccountSecurityStore.getState().createAppPassword('x')
|
||||
).rejects.toThrow('description too short');
|
||||
});
|
||||
|
||||
it('throws when the server does not return a secret', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { created: { new: { id: 'p' } } }, '0'],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().createAppPassword('x')
|
||||
).rejects.toThrow(/did not return/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAppPassword', () => {
|
||||
it('sends removeAppPassword and refreshes auth info', async () => {
|
||||
useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] });
|
||||
it('calls AppPassword/set with destroy and refreshes auth info', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([['x:AppPassword/set', { destroyed: ['p1'] }, '0']])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
]);
|
||||
|
||||
// First call: POST removeAppPassword
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
// Second call: fetchAuthInfo refresh
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } })
|
||||
);
|
||||
await useAccountSecurityStore.getState().removeAppPassword('p1');
|
||||
|
||||
await useAccountSecurityStore.getState().removeAppPassword('Thunderbird');
|
||||
|
||||
expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEncryption', () => {
|
||||
it('sends crypto settings and updates local encryptionType', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' });
|
||||
|
||||
expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp');
|
||||
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on failure without changing encryptionType', async () => {
|
||||
useAccountSecurityStore.setState({ encryptionType: 'disabled' });
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' }));
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' })
|
||||
).rejects.toThrow('Encryption error');
|
||||
|
||||
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
|
||||
const args = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(args).toEqual({ accountId: 'acc-primary', destroy: ['p1'] });
|
||||
expect(mockedJmap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearState', () => {
|
||||
it('resets all state to defaults', () => {
|
||||
it('resets all derived fields back to defaults', () => {
|
||||
useAccountSecurityStore.setState({
|
||||
isStalwart: true,
|
||||
otpEnabled: true,
|
||||
appPasswords: ['app1'],
|
||||
encryptionType: 'pgp',
|
||||
displayName: 'Test User',
|
||||
emails: ['test@example.com'],
|
||||
quota: 5000000,
|
||||
roles: ['admin'],
|
||||
error: 'some error',
|
||||
appPasswords: [{ id: 'p', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
|
||||
encryptionType: 'Aes256',
|
||||
displayName: 'user',
|
||||
emails: ['a@b'],
|
||||
quota: 10,
|
||||
roles: ['User'],
|
||||
error: 'x',
|
||||
});
|
||||
|
||||
useAccountSecurityStore.getState().clearState();
|
||||
|
||||
const state = useAccountSecurityStore.getState();
|
||||
expect(state.isStalwart).toBeNull();
|
||||
expect(state.isProbing).toBe(false);
|
||||
expect(state.otpEnabled).toBe(false);
|
||||
expect(state.appPasswords).toEqual([]);
|
||||
expect(state.encryptionType).toBe('disabled');
|
||||
expect(state.encryptionType).toBe('Disabled');
|
||||
expect(state.displayName).toBe('');
|
||||
expect(state.emails).toEqual([]);
|
||||
expect(state.quota).toBe(0);
|
||||
expect(state.roles).toEqual([]);
|
||||
expect(state.isSaving).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+206
-165
@@ -1,51 +1,83 @@
|
||||
import { create } from 'zustand';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { stalwartJmap, requireResult } from '@/lib/stalwart/jmap-passthrough';
|
||||
|
||||
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
|
||||
|
||||
export interface AppPasswordInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
allowedIps: string[];
|
||||
}
|
||||
|
||||
interface AccountSecurityState {
|
||||
// Detection
|
||||
isStalwart: boolean | null; // null = not yet probed
|
||||
isStalwart: boolean | null;
|
||||
isProbing: boolean;
|
||||
|
||||
// Auth info
|
||||
otpEnabled: boolean;
|
||||
appPasswords: string[];
|
||||
appPasswords: AppPasswordInfo[];
|
||||
isLoadingAuth: boolean;
|
||||
|
||||
// Crypto info
|
||||
encryptionType: string;
|
||||
// Encryption-at-rest
|
||||
encryptionType: EncryptionType;
|
||||
isLoadingCrypto: boolean;
|
||||
|
||||
// Principal info
|
||||
// Profile
|
||||
displayName: string;
|
||||
emails: string[];
|
||||
quota: number;
|
||||
roles: string[];
|
||||
isLoadingPrincipal: boolean;
|
||||
|
||||
// Operation states
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
probe: () => Promise<boolean>;
|
||||
fetchAuthInfo: () => Promise<void>;
|
||||
fetchCryptoInfo: () => Promise<void>;
|
||||
fetchPrincipal: () => Promise<void>;
|
||||
fetchAll: () => Promise<void>;
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
updateDisplayName: (displayName: string) => Promise<void>;
|
||||
enableTotp: () => Promise<string>;
|
||||
disableTotp: () => Promise<void>;
|
||||
addAppPassword: (name: string, password: string) => Promise<void>;
|
||||
removeAppPassword: (name: string) => Promise<void>;
|
||||
updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise<void>;
|
||||
|
||||
enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise<void>;
|
||||
disableTotp: (currentPassword: string) => Promise<void>;
|
||||
|
||||
createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>;
|
||||
removeAppPassword: (id: string) => Promise<void>;
|
||||
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
function getApiHeaders(): Record<string, string> {
|
||||
return getActiveAccountSlotHeaders();
|
||||
function getPrimaryAccountId(): string {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) throw new Error('Not authenticated');
|
||||
return client.getAccountId();
|
||||
}
|
||||
|
||||
function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
|
||||
? Object.keys(raw.allowedIps as Record<string, unknown>)
|
||||
: [];
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
description: typeof raw.description === 'string' ? raw.description : '',
|
||||
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
|
||||
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
|
||||
allowedIps,
|
||||
};
|
||||
}
|
||||
|
||||
function extractEncryptionType(raw: unknown): EncryptionType {
|
||||
if (!raw || typeof raw !== 'object') return 'Disabled';
|
||||
const type = (raw as { ['@type']?: string })['@type'];
|
||||
if (type === 'Aes128' || type === 'Aes256') return type;
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
|
||||
@@ -54,7 +86,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'disabled',
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
displayName: '',
|
||||
emails: [],
|
||||
@@ -67,11 +99,8 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
probe: async () => {
|
||||
set({ isProbing: true });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/probe', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
const data = await response.json();
|
||||
const isStalwart = data.isStalwart === true;
|
||||
const client = useAuthStore.getState().client;
|
||||
const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap');
|
||||
set({ isStalwart, isProbing: false });
|
||||
return isStalwart;
|
||||
} catch (error) {
|
||||
@@ -84,16 +113,31 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
fetchAuthInfo: async () => {
|
||||
set({ isLoadingAuth: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/auth', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
set({
|
||||
otpEnabled: data.data?.otpEnabled ?? false,
|
||||
appPasswords: data.data?.appPasswords ?? [],
|
||||
isLoadingAuth: false,
|
||||
});
|
||||
const accountId = getPrimaryAccountId();
|
||||
const responses = await stalwartJmap([
|
||||
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'],
|
||||
['x:AppPassword/query', { accountId }, '1'],
|
||||
]);
|
||||
|
||||
const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(
|
||||
responses,
|
||||
'x:AccountPassword/get',
|
||||
);
|
||||
const queryResult = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
|
||||
|
||||
const otpAuth = passwordResult.list?.[0]?.otpAuth;
|
||||
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
|
||||
|
||||
let appPasswords: AppPasswordInfo[] = [];
|
||||
if (queryResult.ids?.length) {
|
||||
const getResponses = await stalwartJmap([
|
||||
['x:AppPassword/get', { accountId, ids: queryResult.ids }, '0'],
|
||||
]);
|
||||
const getResult = requireResult<{ list: Array<Record<string, unknown>> }>(getResponses, 'x:AppPassword/get');
|
||||
appPasswords = (getResult.list ?? []).map(appPasswordFromResult);
|
||||
}
|
||||
|
||||
set({ otpEnabled, appPasswords, isLoadingAuth: false });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch auth info:', error);
|
||||
set({
|
||||
@@ -106,15 +150,16 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
fetchCryptoInfo: async () => {
|
||||
set({ isLoadingCrypto: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/crypto', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
set({
|
||||
encryptionType: data.data?.type ?? 'disabled',
|
||||
isLoadingCrypto: false,
|
||||
});
|
||||
const accountId = getPrimaryAccountId();
|
||||
const responses = await stalwartJmap([
|
||||
['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0'],
|
||||
]);
|
||||
const result = requireResult<{ list: Array<{ encryptionAtRest?: unknown }> }>(
|
||||
responses,
|
||||
'x:AccountSettings/get',
|
||||
);
|
||||
const encryptionType = extractEncryptionType(result.list?.[0]?.encryptionAtRest);
|
||||
set({ encryptionType, isLoadingCrypto: false });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch crypto info:', error);
|
||||
set({
|
||||
@@ -127,37 +172,42 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
fetchPrincipal: async () => {
|
||||
set({ isLoadingPrincipal: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/principal', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
// User lacks permission to read principal (e.g. non-admin); treat as empty
|
||||
set({
|
||||
displayName: '',
|
||||
emails: [],
|
||||
quota: 0,
|
||||
roles: [],
|
||||
isLoadingPrincipal: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const principal = data.data;
|
||||
const accountId = getPrimaryAccountId();
|
||||
const responses = await stalwartJmap([
|
||||
['x:Account/get', { accountId, ids: [accountId] }, '0'],
|
||||
]);
|
||||
const result = requireResult<{
|
||||
list: Array<{
|
||||
description?: string | null;
|
||||
aliases?: Record<string, { name?: string; domainId?: string; enabled?: boolean }>;
|
||||
quotas?: { maxDiskQuota?: number };
|
||||
roles?: { ['@type']?: string };
|
||||
name?: string;
|
||||
domainId?: string;
|
||||
}>;
|
||||
}>(responses, 'x:Account/get');
|
||||
|
||||
const acc = result.list?.[0];
|
||||
const aliasAddresses = acc?.aliases
|
||||
? Object.values(acc.aliases)
|
||||
.filter((a) => a?.enabled !== false && a?.name)
|
||||
.map((a) => a?.name!)
|
||||
: [];
|
||||
const primaryEmail = acc?.name ? [acc.name] : [];
|
||||
set({
|
||||
displayName: principal?.description ?? '',
|
||||
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
|
||||
quota: principal?.quota ?? 0,
|
||||
roles: principal?.roles ?? [],
|
||||
displayName: acc?.description ?? '',
|
||||
emails: [...primaryEmail, ...aliasAddresses],
|
||||
quota: acc?.quotas?.maxDiskQuota ?? 0,
|
||||
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
|
||||
isLoadingPrincipal: false,
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch principal:', error);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to fetch principal';
|
||||
const isForbidden = msg.toLowerCase().includes('forbidden');
|
||||
set({
|
||||
isLoadingPrincipal: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch principal',
|
||||
error: isForbidden ? null : msg,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -170,17 +220,17 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
changePassword: async (currentPassword, newPassword) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/password', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId,
|
||||
update: { singleton: { currentSecret: currentPassword, secret: newPassword } },
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -194,19 +244,14 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
updateDisplayName: async (displayName) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/principal', {
|
||||
method: 'PATCH',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([
|
||||
{ action: 'set', field: 'description', value: displayName },
|
||||
]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
[
|
||||
'x:AccountSettings/set',
|
||||
{ accountId, update: { singleton: { description: displayName } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
set({ displayName, isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -217,23 +262,26 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
enableTotp: async () => {
|
||||
enableTotp: async (currentPassword, otpUrl, otpCode) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/auth', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
singleton: {
|
||||
currentSecret: currentPassword,
|
||||
otpAuth: { otpUrl, otpCode },
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
set({ otpEnabled: true, isSaving: false });
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
@@ -243,20 +291,25 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
disableTotp: async () => {
|
||||
disableTotp: async (currentPassword) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/auth', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
singleton: {
|
||||
currentSecret: currentPassword,
|
||||
otpAuth: { otpUrl: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
set({ otpEnabled: false, isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -267,47 +320,59 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
addAppPassword: async (name, password) => {
|
||||
createAppPassword: async (description, expiresAt) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/auth', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
|
||||
});
|
||||
const accountId = getPrimaryAccountId();
|
||||
const tmpId = 'new';
|
||||
const responses = await stalwartJmap([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{
|
||||
accountId,
|
||||
create: {
|
||||
[tmpId]: {
|
||||
description,
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = requireResult<{
|
||||
created?: Record<string, { id: string; secret: string; createdAt?: string }>;
|
||||
notCreated?: Record<string, { type: string; description?: string }>;
|
||||
}>(responses, 'x:AppPassword/set');
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) {
|
||||
throw new Error(notCreated.description || notCreated.type || 'Failed to create app password');
|
||||
}
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id || !created.secret) {
|
||||
throw new Error('Server did not return created app password');
|
||||
}
|
||||
|
||||
// Refresh auth info to get updated app passwords list
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
return { id: created.id, secret: created.secret };
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to add app password',
|
||||
error: error instanceof Error ? error.message : 'Failed to create app password',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeAppPassword: async (name) => {
|
||||
removeAppPassword: async (id) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/auth', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Refresh auth info to get updated app passwords list
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
['x:AppPassword/set', { accountId, destroy: [id] }, '0'],
|
||||
]);
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
@@ -319,37 +384,13 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
updateEncryption: async (settings) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await apiFetch('/api/account/stalwart/crypto', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || data.details || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
set({ encryptionType: settings.type, isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update encryption',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearState: () => set({
|
||||
isStalwart: null,
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'disabled',
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
displayName: '',
|
||||
emails: [],
|
||||
|
||||
@@ -29,7 +29,6 @@ interface FilterStore {
|
||||
toggleRule: (ruleId: string) => void;
|
||||
setRawScript: (content: string) => void;
|
||||
resetToVisualBuilder: () => void;
|
||||
syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise<void>;
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
@@ -193,69 +192,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
|
||||
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
|
||||
|
||||
syncVacationToScript: async (client, vacation) => {
|
||||
try {
|
||||
// Preserve current rules before re-fetching, since the server
|
||||
// may have overwritten our script with a vacation-only one.
|
||||
const { rules: previousRules } = get();
|
||||
|
||||
// Always re-fetch scripts from the server to get the current state
|
||||
// after Stalwart may have rewritten the active script.
|
||||
const allScripts = await client.getSieveScripts();
|
||||
// Skip the server-managed 'vacation' script (RFC 9661 §4)
|
||||
const scripts = allScripts.filter(s => s.name !== 'vacation');
|
||||
const activeScript = scripts.find(s => s.isActive) || scripts[0];
|
||||
|
||||
let rules = previousRules;
|
||||
let externalRequires = get().externalRequires;
|
||||
|
||||
// If there's an active script, try to parse our metadata from it.
|
||||
// If the server overwrote it (no metadata), fall back to stored rules.
|
||||
if (activeScript) {
|
||||
const content = await client.getSieveScriptContent(activeScript.blobId);
|
||||
const parsed = parseScript(content);
|
||||
if (!parsed.isOpaque) {
|
||||
rules = parsed.rules;
|
||||
externalRequires = parsed.externalRequires;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a combined script with our metadata, rules, and vacation
|
||||
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined, { externalRequires });
|
||||
|
||||
if (activeScript) {
|
||||
// Preserve the script's current activation state - don't pass activate: true
|
||||
// unconditionally, as that would deactivate the server-managed 'vacation'
|
||||
// script and cause VacationResponse/get to return isEnabled: false.
|
||||
await client.updateSieveScript(activeScript.id, content, activeScript.isActive);
|
||||
set({
|
||||
activeScriptId: activeScript.id,
|
||||
rawScript: content,
|
||||
rules,
|
||||
vacationSettings: vacation,
|
||||
isOpaque: false,
|
||||
externalRequires,
|
||||
});
|
||||
} else {
|
||||
// Don't activate; there may be a server-managed 'vacation' script active.
|
||||
// The filters script will be activated when the user saves filters normally.
|
||||
const script = await client.createSieveScript('filters', content, false);
|
||||
set({
|
||||
activeScriptId: script.id,
|
||||
rawScript: content,
|
||||
rules,
|
||||
vacationSettings: vacation,
|
||||
isOpaque: false,
|
||||
externalRequires,
|
||||
});
|
||||
}
|
||||
|
||||
debug.log('filters', 'Vacation synced to sieve script');
|
||||
} catch (error) {
|
||||
debug.error('Failed to sync vacation to sieve script:', error);
|
||||
}
|
||||
},
|
||||
|
||||
clearState: () => set({
|
||||
rules: [],
|
||||
isLoading: false,
|
||||
|
||||
Reference in New Issue
Block a user