import { create } from 'zustand'; import { debug } from '@/lib/debug'; 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[]; } export interface ApiKeyInfo { id: string; description: string; createdAt: string | null; expiresAt: string | null; allowedIps: string[]; } export interface AppCredentialInput { description: string; expiresAt?: string | null; allowedIps?: string[]; } interface AccountSecurityState { isStalwart: boolean | null; isProbing: boolean; // Auth info otpEnabled: boolean; appPasswords: AppPasswordInfo[]; apiKeys: ApiKeyInfo[]; isLoadingAuth: boolean; // Encryption-at-rest encryptionType: EncryptionType; isLoadingCrypto: boolean; // Profile displayName: string; emails: string[]; quota: number; roles: string[]; isLoadingPrincipal: boolean; isSaving: boolean; error: string | null; probe: () => Promise; fetchAuthInfo: () => Promise; fetchCryptoInfo: () => Promise; fetchPrincipal: () => Promise; fetchAll: () => Promise; changePassword: (currentPassword: string, newPassword: string) => Promise; updateDisplayName: (displayName: string) => Promise; enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise; disableTotp: (currentPassword: string) => Promise; createAppPassword: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>; removeAppPassword: (id: string) => Promise; createApiKey: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>; removeApiKey: (id: string) => Promise; clearState: () => void; } function getPrimaryAccountId(): string { const client = useAuthStore.getState().client; if (!client) throw new Error('Not authenticated'); return client.getAccountId(); } function credentialFromResult(raw: Record): AppPasswordInfo { const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object' ? Object.keys(raw.allowedIps as Record) : []; 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 ipsToMap(ips?: string[]): Record | undefined { if (!ips || ips.length === 0) return undefined; return Object.fromEntries(ips.map((ip) => [ip, true])); } function buildCreateBody(input: AppCredentialInput): Record { const body: Record = { description: input.description }; if (input.expiresAt) body.expiresAt = input.expiresAt; const allowed = ipsToMap(input.allowedIps); if (allowed) body.allowedIps = allowed; return body; } type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set'; type StoreGet = () => AccountSecurityState; type StoreSet = (partial: Partial) => void; async function createCredential( get: StoreGet, set: StoreSet, method: SetMethod, input: AppCredentialInput, fallbackError: string, ): Promise<{ id: string; secret: string }> { set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); const tmpId = 'new'; const responses = await stalwartJmap([ [method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0'], ]); const result = requireResult<{ created?: Record; notCreated?: Record; }>(responses, method); const notCreated = result.notCreated?.[tmpId]; if (notCreated) { throw new Error(notCreated.description || notCreated.type || fallbackError); } const created = result.created?.[tmpId]; if (!created?.id || !created.secret) { throw new Error(`Server did not return created credential`); } await get().fetchAuthInfo(); set({ isSaving: false }); return { id: created.id, secret: created.secret }; } catch (error) { set({ isSaving: false, error: error instanceof Error ? error.message : fallbackError, }); throw error; } } async function removeCredential( get: StoreGet, set: StoreSet, method: SetMethod, id: string, fallbackError: string, ): Promise { set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); await stalwartJmap([ [method, { accountId, destroy: [id] }, '0'], ]); await get().fetchAuthInfo(); set({ isSaving: false }); } catch (error) { set({ isSaving: false, error: error instanceof Error ? error.message : fallbackError, }); throw error; } } 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()((set, get) => ({ isStalwart: null, isProbing: false, otpEnabled: false, appPasswords: [], apiKeys: [], isLoadingAuth: false, encryptionType: 'Disabled', isLoadingCrypto: false, displayName: '', emails: [], quota: 0, roles: [], isLoadingPrincipal: false, isSaving: false, error: null, probe: async () => { set({ isProbing: true }); try { const client = useAuthStore.getState().client; const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap'); set({ isStalwart, isProbing: false }); return isStalwart; } catch (error) { debug.error('Stalwart probe failed:', error); set({ isStalwart: false, isProbing: false }); return false; } }, fetchAuthInfo: async () => { set({ isLoadingAuth: true, error: null }); try { const accountId = getPrimaryAccountId(); const responses = await stalwartJmap([ ['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'], ['x:AppPassword/query', { accountId }, '1'], ['x:ApiKey/query', { accountId }, '2'], ]); const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>( responses, 'x:AccountPassword/get', ); const appPwQuery = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query'); const apiKeyQuery = requireResult<{ ids: string[] }>(responses, 'x:ApiKey/query'); const otpAuth = passwordResult.list?.[0]?.otpAuth; const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl); const followUps: [string, Record, string][] = []; if (appPwQuery.ids?.length) { followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']); } if (apiKeyQuery.ids?.length) { followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']); } let appPasswords: AppPasswordInfo[] = []; let apiKeys: ApiKeyInfo[] = []; if (followUps.length) { const followUpResponses = await stalwartJmap(followUps); if (appPwQuery.ids?.length) { const r = requireResult<{ list: Array> }>(followUpResponses, 'x:AppPassword/get'); appPasswords = (r.list ?? []).map(credentialFromResult); } if (apiKeyQuery.ids?.length) { const r = requireResult<{ list: Array> }>(followUpResponses, 'x:ApiKey/get'); apiKeys = (r.list ?? []).map(credentialFromResult); } } set({ otpEnabled, appPasswords, apiKeys, isLoadingAuth: false }); } catch (error) { debug.error('Failed to fetch auth info:', error); set({ isLoadingAuth: false, error: error instanceof Error ? error.message : 'Failed to fetch auth info', }); } }, fetchCryptoInfo: async () => { set({ isLoadingCrypto: true, error: null }); try { 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({ isLoadingCrypto: false, error: error instanceof Error ? error.message : 'Failed to fetch crypto info', }); } }, fetchPrincipal: async () => { set({ isLoadingPrincipal: true, error: null }); try { const accountId = getPrimaryAccountId(); const responses = await stalwartJmap([ ['x:Account/get', { accountId, ids: [accountId] }, '0'], ]); const result = requireResult<{ list: Array<{ description?: string | null; aliases?: Record; 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) .flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : [])) : []; const primaryEmail = acc?.name ? [acc.name] : []; set({ 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: isForbidden ? null : msg, }); } }, fetchAll: async () => { const { fetchAuthInfo, fetchCryptoInfo, fetchPrincipal } = get(); await Promise.allSettled([fetchAuthInfo(), fetchCryptoInfo(), fetchPrincipal()]); }, changePassword: async (currentPassword, newPassword) => { set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); await stalwartJmap([ [ 'x:AccountPassword/set', { accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } }, }, '0', ], ]); set({ isSaving: false }); } catch (error) { set({ isSaving: false, error: error instanceof Error ? error.message : 'Failed to change password', }); throw error; } }, updateDisplayName: async (displayName) => { set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); await stalwartJmap([ [ 'x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0', ], ]); set({ displayName, isSaving: false }); } catch (error) { set({ isSaving: false, error: error instanceof Error ? error.message : 'Failed to update display name', }); throw error; } }, enableTotp: async (currentPassword, otpUrl, otpCode) => { set({ isSaving: true, error: null }); try { const accountId = getPrimaryAccountId(); await stalwartJmap([ [ 'x:AccountPassword/set', { accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode }, }, }, }, '0', ], ]); set({ otpEnabled: true, isSaving: false }); } catch (error) { set({ isSaving: false, error: error instanceof Error ? error.message : 'Failed to enable TOTP', }); throw error; } }, disableTotp: async (currentPassword) => { set({ isSaving: true, error: null }); try { 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({ isSaving: false, error: error instanceof Error ? error.message : 'Failed to disable TOTP', }); throw error; } }, createAppPassword: async (input) => { return createCredential(get, set, 'x:AppPassword/set', input, 'Failed to create app password'); }, removeAppPassword: async (id) => { return removeCredential(get, set, 'x:AppPassword/set', id, 'Failed to remove app password'); }, createApiKey: async (input) => { return createCredential(get, set, 'x:ApiKey/set', input, 'Failed to create API key'); }, removeApiKey: async (id) => { return removeCredential(get, set, 'x:ApiKey/set', id, 'Failed to remove API key'); }, clearState: () => set({ isStalwart: null, isProbing: false, otpEnabled: false, appPasswords: [], apiKeys: [], isLoadingAuth: false, encryptionType: 'Disabled', isLoadingCrypto: false, displayName: '', emails: [], quota: 0, roles: [], isLoadingPrincipal: false, isSaving: false, error: null, }), }));