Files
ihasmail/web/src/views/settings/SecuritySettings.tsx
T
jcoffey-dev c6db19de19 Stop offering to turn two-factor authentication on
Settings > Security & sessions still had the full enrolment flow --
QR code, secret, "Set up" -- for something that cannot be signed in
with. Turning 2FA on there took a working account and made webmail
unreachable from any device not already signed in, because ihasmail
has nowhere to send a TOTP code: Stalwart accepts one through an
OAuth flow alone and offers no password grant (#75). The one mercy
was that enabling reseals the current session onto a fresh app
password, so the browser doing it stayed in -- and the next sign-in
elsewhere did not.

So the enrolment path is gone until sign-in with a code works.

Turning 2FA *off* stays. It is a plain registry write, it was
verified live on 0.16.19, and anyone already enrolled -- here or in
Stalwart's own settings -- needs a way back. That control is now the
whole section, and it appears only for an account that has 2FA on;
everyone else no longer sees the heading at all.

The password form keeps its authenticator-code field on the same
condition, since Stalwart demands a code on every credential write
once 2FA is on.

Nothing changes on the server: /api/account/2fa/begin and /enable
are untouched and still tested, ready for the OAuth work that makes
them usable. The sign-in page's code field is also untouched -- it
already explains itself and points at app passwords.

README no longer advertises enrolment by QR code, and the roadmap
entry says which direction the setting still moves.
2026-08-26 16:21:27 -07:00

335 lines
14 KiB
TypeScript

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";
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<SessionRow[] | null>(null);
const [current, setCurrent] = useState<string>("");
const [state, setState] = useState<SecurityState | null>(null);
/** Set when the server has no self-service API at all (a proxy, say). */
const [unsupported, setUnsupported] = useState<string | null>(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<SecurityState>("/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 (
<div>
<h1>Security & sessions</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<h2>Password</h2>
{unsupported ? (
<p className="hint">{unsupported}</p>
) : (
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
)}
{!unsupported && state?.otpEnabled && (
<>
<h2>Two-factor authentication</h2>
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
</>
)}
<h2>App passwords</h2>
{unsupported ? (
<p className="hint">App passwords are managed by your mail administrator.</p>
) : (
<AppPasswords state={state} reload={loadSecurity} />
)}
<h2>Active webmail sessions</h2>
{rows === null ? <p className="hint">Loading…</p> : (
<table className="sessions-table">
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
<td className="mono small">{r.ip}</td>
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
<td>{formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}</td>
<td />
</tr>
))}
</tbody>
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
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 (
<form onSubmit={submit}>
<p className="hint" style={{ marginBottom: 12 }}>Changing your password signs out your other webmail sessions. Any app passwords keep working.</p>
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-current">Current password</label>
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
{otpEnabled && (
<div className="field" style={{ maxWidth: 380 }}>
<label htmlFor="pw-code">Code from your authenticator</label>
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
</div>
)}
<div className="field-row" style={{ maxWidth: 780 }}>
<div className="field">
<label htmlFor="pw-new">New password</label>
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="pw-confirm">Confirm new password</label>
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</div>
</div>
<button className="btn btn-primary" disabled={busy || !current || !next}>{busy ? "Changing" : "Change password"}</button>
</form>
);
}
/* ------------------------------------------------------------------ */
/**
* 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<void> }) {
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("Two-factor authentication is off");
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
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.
</p>
<div className="row" style={{ alignItems: "center", gap: 10 }}>
<ShieldCheck size={18} />
<b>Enabled</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
</div>
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
footer={<>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working" : "Turn off"}</button>
</>}>
<p>Your password alone will be enough to sign in again.</p>
<div className="field">
<label htmlFor="tfa-off-pw">Your password</label>
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<div className="field">
<label htmlFor="tfa-off-code">Current code</label>
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
</Dialog>
</div>
);
}
/* ------------------------------------------------------------------ */
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
if (!state) return <p className="hint">Loading…</p>;
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 (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
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.
</p>
{state.appPasswords.length > 0 && (
<table className="sessions-table">
<thead><tr><th>Name</th><th>Created</th><th /></tr></thead>
<tbody>
{state.appPasswords.map((row) => (
<tr key={row.id}>
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
<td>{row.createdAt ? formatFullDate(row.createdAt) : ""}</td>
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
</tr>
))}
</tbody>
</table>
)}
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
<label htmlFor="ap-name">New app password for</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating" : "Create"}</button>
</form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
{issued && (
<div>
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> Use your usual address as the username.</p>
</div>
)}
</Dialog>
</div>
);
}
function CopyableSecret({ value }: { value: string }) {
return (
<div className="row" style={{ gap: 6, alignItems: "center" }}>
<code className="mono" style={{ userSelect: "all", wordBreak: "break-all", flex: 1, padding: "6px 8px", background: "var(--bg-hover)", borderRadius: 6 }}>{value}</code>
<button
type="button"
className="btn btn-sm btn-ghost"
title="Copy"
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
>
<Copy size={14} />
</button>
</div>
);
}
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}` : ""}`;
}