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:
Linus Rath
2026-03-12 02:20:56 +01:00
parent 68d4a9a641
commit ab72fc06ff
26 changed files with 3143 additions and 310 deletions
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StalwartClient } from '../stalwart/client';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('StalwartClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: StalwartClient;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('constructor', () => {
it('strips trailing slash from server URL', () => {
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
c.getAuthInfo();
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.anything()
);
});
});
describe('probe', () => {
it('returns true when server responds with data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns true when server returns 401 (API exists but needs auth)', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns false when server returns 404', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false when response has no data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
const result = await client.probe();
expect(result).toBe(false);
});
});
describe('getAuthInfo', () => {
it('returns auth info on success', async () => {
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
const result = await client.getAuthInfo();
expect(result).toEqual(authInfo);
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Basic dXNlcjpwYXNz',
}),
})
);
});
it('throws on non-ok response', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
});
it('throws with HTTP status when error body is unparseable', async () => {
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth action and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await client.enableTotp();
expect(result).toBe(totpUrl);
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth action', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.disableTotp();
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword action with name and password', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.addAppPassword('Thunderbird', 'secret123');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword action with name', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.removeAppPassword('Thunderbird');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('getCryptoInfo', () => {
it('returns crypto info on success', async () => {
const cryptoInfo = { type: 'pgp' as const };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
const result = await client.getCryptoInfo();
expect(result).toEqual(cryptoInfo);
});
});
describe('updateCrypto', () => {
it('sends crypto settings', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateCrypto({ type: 'pgp' });
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual({ type: 'pgp' });
});
});
describe('getPrincipal', () => {
it('returns principal data on success', async () => {
const principal = {
id: 1, type: 'individual', name: 'testuser',
description: 'Test User', emails: ['test@example.com'],
secrets: [], quota: 1000000, roles: ['user'], lists: [],
};
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
const result = await client.getPrincipal('testuser');
expect(result).toEqual(principal);
});
it('encodes special characters in username', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await client.getPrincipal('user@example.com');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/principal/user%40example.com',
expect.anything()
);
});
});
describe('updatePrincipal', () => {
it('sends PATCH with action array', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updatePrincipal('testuser', [
{ action: 'set', field: 'description', value: 'New Name' },
]);
const call = fetchSpy.mock.calls[0];
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
expect(call[1]?.method).toBe('PATCH');
const body = JSON.parse(call[1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
});
describe('changePassword', () => {
it('sends set secrets action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.changePassword('testuser', 'newPassword123');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
});
});
describe('updateDisplayName', () => {
it('sends set description action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateDisplayName('testuser', 'John Doe');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
});
});
describe('request error handling', () => {
it('parses error.detail from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
});
it('parses error.details from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
});
it('parses error.error from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
});
it('falls back to HTTP status code on non-JSON error', async () => {
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
});
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Stalwart Management API Client
*
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
* - Password change (PATCH /principal/{name})
* - Display name update (PATCH /principal/{name})
* - App passwords (POST /account/auth)
* - TOTP 2FA management (POST /account/auth)
* - Encryption-at-rest (GET/POST /account/crypto)
* - Account auth info (GET /account/auth)
*/
export interface StalwartAuthInfo {
otpEnabled: boolean;
isAdminApp: boolean;
appPasswords: string[];
}
export interface StalwartCryptoInfo {
type: 'disabled' | 'pgp' | 'smime';
}
export interface StalwartPrincipal {
id: number;
type: string;
name: string;
description: string;
emails: string | string[];
secrets: string | string[];
quota: number;
roles: string[];
lists: string[];
}
export interface PrincipalUpdateAction {
action: 'set' | 'addItem' | 'removeItem';
field: string;
value: string | number;
}
export interface StalwartApiError {
error: string;
details: string;
reason?: string | null;
}
export class StalwartClient {
private baseUrl: string;
private authHeader: string;
constructor(serverUrl: string, authHeader: string) {
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
this.authHeader = authHeader;
}
// eslint-disable-next-line no-undef
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let errorDetail = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.detail) errorDetail = body.detail;
else if (body.details) errorDetail = body.details;
else if (body.error) errorDetail = body.error;
} catch { /* use status code */ }
throw new Error(errorDetail);
}
return response.json();
}
/** Probe whether this server exposes Stalwart's management API */
async probe(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/account/auth`, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (response.status === 401) return true; // API exists but needs auth
if (!response.ok) return false;
const data = await response.json();
return data.data !== undefined;
} catch {
return false;
}
}
/** GET /account/auth - Fetch 2FA and app password status */
async getAuthInfo(): Promise<StalwartAuthInfo> {
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
return result.data;
}
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify(actions),
});
}
/** Enable TOTP - returns the TOTP URL for QR code generation */
async enableTotp(): Promise<string> {
const result = await this.request<{ data: string }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
return result.data;
}
/** Disable TOTP */
async disableTotp(): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
}
/** Add an app password */
async addAppPassword(name: string, password: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
}
/** Remove an app password */
async removeAppPassword(name: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
}
/** GET /account/crypto - Fetch encryption-at-rest settings */
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
return result.data;
}
/** POST /account/crypto - Update encryption-at-rest settings */
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
await this.request<{ data: unknown }>('/account/crypto', {
method: 'POST',
body: JSON.stringify(settings),
});
}
/** GET /principal/{name} - Fetch principal details */
async getPrincipal(name: string): Promise<StalwartPrincipal> {
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
return result.data;
}
/** PATCH /principal/{name} - Update principal fields */
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(actions),
});
}
/** Change password via PATCH /principal/{name} */
async changePassword(name: string, newPassword: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'secrets', value: newPassword },
]);
}
/** Update display name via PATCH /principal/{name} */
async updateDisplayName(name: string, displayName: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'description', value: displayName },
]);
}
}