Merge pull request #83 from LINUXexpert-org/remove-2fa-setup
Stop offering to turn two-factor authentication on
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(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!unsupported && state?.otpEnabled && (
|
||||||
|
<>
|
||||||
<h2>Two-factor authentication</h2>
|
<h2>Two-factor authentication</h2>
|
||||||
{unsupported ? (
|
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
|
||||||
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
|
</>
|
||||||
) : (
|
|
||||||
<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