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 { plural, t, tNode } from "@/lib/i18n"; 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 { otpEnabled: boolean; appPasswords: AppPasswordRow[]; } 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 (a proxy, say). */ 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 (

{t("Security & sessions")}

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

{t("Password")}

{unsupported ? (

{unsupported}

) : ( { void load(); }} /> )} {!unsupported && state?.otpEnabled && ( <>

{t("Two-factor authentication")}

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

{t("App passwords")}

{unsupported ? (

{t("App passwords are managed by your mail administrator.")}

) : ( )}

{t("Active webmail sessions")}

{rows === null ?

{t("Loading…")}

: ( {rows.map((r) => ( ))}
{t("Device")}{t("IP")}{t("Last active")}{t("Expires")}
{shortUa(r.userAgent)}
{r.id === current && {t("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(t("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 (

{t("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 />
); } /* ------------------------------------------------------------------ */ /** * Only the way *out*. Setting two-factor authentication up is gone until * signing in with a code works: Stalwart takes a TOTP code through an OAuth * flow alone and offers no password grant, so ihasmail has nowhere to send one * (#75). Turning it on here would lock the account out of webmail on its next * sign-in. Turning it off is a plain registry write, works today, and has to * stay — whoever is already enrolled needs a way back. */ function TwoFactorOff({ reload }: { reload: () => Promise }) { const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [disabling, setDisabling] = useState(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(t("Two-factor authentication is off")); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; return (

{t("This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.")}

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

{t("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 [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null); if (!state) return

{t("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, current }), }); setIssued({ description: name, secret: res.secret }); setName(""); setCurrent(""); await reload(); } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; const revoke = async (row: AppPasswordRow) => { const ok = await confirmDialog({ title: t("Revoke “{name}”?", { name: row.description }), message: t("Anything signed in with this password stops working immediately."), confirmLabel: t("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(t("App password revoked")); } catch (err) { toast.error((err as Error).message); } }; return (

{t("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.appPasswords.map((row) => ( ))}
{t("Name")}{t("Created")}
{row.description} {row.createdAt ? formatFullDate(row.createdAt) : "—"}
)}
setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
{/* A credential that outlives this session: the server asks for the password first. */}
setCurrent(e.target.value)} required />
setIssued(null)} title={t("Your new app password")} size="sm" footer={}> {issued && (

{tNode("Copy it into {name} now — it isn't shown again.", { name: {issued.description} })}

{t("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}` : ""}`; }