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:
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user