'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { useParams } from 'next/navigation'; 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, Terminal, QrCode } 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, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; import { sanitizeI18nHtml } from '@/lib/email-sanitization'; function PasswordChangeSection() { const t = useTranslations('settings.security'); const { changePassword, isSaving } = useAccountSecurityStore(); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [showCurrent, setShowCurrent] = useState(false); const [showNew, setShowNew] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); if (newPassword.length < 8) { setError(t('password.error_min_length')); return; } if (newPassword !== confirmPassword) { setError(t('password.error_mismatch')); return; } try { await changePassword(currentPassword, newPassword); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); toast.success(t('password.success')); } catch (err) { const msg = err instanceof Error ? err.message : t('password.error_generic'); setError(msg); toast.error(t('password.error_title'), msg); } }; return (

{t('password.title')}

setCurrentPassword(e.target.value)} required autoComplete="current-password" className="pr-10" />
setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" className="pr-10" />
setConfirmPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
{error && (

{error}

)}
); } function DisplayNameSection() { const t = useTranslations('settings.security'); const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore(); const [name, setName] = useState(displayName); const [saved, setSaved] = useState(false); useEffect(() => { setName(displayName); }, [displayName]); const handleSave = async () => { try { await updateDisplayName(name); setSaved(true); setTimeout(() => setSaved(false), 2000); toast.success(t('display_name.success')); } catch (err) { toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined); } }; if (isLoadingPrincipal) { return ( ); } return (
setName(e.target.value)} placeholder={displayName || t('display_name.placeholder')} className="w-48" />
); } 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 { 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; } try { await enableTotp(password, setupUrl, otpCode.trim()); cancelSetup(); toast.success(t('totp.enabled')); } catch (err) { setSetupError(err instanceof Error ? err.message : t('totp.enable_error')); } }; 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(''); } }; if (isLoadingAuth) { return ( ); } return (
{otpEnabled ? t('totp.active') : t('totp.inactive')}
{setupUrl && (

{t('totp.setup_instructions')}

{qrDataUrl && (
TOTP QR code
)}
{setupUrl}
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}

}
)}
); } 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 (
{entry.description || entry.id} {entry.createdAt && ( {new Date(entry.createdAt).toLocaleDateString()} {entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''} )} {entry.allowedIps.length > 0 && (
{entry.allowedIps.map((ip) => ( {ip} ))}
)}
); } 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 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); const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); if (!newDescription.trim()) return; try { 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(tk('added')); } catch (err) { toast.error(tk('add_error'), err instanceof Error ? err.message : undefined); } }; const handleRemove = async (id: string) => { try { await onRemove(id); toast.success(tk('removed')); } catch (err) { toast.error(tk('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 (

{tk('title')}

); } return (

{tk('title')}

{tk('description')}

{createdSecret && (

{tk('copy_now_warning')}

{createdSecret}
)} {showAdd && (
setNewDescription(e.target.value)} placeholder={tk('name_placeholder')} required />
setExpiresAt(e.target.value)} />