Merge pull request #83 from LINUXexpert-org/remove-2fa-setup

Stop offering to turn two-factor authentication on
This commit is contained in:
LINUXexpert.org
2026-08-26 16:23:27 -07:00
committed by GitHub
2 changed files with 21 additions and 84 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ seconds of downtime with nothing lost.
**Settings**
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8``de-DE`) and script modifiers preserved (`sr_RS@latin``sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
- **Self-service credentials** in Settings Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Built on the `x:AccountPassword` / `x:AppPassword` registry objects
- **Self-service credentials** in Settings Security: change your password and manage **app passwords** (a separate password per mail app or device, revocable on its own). Built on the `x:AccountPassword` / `x:AppPassword` registry objects. Turning **two-factor authentication** *on* is not offered here — ihasmail cannot sign in with a code yet, so enrolling would only lock the account out on its next sign-in. An account that already has 2FA on gets one control, to turn it off
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings Appearance
- **Hide identities from the compose picker** — an account with alias domains can have every address twice over while only a handful are ever sent from, which makes the From picker unusable. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable in Settings, the way an unsubscribed folder is still a folder. The identity a draft is already using and the default can never be hidden, and hiding every one of them offers them all again — a sender picker with nothing in it is worse than a cluttered one
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
@@ -307,7 +307,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see Quick start). Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see Quick start), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
## License
+18 -81
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(); }} />
)}
{!unsupported && state?.otpEnabled && (
<>
<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(); }} />
<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>