import { useCallback, useEffect, useState } from "react"; import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react"; import { apiFetch, ApiError } from "@/jmap/client"; import { useSession } from "@/store/session"; import { formatFullDate } from "@/lib/format"; import { toast } from "@/ui/toast"; import { confirmDialog, Dialog } from "@/ui/dialog"; import { QrCode } from "@/ui/qrcode"; interface SessionRow { id: string; username: string; createdAt: number; lastSeenAt: number; expiresAt: number; remember: boolean; userAgent: string; ip: string; } interface AppPasswordRow { id: string; description: string; createdAt: string | null; expiresAt: string | null; } interface SecurityState { backend: "registry" | "legacy"; otpEnabled: boolean; appPasswords: AppPasswordRow[]; appPasswordsKeyedByName: boolean; } export function SecuritySettings() { const [rows, setRows] = useState(null); const [current, setCurrent] = useState(""); const [state, setState] = useState(null); /** Set when the server has no self-service API at all (pre-0.15 or a proxy). */ const [unsupported, setUnsupported] = useState(null); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([])); const loadSecurity = useCallback(async () => { try { setState(await apiFetch("/api/account/security")); setUnsupported(null); } catch (err) { setState(null); setUnsupported(err instanceof ApiError && err.status === 501 ? err.message : (err as Error).message); } }, []); useEffect(() => { void load(); void loadSecurity(); }, [loadSecurity]); return (

Security & sessions

You're signed in as {session?.username}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.

Password

{unsupported ? (

{unsupported}

) : ( { void load(); }} /> )}

Two-factor authentication

{unsupported ? (

Two-factor authentication is managed by your mail administrator.

) : ( { await loadSecurity(); await load(); }} /> )}

App passwords

{unsupported ? (

App passwords are managed by your mail administrator.

) : ( )}

Active webmail sessions

{rows === null ?

Loading…

: ( {rows.map((r) => ( ))}
DeviceIPLast activeExpires
{shortUa(r.userAgent)}
{r.id === current && this device}
{r.ip} {formatFullDate(new Date(r.lastSeenAt).toISOString())} {formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}
)}
); } /* ------------------------------------------------------------------ */ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) { const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [confirm, setConfirm] = useState(""); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const submit = async (e: React.FormEvent) => { e.preventDefault(); if (next !== confirm) { toast.error("The new passwords don't match"); return; } setBusy(true); try { const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", { method: "POST", body: JSON.stringify({ current, next, otpCode: code || undefined }), }); setCurrent(""); setNext(""); setConfirm(""); setCode(""); toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed"); onChanged(); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; return (

Changing your password signs out your other webmail sessions. Any app passwords keep working.

setCurrent(e.target.value)} required />
{otpEnabled && (
setCode(e.target.value)} placeholder="123456" required />
)}
setNext(e.target.value)} required />
setConfirm(e.target.value)} required />
); } /* ------------------------------------------------------------------ */ function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null); const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [disabling, setDisabling] = useState(false); if (!state) return

Loading…

; const begin = async () => { try { setSetup(await apiFetch<{ secret: string; url: string }>("/api/account/2fa/begin", { method: "POST", body: "{}" })); setCode(""); setPassword(""); } catch (err) { toast.error((err as Error).message); } }; const enable = async () => { if (!setup) return; setBusy(true); try { const res = await apiFetch<{ sessionKept: boolean }>("/api/account/2fa/enable", { method: "POST", body: JSON.stringify({ url: setup.url, code, current: password }), }); setSetup(null); await reload(); if (res.sessionKept) { toast.success("Two-factor authentication is on. This browser stays signed in."); } else { toast.success("Two-factor authentication is on. You'll need to sign in again with a code."); } } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; const disable = async () => { setBusy(true); try { await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) }); setDisabling(false); await reload(); toast.success("Two-factor authentication is off"); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; return (

{state.otpEnabled ? "Signing in requires a code from your authenticator app as well as your password." : "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."}

{state.otpEnabled ? "Enabled" : "Not enabled"} {state.otpEnabled ? : }
setSetup(null)} title="Set up two-factor authentication" size="md" footer={<> }> {setup && (
  1. Scan this with your authenticator app.
  2. Enter the six-digit code it shows, and your password.
setCode(e.target.value)} placeholder="123456" />
setPassword(e.target.value)} />

Codes are checked before anything is saved, so a mistyped key can't lock you out.

)}
setDisabling(false)} title="Turn off two-factor authentication" size="sm" footer={<> }>

Your password alone will be enough to sign in again.

setPassword(e.target.value)} />
setCode(e.target.value)} placeholder="123456" />
); } /* ------------------------------------------------------------------ */ function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { const [name, setName] = useState(""); const [busy, setBusy] = useState(false); const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null); if (!state) return

Loading…

; const create = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); try { const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: name }), }); setIssued({ description: name, secret: res.secret }); setName(""); await reload(); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; const revoke = async (row: AppPasswordRow) => { const ok = await confirmDialog({ title: `Revoke "${row.description}"?`, message: "Anything signed in with this password stops working immediately.", confirmLabel: "Revoke", danger: true, }); if (!ok) return; try { await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) }); await reload(); toast.success("App password revoked"); } catch (err) { toast.error((err as Error).message); } }; return (

A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.

{state.appPasswords.length > 0 && ( {!state.appPasswordsKeyedByName && } {state.appPasswords.map((row) => ( {!state.appPasswordsKeyedByName && } ))}
NameCreated
{row.description}{row.createdAt ? formatFullDate(row.createdAt) : "—"}
)}
setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
{state.appPasswordsKeyedByName &&

This mail server identifies app passwords by name, so give each one a different name.

} setIssued(null)} title="Your new app password" size="sm" footer={}> {issued && (

Copy it into {issued.description} now — it isn't shown again.

Use your usual address as the username.

)}
); } function CopyableSecret({ value }: { value: string }) { return (
{value}
); } function shortUa(ua: string): string { const browser = /Firefox\/(\d+)/.exec(ua) ? `Firefox ${/Firefox\/(\d+)/.exec(ua)![1]}` : /Edg\/(\d+)/.exec(ua) ? `Edge ${/Edg\/(\d+)/.exec(ua)![1]}` : /Chrome\/(\d+)/.exec(ua) ? `Chrome ${/Chrome\/(\d+)/.exec(ua)![1]}` : /Safari\/(\d+)/.exec(ua) ? "Safari" : "Browser"; const os = /Windows/.test(ua) ? "Windows" : /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS" : /Mac OS/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : ""; return `${browser}${os ? ` on ${os}` : ""}`; }