feat: migrate Stalwart management API to JMAP x: methods (0.16)

Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
This commit is contained in:
Linus Rath
2026-04-21 17:29:23 +02:00
parent 9ad2facad3
commit 794001fdbd
25 changed files with 1189 additions and 1592 deletions
+214 -134
View File
@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
const { client } = useAuthStore();
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(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() {
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
onChange={handleToggle}
disabled={isSaving}
/>
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
{ /* eslint-disable-next-line @next/next/no-img-element */ }
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
</div>
)}
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
</div>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
</div>
</div>
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('password.current')}
autoComplete="current-password"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
{t('app_passwords.cancel')}
</Button>
</div>
</div>
@@ -250,36 +346,52 @@ function TotpSection() {
);
}
function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) {
return (
<div className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<div className="flex flex-col">
<span className="text-sm text-foreground">{password.description || password.id}</span>
{password.createdAt && (
<span className="text-xs text-muted-foreground">
{new Date(password.createdAt).toLocaleDateString()}
{password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(password.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
}
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<string | null>(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 (
<div className="space-y-2">
@@ -322,43 +442,40 @@ function AppPasswordsSection() {
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
{createdSecret && (
<div className="p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('app_passwords.copy_now_warning')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono">
{createdSecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
{t('app_passwords.done')}
</Button>
</div>
)}
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
@@ -371,19 +488,8 @@ function AppPasswordsSection() {
{appPasswords.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
{appPasswords.map((p) => (
<AppPasswordRow key={p.id} password={p} onRemove={handleRemove} isSaving={isSaving} />
))}
</div>
) : (
@@ -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 (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
</span>
</SettingItem>
);
}
@@ -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(() => {
-14
View File
@@ -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);