From 794001fdbd879a4cbd0687cb148f5bd9e21a92c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:29:23 +0200 Subject: [PATCH 01/31] 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 --- app/api/account/stalwart/auth/route.ts | 87 --- app/api/account/stalwart/crypto/route.ts | 87 --- app/api/account/stalwart/jmap/route.ts | 46 ++ app/api/account/stalwart/password/route.ts | 93 ---- app/api/account/stalwart/principal/route.ts | 96 ---- app/api/account/stalwart/probe/route.ts | 44 -- app/api/admin/auth/route.ts | 26 +- app/api/admin/stalwart-check/route.ts | 41 -- app/api/webdav/route.ts | 2 +- components/layout/navigation-rail.tsx | 7 +- .../settings/account-security-settings.tsx | 348 +++++++----- components/settings/vacation-settings.tsx | 14 - lib/__tests__/jmap-passthrough.test.ts | 126 +++++ lib/__tests__/stalwart-client.test.ts | 246 --------- lib/demo/demo-client.ts | 4 + lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 7 + lib/stalwart/client.ts | 185 ------- lib/stalwart/credentials.ts | 27 +- lib/stalwart/jmap-passthrough.ts | 66 +++ package-lock.json | 287 +++++++++- package.json | 3 + .../__tests__/account-security-store.test.ts | 503 ++++++++---------- stores/account-security-store.ts | 371 +++++++------ stores/filter-store.ts | 64 --- 25 files changed, 1189 insertions(+), 1592 deletions(-) delete mode 100644 app/api/account/stalwart/auth/route.ts delete mode 100644 app/api/account/stalwart/crypto/route.ts create mode 100644 app/api/account/stalwart/jmap/route.ts delete mode 100644 app/api/account/stalwart/password/route.ts delete mode 100644 app/api/account/stalwart/principal/route.ts delete mode 100644 app/api/account/stalwart/probe/route.ts delete mode 100644 app/api/admin/stalwart-check/route.ts create mode 100644 lib/__tests__/jmap-passthrough.test.ts delete mode 100644 lib/__tests__/stalwart-client.test.ts delete mode 100644 lib/stalwart/client.ts create mode 100644 lib/stalwart/jmap-passthrough.ts diff --git a/app/api/account/stalwart/auth/route.ts b/app/api/account/stalwart/auth/route.ts deleted file mode 100644 index 4fa0f4d0..00000000 --- a/app/api/account/stalwart/auth/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/auth - * Proxy to Stalwart GET /api/account/auth - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart auth info failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch auth info' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/account/stalwart/auth - * Proxy to Stalwart POST /api/account/auth - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'POST', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart auth update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/crypto/route.ts b/app/api/account/stalwart/crypto/route.ts deleted file mode 100644 index 07681115..00000000 --- a/app/api/account/stalwart/crypto/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/crypto - * Proxy to Stalwart GET /api/account/crypto - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart crypto info failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch crypto info' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/account/stalwart/crypto - * Proxy to Stalwart POST /api/account/crypto - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { - method: 'POST', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart crypto update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts new file mode 100644 index 00000000..7f292c17 --- /dev/null +++ b/app/api/account/stalwart/jmap/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +/** + * POST /api/account/stalwart/jmap + * + * Passthrough to Stalwart's JMAP endpoint using the stored basic-auth + * context so the browser does not need access to the user's credentials. + * + * Body: standard JMAP request `{ using: string[], methodCalls: [...] }` + * + * In Stalwart 0.16 all management operations (password change, app + * passwords, API keys, account settings, etc.) are exposed as JMAP + * methods under the `x:` namespace on the same endpoint. + */ +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.text(); + + const response = await fetch(`${creds.serverUrl}/jmap/`, { + method: 'POST', + headers: { + 'Authorization': creds.authHeader, + 'Content-Type': 'application/json', + }, + body, + }); + + const responseText = await response.text(); + return new NextResponse(responseText, { + status: response.status, + headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' }, + }); + } catch (error) { + logger.error('Stalwart JMAP passthrough error', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/account/stalwart/password/route.ts b/app/api/account/stalwart/password/route.ts deleted file mode 100644 index e229e63f..00000000 --- a/app/api/account/stalwart/password/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { logger } from '@/lib/logger'; -import { encryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; -import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context'; - -const COOKIE_OPTIONS = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/', - maxAge: SESSION_COOKIE_MAX_AGE, -}; - -/** - * POST /api/account/stalwart/password - * Change user password via Stalwart PATCH /api/principal/{name} - * - * Body: { currentPassword: string, newPassword: string } - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const { currentPassword, newPassword } = await request.json(); - - if (!currentPassword || !newPassword) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - if (newPassword.length < 8) { - return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); - } - - // Verify current password by attempting to authenticate - const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`; - const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, { - method: 'GET', - headers: { 'Authorization': verifyAuth }, - }); - - if (!verifyResponse.ok) { - return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 }); - } - - // Change password via Stalwart principal API - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'PATCH', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify([ - { action: 'set', field: 'secrets', value: newPassword }, - ]), - }); - - if (!response.ok) { - const text = await response.text(); - logger.warn('Stalwart password change failed', { status: response.status }); - return NextResponse.json( - { error: 'Failed to change password', details: text }, - { status: response.status } - ); - } - - // If session cookie exists, update it with the new password - const cookieStore = await cookies(); - - if (creds.hasSessionCookie) { - const newToken = encryptSession(creds.serverUrl, creds.username, newPassword); - cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS); - } - - if (creds.authHeader.startsWith('Basic ')) { - setStalwartAuthContextInStore(cookieStore, creds.slot, { - serverUrl: creds.serverUrl, - username: creds.username, - authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`, - }); - } - - return NextResponse.json({ ok: true }); - } catch (error) { - logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/principal/route.ts b/app/api/account/stalwart/principal/route.ts deleted file mode 100644 index 8801c2b6..00000000 --- a/app/api/account/stalwart/principal/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/principal - * Proxy to Stalwart GET /api/principal/{username} - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart principal fetch failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch principal' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * PATCH /api/account/stalwart/principal - * Proxy to Stalwart PATCH /api/principal/{username} - * Body: PrincipalUpdateAction[] (array of {action, field, value}) - */ -export async function PATCH(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - // Prevent secrets field from being changed through this endpoint (use /password instead) - if (Array.isArray(body)) { - const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets'); - if (hasSecrets) { - return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 }); - } - } - - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'PATCH', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart principal update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/probe/route.ts b/app/api/account/stalwart/probe/route.ts deleted file mode 100644 index e7dbe525..00000000 --- a/app/api/account/stalwart/probe/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * GET /api/account/stalwart/probe - * Detect whether the JMAP server is Stalwart by probing /api/account/auth - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ isStalwart: false }); - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - try { - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - return NextResponse.json({ isStalwart: false }); - } - - const data = await response.json(); - const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean'; - - return NextResponse.json({ isStalwart }); - } catch { - clearTimeout(timeout); - return NextResponse.json({ isStalwart: false }); - } - } catch (error) { - logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ isStalwart: false }); - } -} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 803edd08..3ba03af6 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -7,20 +7,38 @@ import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** - * Check if the current user is a Stalwart admin by probing an admin-only endpoint. + * Permissions that indicate Stalwart admin privileges. + * If the authenticated user has at least one of these, they can manage + * system-level resources and are considered an admin. + */ +const ADMIN_PERMISSIONS = [ + 'sysAccountQuery', + 'sysTenantQuery', + 'sysSystemSettingsGet', +]; + +/** + * Check if the current user is a Stalwart admin by inspecting the + * permissions list returned by Stalwart's /api/account endpoint. */ async function checkStalwartAdmin(request: NextRequest): Promise { try { const creds = await getStalwartCredentials(request); if (!creds) return false; - // Probe admin-only endpoint: listing principals requires admin privileges - const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, { + const response = await fetch(`${creds.serverUrl}/api/account`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, }); - const isAdmin = response.ok; + if (!response.ok) { + logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false }); + return false; + } + + const data = await response.json() as { permissions?: string[] }; + const permissions = Array.isArray(data.permissions) ? data.permissions : []; + const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p)); logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin }); return isAdmin; } catch (error) { diff --git a/app/api/admin/stalwart-check/route.ts b/app/api/admin/stalwart-check/route.ts deleted file mode 100644 index 4c2c3d6a..00000000 --- a/app/api/admin/stalwart-check/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * GET /api/admin/stalwart-check - * Check if the currently logged-in user is a Stalwart admin. - * Probes the admin-only principal-list endpoint - if the user can access it, they're an admin. - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ isStalwartAdmin: false }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } - - // Probe an admin-only endpoint: listing principals requires admin privileges. - // Use limit=1 to minimize payload. - const url = `${creds.apiUrl}/api/principal?limit=1`; - const response = await fetch(url, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - const isStalwartAdmin = response.ok; - logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin }); - - return NextResponse.json({ isStalwartAdmin }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } catch (error) { - logger.error('Stalwart admin check error', { - error: error instanceof Error ? error.message : 'Unknown', - }); - return NextResponse.json({ isStalwartAdmin: false }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } -} diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts index 85cbbac7..acc19542 100644 --- a/app/api/webdav/route.ts +++ b/app/api/webdav/route.ts @@ -55,7 +55,7 @@ export async function POST(request: NextRequest) { } const davPath = request.headers.get('X-WebDAV-Path') || '/'; - const baseUrl = creds.apiUrl.replace(/\/$/, ''); + const baseUrl = creds.serverUrl.replace(/\/$/, ''); const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath); // Build headers for the upstream request diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 20062155..eeeccbba 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -223,11 +223,12 @@ export function NavigationRail({ let cancelled = false; const headers = getActiveAccountSlotHeaders(); if (!headers['X-JMAP-Cookie-Slot']) return; - apiFetch('/api/admin/stalwart-check', { headers }) + apiFetch('/api/admin/auth', { headers }) .then(res => res.json()) .then(data => { - if (!cancelled && data.isStalwartAdmin) { - setIsStalwartAdmin(true); + if (cancelled || !data.stalwartAdmin) return; + setIsStalwartAdmin(true); + if (!data.authenticated) { // Pre-create admin session so /admin works even after full page navigation apiFetch('/api/admin/auth', { method: 'POST', diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 5f1624d4..0f29068c 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslations } from 'next-intl'; +import QRCode from 'qrcode'; +import * as OTPAuth from 'otpauth'; import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; -import { useAccountSecurityStore } from '@/stores/account-security-store'; +import { useAccountSecurityStore, type AppPasswordInfo } from '@/stores/account-security-store'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; @@ -172,37 +174,95 @@ function DisplayNameSection() { ); } +function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } { + const totp = new OTPAuth.TOTP({ + issuer: 'Stalwart', + label: accountLabel || 'account', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new OTPAuth.Secret({ size: 20 }), + }); + return { totp, url: totp.toString() }; +} + function TotpSection() { const t = useTranslations('settings.security'); const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore(); - const [totpUrl, setTotpUrl] = useState(null); - const [copied, setCopied] = useState(false); + const { client } = useAuthStore(); + + const [setupUrl, setSetupUrl] = useState(null); + const [setupTotp, setSetupTotp] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [password, setPassword] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [setupError, setSetupError] = useState(null); + const [disableOpen, setDisableOpen] = useState(false); + + useEffect(() => { + if (!setupUrl) { setQrDataUrl(null); return; } + let cancelled = false; + QRCode.toDataURL(setupUrl, { width: 220, margin: 1 }) + .then((url) => { if (!cancelled) setQrDataUrl(url); }) + .catch(() => { /* ignore */ }); + return () => { cancelled = true; }; + }, [setupUrl]); + + const startSetup = () => { + const { totp, url } = generateTotp(client?.getUsername() ?? 'account'); + setSetupTotp(totp); + setSetupUrl(url); + setPassword(''); + setOtpCode(''); + setSetupError(null); + }; + + const cancelSetup = () => { + setSetupTotp(null); + setSetupUrl(null); + setPassword(''); + setOtpCode(''); + setSetupError(null); + }; + + const confirmSetup = async () => { + if (!setupTotp || !setupUrl) return; + if (!password) { setSetupError(t('totp.password_required')); return; } + if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; } + if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) { + setSetupError(t('totp.code_invalid')); + return; + } - const handleToggle = async (enable: boolean) => { try { - if (enable) { - const url = await enableTotp(); - setTotpUrl(url); - toast.success(t('totp.enabled')); - } else { - await disableTotp(); - setTotpUrl(null); - toast.success(t('totp.disabled')); - } + await enableTotp(password, setupUrl, otpCode.trim()); + cancelSetup(); + toast.success(t('totp.enabled')); } catch (err) { - toast.error( - enable ? t('totp.enable_error') : t('totp.disable_error'), - err instanceof Error ? err.message : undefined - ); + setSetupError(err instanceof Error ? err.message : t('totp.enable_error')); } }; - const handleCopyUrl = () => { - if (totpUrl) { - navigator.clipboard.writeText(totpUrl).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); + const handleDisable = async () => { + if (!password) { setSetupError(t('totp.password_required')); return; } + try { + await disableTotp(password); + setDisableOpen(false); + setPassword(''); + setSetupError(null); + toast.success(t('totp.disabled')); + } catch (err) { + setSetupError(err instanceof Error ? err.message : t('totp.disable_error')); + } + }; + + const handleToggle = (enable: boolean) => { + setSetupError(null); + if (enable) { + startSetup(); + } else { + setDisableOpen(true); + setPassword(''); } }; @@ -218,30 +278,66 @@ function TotpSection() {
- {isSaving ? ( - - ) : ( - - )} + {otpEnabled ? t('totp.active') : t('totp.inactive')}
- {totpUrl && ( -
+ {setupUrl && ( +

{t('totp.setup_instructions')}

+ {qrDataUrl && ( +
+ { /* eslint-disable-next-line @next/next/no-img-element */ } + TOTP QR code +
+ )}
- - {totpUrl} - -
+
+ + setPassword(e.target.value)} autoComplete="current-password" /> +
+
+ + setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} /> +
+ {setupError &&

{setupError}

} +
+ + +
+
+ )} + + {disableOpen && ( +
+

{t('totp.disable_confirm_prompt')}

+ setPassword(e.target.value)} + placeholder={t('password.current')} + autoComplete="current-password" + /> + {setupError &&

{setupError}

} +
+ +
@@ -250,36 +346,52 @@ function TotpSection() { ); } +function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) { + return ( +
+
+ {password.description || password.id} + {password.createdAt && ( + + {new Date(password.createdAt).toLocaleDateString()} + {password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''} + + )} +
+ +
+ ); +} + function AppPasswordsSection() { const t = useTranslations('settings.security'); - const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore(); + const { appPasswords, createAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore(); const [showAdd, setShowAdd] = useState(false); - const [newName, setNewName] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - - const generatePassword = useCallback(() => { - const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - let result = ''; - const array = new Uint8Array(24); - crypto.getRandomValues(array); - for (const byte of array) { - result += chars[byte % chars.length]; - } - // Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx - return result.match(/.{1,4}/g)?.join('-') ?? result; - }, []); + const [newDescription, setNewDescription] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [createdSecret, setCreatedSecret] = useState(null); + const [copied, setCopied] = useState(false); const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); - if (!newName.trim()) return; - - const password = newPassword || generatePassword(); + if (!newDescription.trim()) return; try { - await addAppPassword(newName.trim(), password); - setNewName(''); - setNewPassword(''); + const result = await createAppPassword( + newDescription.trim(), + expiresAt ? new Date(expiresAt).toISOString() : null, + ); + setCreatedSecret(result.secret); + setNewDescription(''); + setExpiresAt(''); setShowAdd(false); toast.success(t('app_passwords.added')); } catch (err) { @@ -287,15 +399,23 @@ function AppPasswordsSection() { } }; - const handleRemove = async (name: string) => { + const handleRemove = async (id: string) => { try { - await removeAppPassword(name); + await removeAppPassword(id); toast.success(t('app_passwords.removed')); } catch (err) { toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined); } }; + const handleCopySecret = () => { + if (!createdSecret) return; + navigator.clipboard.writeText(createdSecret).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + if (isLoadingAuth) { return (
@@ -322,43 +442,40 @@ function AppPasswordsSection() {

{t('app_passwords.description')}

+ {createdSecret && ( +
+

{t('app_passwords.copy_now_warning')}

+
+ + {createdSecret} + + +
+ +
+ )} + {showAdd && (
setNewName(e.target.value)} + value={newDescription} + onChange={(e) => setNewDescription(e.target.value)} placeholder={t('app_passwords.name_placeholder')} required />
- -
-
- setNewPassword(e.target.value)} - placeholder={t('app_passwords.password_placeholder')} - className="pr-10" - /> - -
- -
+ + setExpiresAt(e.target.value)} />
- @@ -371,19 +488,8 @@ function AppPasswordsSection() { {appPasswords.length > 0 ? (
- {appPasswords.map((name) => ( -
- {name} - -
+ {appPasswords.map((p) => ( + ))}
) : ( @@ -395,21 +501,7 @@ function AppPasswordsSection() { function EncryptionSection() { const t = useTranslations('settings.security'); - const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore(); - - const handleToggle = async (enabled: boolean) => { - try { - if (enabled) { - await updateEncryption({ type: 'pgp', algo: 'Aes256' }); - toast.success(t('encryption.enabled')); - } else { - await updateEncryption({ type: 'disabled' }); - toast.success(t('encryption.disabled_success')); - } - } catch (err) { - toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined); - } - }; + const { encryptionType, isLoadingCrypto } = useAccountSecurityStore(); if (isLoadingCrypto) { return ( @@ -419,24 +511,12 @@ function EncryptionSection() { ); } - const isEnabled = encryptionType !== 'disabled'; - + const isEnabled = encryptionType !== 'Disabled'; return ( -
- {isSaving ? ( - - ) : ( - - )} - - {isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')} - -
+ + {isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')} +
); } @@ -446,7 +526,7 @@ function EmailClientSection() { const { client } = useAuthStore(); const [copied, setCopied] = useState(false); - const jmapUsername = client?.getUsername() || ''; + const jmapUsername = useMemo(() => client?.getUsername() || '', [client]); const handleCopy = () => { navigator.clipboard.writeText(jmapUsername).then(() => { diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index d5df9913..19dd248b 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { useVacationStore } from '@/stores/vacation-store'; -import { useFilterStore } from '@/stores/filter-store'; import { useAuthStore } from '@/stores/auth-store'; import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react'; import { toast } from '@/stores/toast-store'; @@ -104,19 +103,6 @@ export function VacationSettings() { textBody: localTextBody, }); - // Re-save the filter script to preserve metadata and include vacation block. - // This prevents the server from injecting vacation Sieve code that destroys - // the metadata comment the visual filter builder relies on. - try { - await useFilterStore.getState().syncVacationToScript(client, { - isEnabled: localEnabled, - subject: localSubject, - textBody: localTextBody, - }); - } catch { - // Non-critical: vacation was saved via JMAP, script sync is best-effort - } - toast.success(tNotifications('vacation_saved')); } catch (error) { console.error('Failed to save vacation response:', error); diff --git a/lib/__tests__/jmap-passthrough.test.ts b/lib/__tests__/jmap-passthrough.test.ts new file mode 100644 index 00000000..5f89fd46 --- /dev/null +++ b/lib/__tests__/jmap-passthrough.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(), +})); + +vi.mock('@/lib/auth/active-account-slot', () => ({ + getActiveAccountSlotHeaders: vi.fn(() => ({ 'X-JMAP-Cookie-Slot': '0' })), +})); + +import { stalwartJmap, requireResult, STALWART_JMAP_USING } from '@/lib/stalwart/jmap-passthrough'; +import { apiFetch } from '@/lib/browser-navigation'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('stalwartJmap', () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POSTs to /api/account/stalwart/jmap with the standard using array', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] })); + + await stalwartJmap([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]); + + expect(mockedFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockedFetch.mock.calls[0]; + expect(url).toBe('/api/account/stalwart/jmap'); + expect(init.method).toBe('POST'); + + const body = JSON.parse(init.body as string); + expect(body.using).toEqual(STALWART_JMAP_USING); + expect(body.methodCalls).toEqual([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]); + }); + + it('forwards the active account slot header', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] })); + + await stalwartJmap([['x:Account/get', {}, '0']]); + + const init = mockedFetch.mock.calls[0][1]; + expect(init.headers['X-JMAP-Cookie-Slot']).toBe('0'); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('returns methodResponses on success', async () => { + const responses = [['x:AccountPassword/get', { list: [{ id: 'singleton' }] }, '0']]; + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: responses })); + + const result = await stalwartJmap([['x:AccountPassword/get', { accountId: 'a', ids: ['singleton'] }, '0']]); + + expect(result).toEqual(responses); + }); + + it('throws with status and message when the passthrough returns non-OK', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'Not authenticated' })); + + await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({ + status: 401, + message: 'Not authenticated', + }); + }); + + it('throws with HTTP fallback message when error body is unparseable', async () => { + mockedFetch.mockResolvedValueOnce(new Response('oh no', { status: 500 })); + + await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({ + status: 500, + message: 'HTTP 500', + }); + }); + + it('throws when first method response is a JMAP-level error', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { + methodResponses: [['error', { type: 'forbidden', description: 'Current secret must be provided' }, '0']], + })); + + await expect(stalwartJmap([['x:AccountPassword/set', {}, '0']])).rejects.toMatchObject({ + status: 200, + message: 'Current secret must be provided', + methodError: { type: 'forbidden', description: 'Current secret must be provided' }, + }); + }); + + it('falls back to error type when description is absent', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { + methodResponses: [['error', { type: 'unknownMethod' }, '0']], + })); + + await expect(stalwartJmap([['x:Nope/get', {}, '0']])).rejects.toMatchObject({ + methodError: { type: 'unknownMethod' }, + message: 'unknownMethod', + }); + }); +}); + +describe('requireResult', () => { + it('returns the arguments of the matching method', () => { + const responses: Array<[string, Record, string]> = [ + ['x:Account/get', { list: [{ id: 'a' }] }, '0'], + ['x:AppPassword/query', { ids: ['p1'] }, '1'], + ]; + + const result = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query'); + expect(result.ids).toEqual(['p1']); + }); + + it('throws when the expected method is missing', () => { + const responses: Array<[string, Record, string]> = [ + ['x:Account/get', {}, '0'], + ]; + + expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/); + }); +}); diff --git a/lib/__tests__/stalwart-client.test.ts b/lib/__tests__/stalwart-client.test.ts deleted file mode 100644 index 2cf9d62d..00000000 --- a/lib/__tests__/stalwart-client.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -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; - 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'); - }); - }); -}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 6a09f19e..ad25b307 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient { // ── Capabilities ────────────────────────────────────────────── + hasAccountCapability(_capability: string, _accountId?: string): boolean { + return false; + } + getCapabilities(): Record { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 34dc54d7..916b7d8b 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -27,6 +27,7 @@ export interface IJMAPClient { // ── Capabilities ────────────────────────────────────────────── getCapabilities(): Record; + hasAccountCapability(capability: string, accountId?: string): boolean; getMaxSizeUpload(): number; getMaxCallsInRequest(): number; getMaxObjectsInGet(): number; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index dc1f57ca..ac90a057 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2558,6 +2558,13 @@ export class JMAPClient implements IJMAPClient { return capability in this.capabilities; } + /** Check whether a capability is present on the primary account. */ + hasAccountCapability(capability: string, accountId?: string): boolean { + const id = accountId || this.accountId; + const caps = this.session?.accounts?.[id]?.accountCapabilities; + return !!caps && capability in caps; + } + getMaxSizeUpload(): number { const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined; return coreCapability?.maxSizeUpload || 0; diff --git a/lib/stalwart/client.ts b/lib/stalwart/client.ts deleted file mode 100644 index 9762b539..00000000 --- a/lib/stalwart/client.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * 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(path: string, init?: RequestInit): Promise { - 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 { - 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 { - 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 { - 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 { - const result = await this.request<{ data: string }>('/account/auth', { - method: 'POST', - body: JSON.stringify([{ type: 'enableOtpAuth' }]), - }); - return result.data; - } - - /** Disable TOTP */ - async disableTotp(): Promise { - 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 { - 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 { - 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 { - 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 { - await this.request<{ data: unknown }>('/account/crypto', { - method: 'POST', - body: JSON.stringify(settings), - }); - } - - /** GET /principal/{name} - Fetch principal details */ - async getPrincipal(name: string): Promise { - 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 { - 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 { - await this.updatePrincipal(name, [ - { action: 'set', field: 'secrets', value: newPassword }, - ]); - } - - /** Update display name via PATCH /principal/{name} */ - async updateDisplayName(name: string, displayName: string): Promise { - await this.updatePrincipal(name, [ - { action: 'set', field: 'description', value: displayName }, - ]); - } -} diff --git a/lib/stalwart/credentials.ts b/lib/stalwart/credentials.ts index a4ffc0b4..c84cd2c0 100644 --- a/lib/stalwart/credentials.ts +++ b/lib/stalwart/credentials.ts @@ -4,9 +4,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; export interface StalwartCredentials { - /** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */ - apiUrl: string; - /** URL of the JMAP server (for JMAP operations like password verification) */ + /** URL of the JMAP server (used for JMAP + management method calls) */ serverUrl: string; authHeader: string; username: string; @@ -14,26 +12,6 @@ export interface StalwartCredentials { slot: number; } -/** - * Resolve the base URL for Stalwart management API requests. - * - * When the JMAP server sits behind a reverse proxy that only forwards - * JMAP paths, the `/api/account/*` and `/api/principal/*` management - * endpoints may not be exposed. In that case, operators can set - * `STALWART_API_URL` to point directly at the Stalwart HTTP listener - * (e.g. `https://admin.example.com`). - */ -function getStalwartApiUrl(jmapServerUrl: string): string { - const url = process.env.STALWART_API_URL || jmapServerUrl; - return url.replace(/\/+$/, ''); -} - -/** - * Extract credentials from the incoming request. - * - * Credentials are read from a verified, httpOnly auth-context cookie that is - * populated after a successful JMAP login or token refresh. - */ function parseSlot(raw: string | null): number | null { if (raw === null) return null; const slot = parseInt(raw, 10); @@ -55,8 +33,7 @@ export async function getStalwartCredentials(request: NextRequest): Promise, string]; +export type JmapMethodResponse = [string, Record, string]; + +export const STALWART_JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']; + +export interface StalwartJmapError extends Error { + status: number; + methodError?: { type: string; description?: string }; +} + +function buildError(message: string, status: number, methodError?: StalwartJmapError['methodError']): StalwartJmapError { + const err = new Error(message) as StalwartJmapError; + err.status = status; + if (methodError) err.methodError = methodError; + return err; +} + +/** + * Send a JMAP request to Stalwart via the server-side passthrough. + * The passthrough injects the stored basic-auth header so credentials + * stay in an httpOnly cookie. + */ +export async function stalwartJmap(methodCalls: JmapMethodCall[]): Promise { + const response = await apiFetch('/api/account/stalwart/jmap', { + method: 'POST', + headers: { ...getActiveAccountSlotHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ using: STALWART_JMAP_USING, methodCalls }), + }); + + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const body = await response.json(); + if (body?.error) message = body.error; + } catch { /* ignore */ } + throw buildError(message, response.status); + } + + const data = await response.json(); + const responses = (data.methodResponses ?? []) as JmapMethodResponse[]; + + const first = responses[0]; + if (first && first[0] === 'error') { + const result = first[1] as { type?: string; description?: string }; + throw buildError(result.description || result.type || 'JMAP error', 200, { + type: result.type || 'unknown', + description: result.description, + }); + } + + return responses; +} + +export function requireResult>( + responses: JmapMethodResponse[], + expectedMethod: string, +): T { + const match = responses.find(r => r[0] === expectedMethod); + if (!match) { + throw buildError(`Expected method ${expectedMethod} in response`, 200); + } + return match[1] as T; +} diff --git a/package-lock.json b/package-lock.json index 891b0ff2..06bc5841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,11 @@ "lucide-react": "^0.575.0", "next": "^16.1.5", "next-intl": "^4.5.8", + "otpauth": "^9.5.0", "pkijs": "^3.3.3", "postal-mime": "^2.7.4", "pvtsutils": "^1.3.6", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "sonner": "^2.0.7", @@ -46,6 +48,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@types/node": "^25.2.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.49.0", @@ -2061,10 +2064,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -4063,6 +4063,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4535,7 +4545,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4545,7 +4554,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4934,6 +4942,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001772", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz", @@ -4987,6 +5004,17 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5000,7 +5028,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5013,7 +5040,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -5217,6 +5243,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5295,6 +5330,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5361,6 +5402,12 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -6171,6 +6218,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6741,6 +6797,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -8024,6 +8089,18 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8074,6 +8151,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -8110,7 +8196,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8219,6 +8304,15 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", @@ -8565,6 +8659,23 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -8682,6 +8793,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8692,6 +8812,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8846,6 +8972,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9123,6 +9255,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -9221,6 +9367,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -10050,6 +10208,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -10099,6 +10263,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -10116,6 +10294,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10123,6 +10307,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c54bcf00..8c2fb66d 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,11 @@ "lucide-react": "^0.575.0", "next": "^16.1.5", "next-intl": "^4.5.8", + "otpauth": "^9.5.0", "pkijs": "^3.3.3", "postal-mime": "^2.7.4", "pvtsutils": "^1.3.6", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "sonner": "^2.0.7", @@ -69,6 +71,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@types/node": "^25.2.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.49.0", diff --git a/stores/__tests__/account-security-store.test.ts b/stores/__tests__/account-security-store.test.ts index e1718730..c7915c5c 100644 --- a/stores/__tests__/account-security-store.test.ts +++ b/stores/__tests__/account-security-store.test.ts @@ -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: (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; + +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; - +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(); }); }); diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index b811cfe9..14cd096f 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -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; fetchAuthInfo: () => Promise; fetchCryptoInfo: () => Promise; fetchPrincipal: () => Promise; fetchAll: () => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; updateDisplayName: (displayName: string) => Promise; - enableTotp: () => Promise; - disableTotp: () => Promise; - addAppPassword: (name: string, password: string) => Promise; - removeAppPassword: (name: string) => Promise; - updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise; + + enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise; + disableTotp: (currentPassword: string) => Promise; + + createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>; + removeAppPassword: (id: string) => Promise; + clearState: () => void; } -function getApiHeaders(): Record { - return getActiveAccountSlotHeaders(); +function getPrimaryAccountId(): string { + const client = useAuthStore.getState().client; + if (!client) throw new Error('Not authenticated'); + return client.getAccountId(); +} + +function appPasswordFromResult(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 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) => ({ @@ -54,7 +86,7 @@ export const useAccountSecurityStore = create()((set, get) otpEnabled: false, appPasswords: [], isLoadingAuth: false, - encryptionType: 'disabled', + encryptionType: 'Disabled', isLoadingCrypto: false, displayName: '', emails: [], @@ -67,11 +99,8 @@ export const useAccountSecurityStore = create()((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()((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> }>(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()((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()((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; + 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()((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()((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()((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()((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()((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; + notCreated?: Record; + }>(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()((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: [], diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 50c312fc..ec3eec32 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -29,7 +29,6 @@ interface FilterStore { toggleRule: (ruleId: string) => void; setRawScript: (content: string) => void; resetToVisualBuilder: () => void; - syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise; clearState: () => void; } @@ -193,69 +192,6 @@ export const useFilterStore = create()((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, From 1f60671886333bc69f73ff35cb7c56f494bd253f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:30:00 +0200 Subject: [PATCH 02/31] fix: clean up unused imports and improve TOTP QR code rendering --- components/settings/account-security-settings.tsx | 3 +-- stores/account-security-store.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 0f29068c..5f2b445c 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import QRCode from 'qrcode'; import * as OTPAuth from 'otpauth'; @@ -294,7 +294,6 @@ function TotpSection() {

{t('totp.setup_instructions')}

{qrDataUrl && (
- { /* eslint-disable-next-line @next/next/no-img-element */ } TOTP QR code
)} diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 14cd096f..400339d0 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -190,8 +190,7 @@ export const useAccountSecurityStore = create()((set, 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!) + .flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : [])) : []; const primaryEmail = acc?.name ? [acc.name] : []; set({ From 30c4afb9777e4e502ddeaf655e6a69e9bf3a73de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:42:52 +0200 Subject: [PATCH 03/31] i18n: add missing translation keys --- components/email/email-composer.tsx | 2 +- lib/calendar-ics-export.ts | 2 +- lib/sieve/parser.ts | 2 +- locales/de/common.json | 25 +++++++++++++++++++++---- locales/en/common.json | 12 +++++++++++- locales/es/common.json | 25 +++++++++++++++++++++---- locales/fr/common.json | 25 +++++++++++++++++++++---- locales/it/common.json | 25 +++++++++++++++++++++---- locales/ja/common.json | 25 +++++++++++++++++++++---- locales/ko/common.json | 25 +++++++++++++++++++++---- locales/lv/common.json | 25 +++++++++++++++++++++---- locales/nl/common.json | 25 +++++++++++++++++++++---- locales/pl/common.json | 25 +++++++++++++++++++++---- locales/pt/common.json | 25 +++++++++++++++++++++---- locales/ru/common.json | 25 +++++++++++++++++++++---- locales/uk/common.json | 25 +++++++++++++++++++++---- locales/zh/common.json | 25 +++++++++++++++++++++---- 17 files changed, 287 insertions(+), 56 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 6422d774..2bf9e569 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -216,7 +216,7 @@ export function EmailComposer({ const [attachments, setAttachments] = useState(() => { if (mode === 'forward' && replyTo?.attachments?.length) { return replyTo.attachments - // Skip inline cid-referenced images — they're embedded in the forwarded HTML body + // Skip inline cid-referenced images - they're embedded in the forwarded HTML body // (matches the viewer's hideInlineImageAttachments logic). .filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) .map(att => ({ diff --git a/lib/calendar-ics-export.ts b/lib/calendar-ics-export.ts index f5884a3a..1f13e0bb 100644 --- a/lib/calendar-ics-export.ts +++ b/lib/calendar-ics-export.ts @@ -13,7 +13,7 @@ function foldLine(line: string): string { return chunks.join("\r\n"); } -// RFC 5545 §3.3.11 — escape backslash, semicolon, comma, and newline in TEXT values. +// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values. function escapeText(value: string): string { return value .replace(/\\/g, "\\\\") diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index bf4823fd..5cbb071d 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -508,7 +508,7 @@ function escapeRegex(s: string): string { /** * Nextcloud Mail wraps its managed filter region with a pair of * `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically - * emits two such regions — one enclosing its own `require [...]` line and + * emits two such regions - one enclosing its own `require [...]` line and * another enclosing the if-blocks it generates from its `# FILTER: [...]` * JSON comments. Parsing the interior blocks individually loses the outer * markers (causing later rules to fall back to "External") and mis-attaches diff --git a/locales/de/common.json b/locales/de/common.json index e1441bb6..08a05646 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -129,7 +129,8 @@ "folders": "Ordner", "mail": "E-Mail", "nav_label": "Navigation", - "add_app": "Apps" + "add_app": "Apps", + "shared": "Geteilt" }, "sidebar_apps": { "modal_title": "Sidebar-Apps", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Gemeinsames Postfach", "description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen" + }, + "colorful_sidebar_icons": { + "label": "Farbige Seitenleistensymbole", + "description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Zwei-Faktor-Authentifizierung deaktiviert", "enable_error": "2FA konnte nicht aktiviert werden", "disable_error": "2FA konnte nicht deaktiviert werden", - "setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):" + "setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):", + "verification_code": "Verifizierungscode", + "confirm": "Bestätigen", + "disable": "Deaktivieren", + "disable_confirm_prompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.", + "password_required": "Passwort ist erforderlich", + "code_required": "Verifizierungscode ist erforderlich", + "code_invalid": "Ungültiger Verifizierungscode. Überprüfen Sie Ihre Authenticator-App und versuchen Sie es erneut." }, "app_passwords": { "title": "App-Passwörter", @@ -1092,7 +1104,10 @@ "removed": "App-Passwort entfernt", "add_error": "App-Passwort konnte nicht erstellt werden", "remove_error": "App-Passwort konnte nicht entfernt werden", - "none": "Keine App-Passwörter konfiguriert" + "none": "Keine App-Passwörter konfiguriert", + "done": "Fertig", + "expires_label": "Läuft ab (optional)", + "copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt." }, "encryption": { "section_title": "Verschlüsselung im Ruhezustand", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}", "actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}" - } + }, + "origin_external": "Extern", + "managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor." }, "templates": { "title": "E-Mail-Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index 9275990f..f2687826 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1080,7 +1080,14 @@ "disabled": "Two-factor authentication disabled", "enable_error": "Failed to enable 2FA", "disable_error": "Failed to disable 2FA", - "setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):" + "setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):", + "verification_code": "Verification code", + "confirm": "Confirm", + "disable": "Disable", + "disable_confirm_prompt": "Enter your password to disable two-factor authentication.", + "password_required": "Password is required", + "code_required": "Verification code is required", + "code_invalid": "Invalid verification code. Check your authenticator app and try again." }, "app_passwords": { "title": "App Passwords", @@ -1088,11 +1095,14 @@ "add": "Add", "create": "Create", "cancel": "Cancel", + "done": "Done", "generate": "Generate", "name_label": "App Name", "name_placeholder": "e.g. Thunderbird, iPhone Mail", + "expires_label": "Expires (optional)", "password_label": "Password (leave empty to auto-generate)", "password_placeholder": "Auto-generated if empty", + "copy_now_warning": "Copy this password now - it will not be shown again.", "added": "App password created", "removed": "App password removed", "add_error": "Failed to create app password", diff --git a/locales/es/common.json b/locales/es/common.json index 13841659..aa153c88 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -129,7 +129,8 @@ "folders": "Carpetas", "mail": "Correo", "nav_label": "Navegación", - "add_app": "Apps" + "add_app": "Apps", + "shared": "Compartido" }, "sidebar_apps": { "modal_title": "Aplicaciones de la barra lateral", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Buzón unificado", "description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas" + }, + "colorful_sidebar_icons": { + "label": "Iconos de barra lateral a color", + "description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Autenticación de dos factores deshabilitada", "enable_error": "No se pudo habilitar 2FA", "disable_error": "No se pudo deshabilitar 2FA", - "setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):" + "setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):", + "verification_code": "Código de verificación", + "confirm": "Confirmar", + "disable": "Desactivar", + "disable_confirm_prompt": "Introduce tu contraseña para desactivar la autenticación de dos factores.", + "password_required": "Se requiere la contraseña", + "code_required": "Se requiere el código de verificación", + "code_invalid": "Código de verificación no válido. Revisa tu aplicación de autenticación e inténtalo de nuevo." }, "app_passwords": { "title": "Contraseñas de aplicación", @@ -1092,7 +1104,10 @@ "removed": "Contraseña de aplicación eliminada", "add_error": "No se pudo crear la contraseña de aplicación", "remove_error": "No se pudo eliminar la contraseña de aplicación", - "none": "No hay contraseñas de aplicación configuradas" + "none": "No hay contraseñas de aplicación configuradas", + "done": "Hecho", + "expires_label": "Caduca (opcional)", + "copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar." }, "encryption": { "section_title": "Cifrado en reposo", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# condición} other {# condiciones}}", "actions_count": "{count, plural, one {# acción} other {# acciones}}" - } + }, + "origin_external": "Externo", + "managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar." }, "templates": { "title": "Plantillas de correo", diff --git a/locales/fr/common.json b/locales/fr/common.json index d84c989e..28b15d32 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -129,7 +129,8 @@ "folders": "Dossiers", "mail": "Messagerie", "nav_label": "Navigation", - "add_app": "Apps" + "add_app": "Apps", + "shared": "Partagé" }, "sidebar_apps": { "modal_title": "Applications de la barre latérale", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Boîte aux lettres unifiée", "description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés" + }, + "colorful_sidebar_icons": { + "label": "Icônes colorées dans la barre latérale", + "description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Authentification à deux facteurs désactivée", "enable_error": "Impossible d'activer la 2FA", "disable_error": "Impossible de désactiver la 2FA", - "setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :" + "setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :", + "verification_code": "Code de vérification", + "confirm": "Confirmer", + "disable": "Désactiver", + "disable_confirm_prompt": "Saisissez votre mot de passe pour désactiver l'authentification à deux facteurs.", + "password_required": "Le mot de passe est requis", + "code_required": "Le code de vérification est requis", + "code_invalid": "Code de vérification non valide. Vérifiez votre application d'authentification et réessayez." }, "app_passwords": { "title": "Mots de passe d'application", @@ -1092,7 +1104,10 @@ "removed": "Mot de passe d'application supprimé", "add_error": "Impossible de créer le mot de passe d'application", "remove_error": "Impossible de supprimer le mot de passe d'application", - "none": "Aucun mot de passe d'application configuré" + "none": "Aucun mot de passe d'application configuré", + "done": "Terminé", + "expires_label": "Expire (facultatif)", + "copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché." }, "encryption": { "section_title": "Chiffrement au repos", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# condition} other {# conditions}}", "actions_count": "{count, plural, one {# action} other {# actions}}" - } + }, + "origin_external": "Externe", + "managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut." }, "templates": { "title": "Modèles d'e-mails", diff --git a/locales/it/common.json b/locales/it/common.json index 0d9a115d..246304ce 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -129,7 +129,8 @@ "folders": "Cartelle", "mail": "Posta", "nav_label": "Navigazione", - "add_app": "App" + "add_app": "App", + "shared": "Condiviso" }, "sidebar_apps": { "modal_title": "App della barra laterale", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Casella di posta unificata", "description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati" + }, + "colorful_sidebar_icons": { + "label": "Icone colorate nella barra laterale", + "description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Autenticazione a due fattori disabilitata", "enable_error": "Impossibile abilitare la 2FA", "disable_error": "Impossibile disabilitare la 2FA", - "setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):" + "setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):", + "verification_code": "Codice di verifica", + "confirm": "Conferma", + "disable": "Disattiva", + "disable_confirm_prompt": "Inserisci la tua password per disattivare l'autenticazione a due fattori.", + "password_required": "La password è obbligatoria", + "code_required": "Il codice di verifica è obbligatorio", + "code_invalid": "Codice di verifica non valido. Controlla la tua app di autenticazione e riprova." }, "app_passwords": { "title": "Password per le app", @@ -1092,7 +1104,10 @@ "removed": "Password per l'app rimossa", "add_error": "Impossibile creare la password per l'app", "remove_error": "Impossibile rimuovere la password per l'app", - "none": "Nessuna password per le app configurata" + "none": "Nessuna password per le app configurata", + "done": "Fatto", + "expires_label": "Scadenza (facoltativa)", + "copy_now_warning": "Copia questa password ora - non verrà più mostrata." }, "encryption": { "section_title": "Crittografia a riposo", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# condizione} other {# condizioni}}", "actions_count": "{count, plural, one {# azione} other {# azioni}}" - } + }, + "origin_external": "Esterno", + "managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo." }, "templates": { "title": "Modelli email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 83d54871..ca3806a0 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -129,7 +129,8 @@ "folders": "フォルダ", "mail": "メール", "nav_label": "ナビゲーション", - "add_app": "アプリ" + "add_app": "アプリ", + "shared": "共有" }, "sidebar_apps": { "modal_title": "サイドバーアプリ", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "統合メールボックス", "description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示" + }, + "colorful_sidebar_icons": { + "label": "カラフルなサイドバーアイコン", + "description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。" } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "二要素認証が無効になりました", "enable_error": "2FAを有効にできませんでした", "disable_error": "2FAを無効にできませんでした", - "setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:" + "setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:", + "verification_code": "確認コード", + "confirm": "確認", + "disable": "無効化", + "disable_confirm_prompt": "二要素認証を無効にするにはパスワードを入力してください。", + "password_required": "パスワードが必要です", + "code_required": "確認コードが必要です", + "code_invalid": "確認コードが無効です。認証アプリを確認してもう一度お試しください。" }, "app_passwords": { "title": "アプリパスワード", @@ -1092,7 +1104,10 @@ "removed": "アプリパスワードが削除されました", "add_error": "アプリパスワードを作成できませんでした", "remove_error": "アプリパスワードを削除できませんでした", - "none": "アプリパスワードは設定されていません" + "none": "アプリパスワードは設定されていません", + "done": "完了", + "expires_label": "有効期限(任意)", + "copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。" }, "encryption": { "section_title": "保存時の暗号化", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, other {#個の条件}}", "actions_count": "{count, plural, other {#個のアクション}}" - } + }, + "origin_external": "外部", + "managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。" }, "templates": { "title": "メールテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index 38476013..728b893b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -129,7 +129,8 @@ "folders": "폴더", "mail": "메일", "nav_label": "내비게이션", - "add_app": "앱" + "add_app": "앱", + "shared": "공유됨" }, "sidebar_apps": { "modal_title": "사이드바 앱", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "통합 메일함", "description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다" + }, + "colorful_sidebar_icons": { + "label": "컬러풀한 사이드바 아이콘", + "description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "2단계 인증이 꺼졌어요", "enable_error": "2단계 인증을 켜지 못했어요", "disable_error": "2단계 인증을 끄지 못했어요", - "setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:" + "setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:", + "verification_code": "확인 코드", + "confirm": "확인", + "disable": "비활성화", + "disable_confirm_prompt": "2단계 인증을 비활성화하려면 비밀번호를 입력하세요.", + "password_required": "비밀번호가 필요합니다", + "code_required": "확인 코드가 필요합니다", + "code_invalid": "유효하지 않은 확인 코드입니다. 인증 앱을 확인하고 다시 시도하세요." }, "app_passwords": { "title": "앱 비밀번호", @@ -1092,7 +1104,10 @@ "removed": "앱 비밀번호가 삭제되었어요", "add_error": "앱 비밀번호를 만들지 못했어요", "remove_error": "앱 비밀번호를 삭제하지 못했어요", - "none": "설정된 앱 비밀번호가 없어요" + "none": "설정된 앱 비밀번호가 없어요", + "done": "완료", + "expires_label": "만료 (선택 사항)", + "copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다." }, "encryption": { "section_title": "저장 데이터 암호화", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "조건 {count}개", "actions_count": "동작 {count}개" - } + }, + "origin_external": "외부", + "managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요." }, "templates": { "title": "이메일 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index e6d2a4d6..5ea41c24 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -129,7 +129,8 @@ "folders": "Mapes", "mail": "Pasts", "nav_label": "Navigācija", - "add_app": "Lietotnes" + "add_app": "Lietotnes", + "shared": "Koplietots" }, "sidebar_apps": { "modal_title": "Sānu joslas lietotnes", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Apvienotā pastkaste", "description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem" + }, + "colorful_sidebar_icons": { + "label": "Krāsainas sānjoslas ikonas", + "description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "2FA ir izslēgta", "enable_error": "Neizdevās iespējot 2FA", "disable_error": "Neizdevās izslēgt 2FA", - "setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):" + "setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):", + "verification_code": "Verifikācijas kods", + "confirm": "Apstiprināt", + "disable": "Atspējot", + "disable_confirm_prompt": "Ievadiet paroli, lai atspējotu divpakāpju autentifikāciju.", + "password_required": "Nepieciešama parole", + "code_required": "Nepieciešams verifikācijas kods", + "code_invalid": "Nederīgs verifikācijas kods. Pārbaudiet autentifikācijas lietotni un mēģiniet vēlreiz." }, "app_passwords": { "title": "Lietotņu paroles", @@ -1092,7 +1104,10 @@ "removed": "Lietotnes parole izdzēsta", "add_error": "Neizdevās izveidot lietotnes paroli", "remove_error": "Neizdevās izdzēst lietotnes paroli", - "none": "Lietotņu paroles nav iestatītas" + "none": "Lietotņu paroles nav iestatītas", + "done": "Gatavs", + "expires_label": "Derīguma termiņš (pēc izvēles)", + "copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta." }, "encryption": { "section_title": "Krātuves šifrēšana", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# nosacījums} other {# nosacījumi}}", "actions_count": "{count, plural, one {# darbība} other {# darbības}}" - } + }, + "origin_external": "Ārējs", + "managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru." }, "templates": { "title": "Vēstuļu veidnes", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4ba98815..bc66e2d2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -129,7 +129,8 @@ "folders": "Mappen", "mail": "E-mail", "nav_label": "Navigatie", - "add_app": "Apps" + "add_app": "Apps", + "shared": "Gedeeld" }, "sidebar_apps": { "modal_title": "Zijbalk-apps", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Gecombineerd postvak", "description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven" + }, + "colorful_sidebar_icons": { + "label": "Gekleurde zijbalkpictogrammen", + "description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Tweefactorauthenticatie uitgeschakeld", "enable_error": "Kan 2FA niet inschakelen", "disable_error": "Kan 2FA niet uitschakelen", - "setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):" + "setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):", + "verification_code": "Verificatiecode", + "confirm": "Bevestigen", + "disable": "Uitschakelen", + "disable_confirm_prompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.", + "password_required": "Wachtwoord is vereist", + "code_required": "Verificatiecode is vereist", + "code_invalid": "Ongeldige verificatiecode. Controleer uw authenticator-app en probeer het opnieuw." }, "app_passwords": { "title": "App-wachtwoorden", @@ -1092,7 +1104,10 @@ "removed": "App-wachtwoord verwijderd", "add_error": "Kan app-wachtwoord niet aanmaken", "remove_error": "Kan app-wachtwoord niet verwijderen", - "none": "Geen app-wachtwoorden geconfigureerd" + "none": "Geen app-wachtwoorden geconfigureerd", + "done": "Klaar", + "expires_label": "Verloopt (optioneel)", + "copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven." }, "encryption": { "section_title": "Versleuteling in rust", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}", "actions_count": "{count, plural, one {# actie} other {# acties}}" - } + }, + "origin_external": "Extern", + "managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor." }, "templates": { "title": "E-mailsjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 54d806cc..ebf36bc3 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -129,7 +129,8 @@ "folders": "Foldery", "mail": "Poczta", "nav_label": "Nawigacja", - "add_app": "Aplikacje" + "add_app": "Aplikacje", + "shared": "Udostępnione" }, "sidebar_apps": { "modal_title": "Aplikacje paska bocznego", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Wspólna skrzynka", "description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont" + }, + "colorful_sidebar_icons": { + "label": "Kolorowe ikony paska bocznego", + "description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Uwierzytelnianie dwuskładnikowe wyłączone", "enable_error": "Nie udało się włączyć 2FA", "disable_error": "Nie udało się wyłączyć 2FA", - "setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):" + "setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):", + "verification_code": "Kod weryfikacyjny", + "confirm": "Potwierdź", + "disable": "Wyłącz", + "disable_confirm_prompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.", + "password_required": "Hasło jest wymagane", + "code_required": "Kod weryfikacyjny jest wymagany", + "code_invalid": "Nieprawidłowy kod weryfikacyjny. Sprawdź aplikację uwierzytelniającą i spróbuj ponownie." }, "app_passwords": { "title": "Hasła aplikacji", @@ -1092,7 +1104,10 @@ "removed": "Hasło aplikacji zostało usunięte", "add_error": "Nie udało się utworzyć hasła aplikacji", "remove_error": "Nie udało się usunąć hasła aplikacji", - "none": "Brak skonfigurowanych haseł aplikacji" + "none": "Brak skonfigurowanych haseł aplikacji", + "done": "Gotowe", + "expires_label": "Wygasa (opcjonalnie)", + "copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone." }, "encryption": { "section_title": "Szyfrowanie danych w spoczynku", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# warunek} other {# warunków}}", "actions_count": "{count, plural, one {# akcja} other {# akcji}}" - } + }, + "origin_external": "Zewnętrzny", + "managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve." }, "templates": { "title": "Szablony wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index ac49bb89..9ef1464b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -129,7 +129,8 @@ "folders": "Pastas", "mail": "E-mail", "nav_label": "Navegação", - "add_app": "Apps" + "add_app": "Apps", + "shared": "Compartilhado" }, "sidebar_apps": { "modal_title": "Apps da barra lateral", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Caixa de correio unificada", "description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas" + }, + "colorful_sidebar_icons": { + "label": "Ícones coloridos na barra lateral", + "description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Autenticação de dois fatores desabilitada", "enable_error": "Não foi possível habilitar a 2FA", "disable_error": "Não foi possível desabilitar a 2FA", - "setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):" + "setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):", + "verification_code": "Código de verificação", + "confirm": "Confirmar", + "disable": "Desativar", + "disable_confirm_prompt": "Digite sua senha para desativar a autenticação de dois fatores.", + "password_required": "A senha é obrigatória", + "code_required": "O código de verificação é obrigatório", + "code_invalid": "Código de verificação inválido. Verifique seu aplicativo autenticador e tente novamente." }, "app_passwords": { "title": "Senhas de aplicativo", @@ -1092,7 +1104,10 @@ "removed": "Senha de aplicativo removida", "add_error": "Não foi possível criar a senha de aplicativo", "remove_error": "Não foi possível remover a senha de aplicativo", - "none": "Nenhuma senha de aplicativo configurada" + "none": "Nenhuma senha de aplicativo configurada", + "done": "Concluído", + "expires_label": "Expira (opcional)", + "copy_now_warning": "Copie esta senha agora - ela não será exibida novamente." }, "encryption": { "section_title": "Criptografia em repouso", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# condição} other {# condições}}", "actions_count": "{count, plural, one {# ação} other {# ações}}" - } + }, + "origin_external": "Externo", + "managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto." }, "templates": { "title": "Modelos de e-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 47100e6f..06762096 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -129,7 +129,8 @@ "folders": "Папки", "mail": "Почта", "nav_label": "Навигация", - "add_app": "Приложения" + "add_app": "Приложения", + "shared": "Общие" }, "sidebar_apps": { "modal_title": "Приложения боковой панели", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Общий почтовый ящик", "description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов" + }, + "colorful_sidebar_icons": { + "label": "Цветные значки боковой панели", + "description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Двухфакторная аутентификация отключена", "enable_error": "Не удалось включить 2FA", "disable_error": "Не удалось отключить 2FA", - "setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):" + "setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):", + "verification_code": "Код подтверждения", + "confirm": "Подтвердить", + "disable": "Отключить", + "disable_confirm_prompt": "Введите пароль, чтобы отключить двухфакторную аутентификацию.", + "password_required": "Требуется пароль", + "code_required": "Требуется код подтверждения", + "code_invalid": "Неверный код подтверждения. Проверьте приложение-аутентификатор и попробуйте снова." }, "app_passwords": { "title": "Пароли приложений", @@ -1092,7 +1104,10 @@ "removed": "Пароль приложения удалён", "add_error": "Не удалось создать пароль приложения", "remove_error": "Не удалось удалить пароль приложения", - "none": "Пароли приложений не настроены" + "none": "Пароли приложений не настроены", + "done": "Готово", + "expires_label": "Срок действия (необязательно)", + "copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан." }, "encryption": { "section_title": "Шифрование хранилища", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# условие} other {# условий}}", "actions_count": "{count, plural, one {# действие} other {# действий}}" - } + }, + "origin_external": "Внешнее", + "managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve." }, "templates": { "title": "Шаблоны писем", diff --git a/locales/uk/common.json b/locales/uk/common.json index 4a45209b..6940904c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -129,7 +129,8 @@ "folders": "Папки", "mail": "Пошта", "nav_label": "Навігація", - "add_app": "програми" + "add_app": "програми", + "shared": "Спільні" }, "sidebar_apps": { "modal_title": "Програми бічної панелі", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "Спільна поштова скринька", "description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів" + }, + "colorful_sidebar_icons": { + "label": "Кольорові значки бічної панелі", + "description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі." } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "Двофакторну автентифікацію вимкнено", "enable_error": "Не вдалося ввімкнути 2FA", "disable_error": "Не вдалося вимкнути 2FA", - "setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):" + "setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):", + "verification_code": "Код підтвердження", + "confirm": "Підтвердити", + "disable": "Вимкнути", + "disable_confirm_prompt": "Введіть пароль, щоб вимкнути двофакторну автентифікацію.", + "password_required": "Потрібен пароль", + "code_required": "Потрібен код підтвердження", + "code_invalid": "Недійсний код підтвердження. Перевірте додаток автентифікації та спробуйте ще раз." }, "app_passwords": { "title": "Паролі програм", @@ -1092,7 +1104,10 @@ "removed": "Пароль програми видалено", "add_error": "Не вдалося створити пароль програми", "remove_error": "Не вдалося видалити пароль програми", - "none": "Паролі програм не налаштовано" + "none": "Паролі програм не налаштовано", + "done": "Готово", + "expires_label": "Термін дії (необов’язково)", + "copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний." }, "encryption": { "section_title": "Шифрування в спокої", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# умова} few {# умови} many {# умов} other {# умов}}", "actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}" - } + }, + "origin_external": "Зовнішнє", + "managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve." }, "templates": { "title": "Шаблони електронної пошти", diff --git a/locales/zh/common.json b/locales/zh/common.json index 06f2ccfb..51879c5e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -129,7 +129,8 @@ "folders": "文件夹", "mail": "邮件", "nav_label": "导航", - "add_app": "应用" + "add_app": "应用", + "shared": "共享" }, "sidebar_apps": { "modal_title": "侧边栏应用", @@ -733,6 +734,10 @@ "unified_mailbox": { "label": "统一邮箱", "description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)" + }, + "colorful_sidebar_icons": { + "label": "彩色侧边栏图标", + "description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。" } }, "keywords": { @@ -1075,7 +1080,14 @@ "disabled": "禁用双因素身份验证", "enable_error": "无法启用 2FA", "disable_error": "无法禁用 2FA", - "setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:" + "setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:", + "verification_code": "验证码", + "confirm": "确认", + "disable": "禁用", + "disable_confirm_prompt": "输入密码以禁用双重身份验证。", + "password_required": "需要密码", + "code_required": "需要验证码", + "code_invalid": "验证码无效。请检查您的身份验证应用并重试。" }, "app_passwords": { "title": "应用密码", @@ -1092,7 +1104,10 @@ "removed": "应用密码已删除", "add_error": "创建应用密码失败", "remove_error": "无法删除应用密码", - "none": "未配置应用密码" + "none": "未配置应用密码", + "done": "完成", + "expires_label": "过期时间(可选)", + "copy_now_warning": "立即复制此密码--它将不再显示。" }, "encryption": { "section_title": "静态加密", @@ -1415,7 +1430,9 @@ "rule_summary": { "conditions_count": "{count, plural, one {# 个条件} other {# 个条件}}", "actions_count": "{count, plural, one {# 个操作} other {# 个操作}}" - } + }, + "origin_external": "外部", + "managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。" }, "templates": { "title": "邮件模板", From 6b7c849332896f2bc4cd32beb75297e57345a11c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:50:18 +0200 Subject: [PATCH 04/31] refactor: remove Stalwart API URL configuration --- .env.example | 5 ----- README.md | 1 - app/admin/settings/page.tsx | 1 - lib/admin/types.ts | 1 - 4 files changed, 8 deletions(-) diff --git a/.env.example b/.env.example index 99f857fa..1b952ce6 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Set to "false" to disable if using a non-Stalwart JMAP server. # STALWART_FEATURES=true -# If your reverse proxy doesn't forward Stalwart management API paths -# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's -# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set. -# STALWART_API_URL=https://admin.example.com - # ============================================================================= # OAuth / OpenID Connect (optional) # ============================================================================= diff --git a/README.md b/README.md index 0feff814..2ded0e07 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,6 @@ Enables the admin marketplace for browsing and installing plugins and themes. ```env STALWART_FEATURES=true # Password change, sieve filters, etc. -STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/* LOG_FORMAT=text # "text" or "json" LOG_LEVEL=info # "error", "warn", "info", "debug" diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index f6aff034..1939c9c1 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -122,7 +122,6 @@ export default function AdminSettingsPage() {
)} - diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 19114661..873a73fc 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -110,7 +110,6 @@ export const CONFIG_ENV_MAP: Record Date: Tue, 21 Apr 2026 18:59:47 +0200 Subject: [PATCH 05/31] feat: add API Keys management and IP allowlist for App Passwords --- .../settings/account-security-settings.tsx | 141 +++++++++--- locales/de/common.json | 17 +- locales/en/common.json | 15 ++ locales/es/common.json | 17 +- locales/fr/common.json | 17 +- locales/it/common.json | 17 +- locales/ja/common.json | 17 +- locales/ko/common.json | 17 +- locales/lv/common.json | 17 +- locales/nl/common.json | 17 +- locales/pl/common.json | 17 +- locales/pt/common.json | 17 +- locales/ru/common.json | 17 +- locales/uk/common.json | 17 +- locales/zh/common.json | 17 +- .../__tests__/account-security-store.test.ts | 94 +++++++- stores/account-security-store.ts | 209 ++++++++++++------ 17 files changed, 556 insertions(+), 124 deletions(-) diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 5f2b445c..571dccb3 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -4,11 +4,11 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import QRCode from 'qrcode'; import * as OTPAuth from 'otpauth'; -import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react'; +import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; -import { useAccountSecurityStore, type AppPasswordInfo } from '@/stores/account-security-store'; +import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; @@ -345,24 +345,43 @@ function TotpSection() { ); } -function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) { +function parseIpList(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) { return ( -
-
- {password.description || password.id} - {password.createdAt && ( +
+
+ {entry.description || entry.id} + {entry.createdAt && ( - {new Date(password.createdAt).toLocaleDateString()} - {password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''} + {new Date(entry.createdAt).toLocaleDateString()} + {entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''} )} + {entry.allowedIps.length > 0 && ( +
+ {entry.allowedIps.map((ip) => ( + + {ip} + + ))} +
+ )}
@@ -370,12 +389,22 @@ function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswor ); } -function AppPasswordsSection() { +interface CredentialSectionProps { + icon: typeof Smartphone; + i18nNamespace: 'app_passwords' | 'api_keys'; + entries: Array; + onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>; + onRemove: (id: string) => Promise; +} + +function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) { const t = useTranslations('settings.security'); - const { appPasswords, createAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore(); + const tk = (key: string) => t(`${i18nNamespace}.${key}`); + const { isSaving, isLoadingAuth } = useAccountSecurityStore(); const [showAdd, setShowAdd] = useState(false); const [newDescription, setNewDescription] = useState(''); const [expiresAt, setExpiresAt] = useState(''); + const [allowedIpsRaw, setAllowedIpsRaw] = useState(''); const [createdSecret, setCreatedSecret] = useState(null); const [copied, setCopied] = useState(false); @@ -384,26 +413,28 @@ function AppPasswordsSection() { if (!newDescription.trim()) return; try { - const result = await createAppPassword( - newDescription.trim(), - expiresAt ? new Date(expiresAt).toISOString() : null, - ); + const result = await onCreate({ + description: newDescription.trim(), + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + allowedIps: parseIpList(allowedIpsRaw), + }); setCreatedSecret(result.secret); setNewDescription(''); setExpiresAt(''); + setAllowedIpsRaw(''); setShowAdd(false); - toast.success(t('app_passwords.added')); + toast.success(tk('added')); } catch (err) { - toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined); + toast.error(tk('add_error'), err instanceof Error ? err.message : undefined); } }; const handleRemove = async (id: string) => { try { - await removeAppPassword(id); - toast.success(t('app_passwords.removed')); + await onRemove(id); + toast.success(tk('removed')); } catch (err) { - toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined); + toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined); } }; @@ -419,8 +450,8 @@ function AppPasswordsSection() { return (
- -

{t('app_passwords.title')}

+ +

{tk('title')}

@@ -431,21 +462,21 @@ function AppPasswordsSection() {
- -

{t('app_passwords.title')}

+ +

{tk('title')}

-

{t('app_passwords.description')}

+

{tk('description')}

{createdSecret && (
-

{t('app_passwords.copy_now_warning')}

+

{tk('copy_now_warning')}

- + {createdSecret}
+
+ +