Files
inbuxa-webmail/web/src/views/settings/SecuritySettings.tsx
T
jcoffey-dev 87383440bb German, generated by AI and marked Beta until somebody signs it off
The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
2026-08-31 11:06:54 -07:00

337 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";
import { t, tNode } from "@/lib/i18n";
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>{t("Security & sessions")}</h1>
<p className="lead">{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}</p>
<h2>{t("Password")}</h2>
{unsupported ? (
<p className="hint">{unsupported}</p>
) : (
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
)}
{!unsupported && state?.otpEnabled && (
<>
<h2>{t("Two-factor authentication")}</h2>
<TwoFactorOff reload={async () => { await loadSecurity(); await load(); }} />
</>
)}
<h2>{t("App passwords")}</h2>
{unsupported ? (
<p className="hint">{t("App passwords are managed by your mail administrator.")}</p>
) : (
<AppPasswords state={state} reload={loadSecurity} />
)}
<h2>{t("Active webmail sessions")}</h2>
{rows === null ? <p className="hint">{t("Loading…")}</p> : (
<table className="sessions-table">
<thead><tr><th>{t("Device")}</th><th>{t("IP")}</th><th>{t("Last active")}</th><th>{t("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 }}>{t("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(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn btn-ghost" onClick={() => void logout()}>{t("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 }}>{t("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">{t("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">{t("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">{t("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">{t("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 }}>
{t("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>{t("Enabled")}</b>
<button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>{t("Turn off")}</button>
</div>
<Dialog open={disabling} onClose={() => setDisabling(false)} title={t("Turn off two-factor authentication")} size="sm"
footer={<>
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>{t("Cancel")}</button>
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working…" : "Turn off"}</button>
</>}>
<p>{t("Your password alone will be enough to sign in again.")}</p>
<div className="field">
<label htmlFor="tfa-off-pw">{t("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">{t("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">{t("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 }}>
{t("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>{t("Name")}</th><th>{t("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)}>{t("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">{t("New app password for")}</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("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={t("Your new app password")} size="sm"
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>{tNode("Copy it into {name} now — it isn't shown again.", { name: <b>{issued.description}</b> })}</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("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={t("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}` : ""}`;
}