Im Downloads-Ordner lagen "certain-finance-export_....json" und "certain-finance-backup_....json" nebeneinander, ohne dass man sah, welche Datei sich zurueckladen laesst. Alle Downloads heissen jetzt certain-finance_<art>_<datum>_<uhrzeit>.<endung>, wobei die Art es sagt: vollbackup, vollbackup-system, datenuebersicht, buchungen, datenbank. Das persoenliche Vollbackup traegt zusaetzlich den Benutzernamen. Den Namen vergibt jetzt allein der Server im Content-Disposition-Kopf; das Frontend liest ihn von dort. Vorher erfanden beide Seiten ihn getrennt und kamen auf unterschiedliche Ergebnisse (Bindestrich gegen Unterstrich, UTC gegen Ortszeit). Zeitstempel ist Ortszeit: wer abends um 20 Uhr exportiert, soll keine Datei von 18 Uhr finden. Passend dazu heisst der Knopf "Alle Daten (JSON)" nun "Datenuebersicht (JSON)", und der Hinweis darueber sagt ausdruecklich, dass sich diese Dateien nicht zurueckladen lassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
8.1 KiB
React
246 lines
8.1 KiB
React
import { useEffect, useState } from "react";
|
||
import { Navigate, useNavigate } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useAuth } from "../auth";
|
||
import { api } from "../api";
|
||
import { useToast } from "../toast";
|
||
import { useConfirm } from "../confirm";
|
||
import Icon from "../components/Icon";
|
||
import PasswordInput from "../components/PasswordInput";
|
||
|
||
/** Codeliste als Textdatei sichern – Zwischenablage ist nicht überall erlaubt. */
|
||
function codesSichern(codes, benutzer) {
|
||
const inhalt =
|
||
`Certain Finance – Wiederherstellungscodes für ${benutzer}\n` +
|
||
`Jeder Code funktioniert genau einmal. Sicher aufbewahren.\n\n` +
|
||
codes.join("\n") + "\n";
|
||
const url = URL.createObjectURL(new Blob([inhalt], { type: "text/plain;charset=utf-8" }));
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `certain-finance_wiederherstellungscodes_${benutzer}.txt`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/** Anzeige der frisch erzeugten Codes – sie sind danach nicht mehr abrufbar. */
|
||
function CodeListe({ codes, benutzer, onFertig }) {
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
|
||
async function kopieren() {
|
||
try {
|
||
await navigator.clipboard.writeText(codes.join("\n"));
|
||
toast(t("twofactor.copied"));
|
||
} catch {
|
||
// Ohne sicheren Kontext (http) gibt es keine Zwischenablage – dann
|
||
// bleibt der Download, und markieren geht ohnehin.
|
||
codesSichern(codes, benutzer);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)", borderColor: "var(--warn)" }}>
|
||
<h2 style={{ marginTop: 0 }}>{t("twofactor.codesTitle")}</h2>
|
||
<p className="muted small">{t("twofactor.codesHint")}</p>
|
||
<div className="tfa-codes selectable">
|
||
{codes.map((c) => <span key={c}>{c}</span>)}
|
||
</div>
|
||
<div className="btn-pair">
|
||
<button className="btn" onClick={kopieren}>
|
||
<Icon name="copy" size={15} /> {t("twofactor.copy")}
|
||
</button>
|
||
<button className="btn" onClick={() => codesSichern(codes, benutzer)}>
|
||
<Icon name="download" size={15} /> {t("twofactor.download")}
|
||
</button>
|
||
<button className="btn primary" onClick={onFertig}>{t("twofactor.codesSaved")}</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function TwoFactor() {
|
||
const { user, aktualisieren } = useAuth();
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const navigate = useNavigate();
|
||
|
||
const [status, setStatus] = useState(null);
|
||
const [policy, setPolicy] = useState(null);
|
||
const [setup, setSetup] = useState(null); // { secret, otpauth_uri, qr_png }
|
||
const [code, setCode] = useState("");
|
||
const [codes, setCodes] = useState(null); // frisch erzeugte Wiederherstellungscodes
|
||
const [fehler, setFehler] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function laden() {
|
||
try {
|
||
const [s, p] = await Promise.all([api.twoFactorStatus(), api.getPolicy()]);
|
||
setStatus(s);
|
||
setPolicy(p);
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
}
|
||
}
|
||
useEffect(() => { laden(); }, []);
|
||
|
||
if (!user) return <Navigate to="/login" replace />;
|
||
// Erst Passwort wechseln, dann zweiter Faktor – dieselbe Reihenfolge, die
|
||
// der Server erzwingt.
|
||
if (user.must_change_password) return <Navigate to="/passwort-aendern" replace />;
|
||
|
||
const pflicht = Boolean(policy?.mfa_required);
|
||
const erzwungen = pflicht && !status?.enabled;
|
||
|
||
async function starten() {
|
||
setFehler(null);
|
||
setBusy(true);
|
||
try {
|
||
setSetup(await api.twoFactorSetup());
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function aktivieren(e) {
|
||
e.preventDefault();
|
||
setFehler(null);
|
||
setBusy(true);
|
||
try {
|
||
const res = await api.twoFactorActivate(code.trim());
|
||
setCodes(res.codes);
|
||
setSetup(null);
|
||
setCode("");
|
||
// Die Sperre „MFA einrichten" fällt damit weg – Benutzer neu laden.
|
||
aktualisieren(await api.me());
|
||
await laden();
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function abschalten() {
|
||
const passwort = await confirm({
|
||
title: t("twofactor.disableTitle"),
|
||
message: t("twofactor.disableHint"),
|
||
confirmLabel: t("twofactor.disable"),
|
||
danger: true,
|
||
prompt: true,
|
||
inputType: "password",
|
||
});
|
||
if (!passwort) return;
|
||
try {
|
||
aktualisieren(await api.twoFactorDisable(passwort));
|
||
toast(t("twofactor.disabled"));
|
||
await laden();
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
}
|
||
}
|
||
|
||
async function codesErneuern() {
|
||
const passwort = await confirm({
|
||
title: t("twofactor.newCodesTitle"),
|
||
message: t("twofactor.newCodesHint"),
|
||
confirmLabel: t("twofactor.newCodes"),
|
||
prompt: true,
|
||
inputType: "password",
|
||
});
|
||
if (!passwort) return;
|
||
try {
|
||
const res = await api.newRecoveryCodes(passwort);
|
||
setCodes(res.codes);
|
||
await laden();
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<h1>{t("twofactor.title")}</h1>
|
||
|
||
{fehler && (
|
||
<div className="alert error" style={{ maxWidth: 520, marginBottom: "var(--sp-4)" }}>
|
||
<Icon name="alert" size={16} />
|
||
<span>{fehler}</span>
|
||
</div>
|
||
)}
|
||
{erzwungen && (
|
||
<div className="alert warn" style={{ maxWidth: 520, marginBottom: "var(--sp-4)" }}>
|
||
<Icon name="shield" size={16} />
|
||
<span>{t("twofactor.requiredHint")}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="card" style={{ maxWidth: 520 }}>
|
||
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
|
||
<Icon name="shield" size={18} />
|
||
{status?.enabled ? t("twofactor.on") : t("twofactor.off")}
|
||
</h2>
|
||
<p className="muted small">{t("twofactor.intro")}</p>
|
||
|
||
{status?.enabled ? (
|
||
<>
|
||
<p className="small">
|
||
{t("twofactor.codesLeft", { n: status.recovery_codes_left })}
|
||
</p>
|
||
<div className="btn-pair">
|
||
<button className="btn" onClick={codesErneuern}>
|
||
<Icon name="refresh-ccw" size={15} /> {t("twofactor.newCodes")}
|
||
</button>
|
||
{!pflicht && (
|
||
<button className="btn danger" onClick={abschalten}>
|
||
{t("twofactor.disable")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : setup ? (
|
||
<form onSubmit={aktivieren}>
|
||
<p className="small">{t("twofactor.scan")}</p>
|
||
<img className="tfa-qr" src={setup.qr_png} alt={t("twofactor.qrAlt")} />
|
||
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>
|
||
{t("twofactor.manual")}
|
||
</p>
|
||
<code className="tfa-secret selectable">{setup.secret}</code>
|
||
|
||
<label className="field" style={{ marginTop: "var(--sp-4)" }}>
|
||
<span>{t("twofactor.code")}</span>
|
||
<input value={code} onChange={(e) => setCode(e.target.value)} required
|
||
inputMode="numeric" maxLength={6} autoComplete="one-time-code"
|
||
placeholder="123456" />
|
||
</label>
|
||
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
|
||
<button className="btn primary" type="submit" disabled={busy}>
|
||
{busy ? t("common.loading") : t("twofactor.activate")}
|
||
</button>
|
||
<button className="btn ghost" type="button" onClick={() => { setSetup(null); setCode(""); }}>
|
||
{t("common.cancel")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<button className="btn primary" onClick={starten} disabled={busy}>
|
||
<Icon name="smartphone" size={15} /> {t("twofactor.start")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{codes && (
|
||
<CodeListe
|
||
codes={codes}
|
||
benutzer={user.username}
|
||
onFertig={() => { setCodes(null); if (erzwungen) navigate("/"); }}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|