feat: add Stalwart account security management
- Add Stalwart API client library (lib/stalwart/client.ts) - Add server-side proxy routes for auth, crypto, password, principal, probe - Add account security Zustand store with full state management - Add Security settings tab with password change, display name, TOTP 2FA, app passwords, and encryption-at-rest controls - Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false) - Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt) - Add tests for Stalwart client (24 tests) and security store (29 tests)
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { create } from 'zustand';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
interface AccountSecurityState {
|
||||
// Detection
|
||||
isStalwart: boolean | null; // null = not yet probed
|
||||
isProbing: boolean;
|
||||
|
||||
// Auth info
|
||||
otpEnabled: boolean;
|
||||
appPasswords: string[];
|
||||
isLoadingAuth: boolean;
|
||||
|
||||
// Crypto info
|
||||
encryptionType: string;
|
||||
isLoadingCrypto: boolean;
|
||||
|
||||
// Principal info
|
||||
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>;
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests.
|
||||
* Returns headers object with auth credentials.
|
||||
*/
|
||||
function getApiHeaders(): Record<string, string> {
|
||||
// We rely on the proxy routes which read from session cookie
|
||||
// No additional headers needed for cookie-based auth
|
||||
return {};
|
||||
}
|
||||
|
||||
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
|
||||
isStalwart: null,
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'disabled',
|
||||
isLoadingCrypto: false,
|
||||
displayName: '',
|
||||
emails: [],
|
||||
quota: 0,
|
||||
roles: [],
|
||||
isLoadingPrincipal: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
|
||||
probe: async () => {
|
||||
set({ isProbing: true });
|
||||
try {
|
||||
const response = await fetch('/api/account/stalwart/probe', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
const data = await response.json();
|
||||
const isStalwart = data.isStalwart === true;
|
||||
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 response = await fetch('/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,
|
||||
});
|
||||
} 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 response = await fetch('/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,
|
||||
});
|
||||
} 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 response = await fetch('/api/account/stalwart/principal', {
|
||||
headers: getApiHeaders(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
const principal = data.data;
|
||||
set({
|
||||
displayName: principal?.description ?? '',
|
||||
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
|
||||
quota: principal?.quota ?? 0,
|
||||
roles: principal?.roles ?? [],
|
||||
isLoadingPrincipal: false,
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch principal:', error);
|
||||
set({
|
||||
isLoadingPrincipal: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch principal',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchAll: async () => {
|
||||
const { fetchAuthInfo, fetchCryptoInfo, fetchPrincipal } = get();
|
||||
await Promise.allSettled([fetchAuthInfo(), fetchCryptoInfo(), fetchPrincipal()]);
|
||||
},
|
||||
|
||||
changePassword: async (currentPassword, newPassword) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/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}`);
|
||||
}
|
||||
|
||||
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 response = await fetch('/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}`);
|
||||
}
|
||||
|
||||
set({ displayName, isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update display name',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
enableTotp: async () => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/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();
|
||||
set({ otpEnabled: true, isSaving: false });
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to enable TOTP',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
disableTotp: async () => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/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}`);
|
||||
}
|
||||
|
||||
set({ otpEnabled: false, isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to disable TOTP',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addAppPassword: async (name, password) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/account/stalwart/auth', {
|
||||
method: 'POST',
|
||||
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
|
||||
});
|
||||
|
||||
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
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to add app password',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeAppPassword: async (name) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/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
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove app password',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateEncryption: async (settings) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/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',
|
||||
isLoadingCrypto: false,
|
||||
displayName: '',
|
||||
emails: [],
|
||||
quota: 0,
|
||||
roles: [],
|
||||
isLoadingPrincipal: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user