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:
@@ -94,7 +94,7 @@ seconds of downtime with nothing lost.
|
|||||||
|
|
||||||
**Settings**
|
**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
|
- **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
|
- **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
|
- **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
|
- 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)
|
- 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)
|
- 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useSession } from "@/store/session";
|
|||||||
import { formatFullDate } from "@/lib/format";
|
import { formatFullDate } from "@/lib/format";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||||
import { QrCode } from "@/ui/qrcode";
|
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,11 +67,11 @@ export function SecuritySettings() {
|
|||||||
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
|
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2>Two-factor authentication</h2>
|
{!unsupported && state?.otpEnabled && (
|
||||||
{unsupported ? (
|
<>
|
||||||
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
|
<h2>Two-factor authentication</h2>
|
||||||
) : (
|
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
|
||||||
<TwoFactor state={state} reload={async () => { await loadSecurity(); await load(); }} />
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2>App passwords</h2>
|
<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 [code, setCode] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [disabling, setDisabling] = 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 () => {
|
const disable = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -225,51 +198,15 @@ function TwoFactor({ state, reload }: { state: SecurityState | null; reload: ()
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="hint" style={{ marginBottom: 12 }}>
|
<p className="hint" style={{ marginBottom: 12 }}>
|
||||||
{state.otpEnabled
|
This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another
|
||||||
? "Signing in requires a code from your authenticator app as well as your password."
|
device needs an app password — or you can turn two-factor authentication off here.
|
||||||
: "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
||||||
<ShieldCheck size={18} className={state.otpEnabled ? "" : "muted"} />
|
<ShieldCheck size={18} />
|
||||||
<b>{state.otpEnabled ? "Enabled" : "Not enabled"}</b>
|
<b>Enabled</b>
|
||||||
{state.otpEnabled
|
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
|
||||||
? <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>}
|
|
||||||
</div>
|
</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"
|
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
|
||||||
footer={<>
|
footer={<>
|
||||||
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
|
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user