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.
This commit is contained in:
2026-08-26 16:21:27 -07:00
parent 96ec790d58
commit c6db19de19
2 changed files with 21 additions and 84 deletions
+19 -82
View File
@@ -5,7 +5,6 @@ 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;
@@ -68,11 +67,11 @@ export function SecuritySettings() {
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
)}
<h2>Two-factor authentication</h2>
{unsupported ? (
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
) : (
<TwoFactor state={state} reload={async () => { await loadSecurity(); await load(); }} />
{!unsupported && state?.otpEnabled && (
<>
<h2>Two-factor authentication</h2>
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
</>
)}
<h2>App passwords</h2>
@@ -168,46 +167,20 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
/* ------------------------------------------------------------------ */
function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null);
/**
* 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);
if (!state) return <p className="hint">Loading…</p>;
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 {
@@ -225,51 +198,15 @@ function TwoFactor({ state, reload }: { state: SecurityState | null; reload: ()
return (
<div>
<p className="hint" style={{ marginBottom: 12 }}>
{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."}
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} className={state.otpEnabled ? "" : "muted"} />
<b>{state.otpEnabled ? "Enabled" : "Not enabled"}</b>
{state.otpEnabled
? <button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
: <button className="btn btn-sm btn-primary" onClick={() => void begin()}>Set up</button>}
<ShieldCheck size={18} />
<b>Enabled</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
</div>
<Dialog open={Boolean(setup)} onClose={() => setSetup(null)} title="Set up two-factor authentication" size="md"
footer={<>
<button className="btn btn-ghost" onClick={() => setSetup(null)}>Cancel</button>
<button className="btn btn-primary" disabled={busy || code.length < 6 || !password} onClick={() => void enable()}>{busy ? "Verifying" : "Turn on"}</button>
</>}>
{setup && (
<div>
<ol style={{ paddingLeft: 18, marginTop: 0 }}>
<li>Scan this with your authenticator app.</li>
<li>Enter the six-digit code it shows, and your password.</li>
</ol>
<div className="row" style={{ gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
<QrCode value={setup.url} size={188} title="Two-factor setup code" />
<div style={{ minWidth: 220, flex: 1 }}>
<div className="field">
<label>Can't scan? Enter this key by hand</label>
<CopyableSecret value={setup.secret} />
</div>
<div className="field">
<label htmlFor="tfa-code">Code from the app</label>
<input id="tfa-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
</div>
<div className="field">
<label htmlFor="tfa-pw">Your password</label>
<input id="tfa-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
</div>
</div>
<p className="hint">Codes are checked before anything is saved, so a mistyped key can't lock you out.</p>
</div>
)}
</Dialog>
<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>