feat: 2FA, Passwortrichtlinie und wiederherstellbares JSON-Backup

Zwei-Faktor per TOTP mit zehn Einmal-Wiederherstellungscodes. Der
Zwischenzustand "Passwort stimmt, Code fehlt" haengt an der vorhandenen
Sitzungstabelle (pending_2fa, zehn Minuten Laufzeit) und gilt nirgends als
angemeldet; nach dem Code wird das Cookie getauscht statt weiterverwendet.
Der Wiederverwendungsschutz merkt sich das tatsaechlich getroffene
Zeitfenster, nicht die aktuelle Uhrzeit - sonst liesse sich ein Code aus dem
naechsten Fenster gleich darauf nochmal einloesen.

Passwortrichtlinie (Laenge, Zeichenklassen, MFA-Pflicht) in der neuen
globalen Tabelle app_settings, durchgesetzt beim Anlegen, Zuruecksetzen und
Aendern von Passwoertern. Bestehende Passwoerter lassen sich nicht gegen eine
neue Richtlinie pruefen - der Hash gibt nichts her. Beim Anmelden liegt das
Passwort aber kurz im Klartext vor: dort wird geprueft und bei Verstoss ein
Wechsel verlangt, mit passendem Grund fuer den Hinweistext.

Nebenbei geschlossen: must_change_password war bisher nur im Frontend
erzwungen, wer die API direkt ansprach kam vorbei. enforce_account_state
haengt jetzt am /api-Sammelrouter und laesst nur noch durch, was man zum
Erfuellen der Pflicht braucht.

Das JSON-Backup liess sich bisher grundsaetzlich nicht zurueckladen: keine
Version, Kategorien nur ueber mehrdeutige Namen, sieben Tabellen fehlten.
Daneben steht nun ein Format v2, das Beziehungen ueber lokale IDs abbildet,
je Benutzer oder systemweit. Zurueckgeladen wird nach einer Pruefansicht,
wahlweise ersetzend oder ergaenzend; vor dem Ersetzen legt der Server einen
Schnappschuss der Datenbank an. Der alte Ansichts-Export bleibt fuer
Tabellenkalkulationen bestehen und wird beim Hochladen ausdruecklich
abgewiesen, statt halb zu raten.

Frontend: wiederverwendbares Passwortfeld mit Augensymbol, animierte
Anforderungsliste, 2FA-Seite, Admin-Karte fuer die Richtlinie und Karte zum
Zurueckladen. Neue Bewegung ist in prefers-reduced-motion hinterlegt; Secret
und Codes sind markierbar, sonst verhinderte das globale user-select das
Kopieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-20 10:11:22 +02:00
co-authored by Claude Opus 5
parent 1d5b23c127
commit 7fc8763d57
40 changed files with 3539 additions and 117 deletions
+7 -1
View File
@@ -14,6 +14,7 @@ import Budget from "./pages/Budget";
import Import from "./pages/Import";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
import TwoFactor from "./pages/TwoFactor";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
@@ -98,10 +99,14 @@ function Protected({ children, adminOnly = false, wide = false }) {
if (loading) return <div className="center muted" style={{ padding: 60 }}>{t("common.loading")}</div>;
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
if (!user) return <Navigate to="/login" replace />;
// Vom Admin gesetztes Temporär-Passwort: erst ändern, dann weiter.
// Offene Pflichten der Reihe nach – dieselbe Reihenfolge erzwingt der Server
// (backend/app/deps.py, enforce_account_state), sonst liefe man in 403er.
if (user.must_change_password && pathname !== "/passwort-aendern") {
return <Navigate to="/passwort-aendern" replace />;
}
if (user.mfa_setup_required && pathname !== "/zwei-faktor") {
return <Navigate to="/zwei-faktor" replace />;
}
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
return (
@@ -135,6 +140,7 @@ export default function App() {
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
<Route path="/import" element={<Protected wide><Import /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/zwei-faktor" element={<Protected><TwoFactor /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+33
View File
@@ -114,11 +114,29 @@ export const api = {
request("/auth/change-password", { method: "POST", body: { current_password, new_password } }),
version: () => request("/version"),
// Zwei-Faktor. verifyTwoFactor ist der zweite Anmeldeschritt und läuft noch
// über das kurzlebige Zwischen-Cookie – dort ist ein 401 normal, also nicht
// vom globalen Abmelde-Griff behandeln lassen.
verifyTwoFactor: (code, recovery = false) =>
request("/auth/2fa/verify", { method: "POST", body: { code, recovery }, skipAuthHandler: true }),
twoFactorStatus: () => request("/auth/2fa/status"),
twoFactorSetup: () => request("/auth/2fa/setup", { method: "POST" }),
twoFactorActivate: (code) => request("/auth/2fa/activate", { method: "POST", body: { code } }),
twoFactorDisable: (password) =>
request("/auth/2fa/disable", { method: "POST", body: { password } }),
newRecoveryCodes: (password) =>
request("/auth/2fa/recovery-codes", { method: "POST", body: { password } }),
// Passwortrichtlinie (lesen: alle, ändern: Admin)
getPolicy: () => request("/policy"),
setPolicy: (body) => request("/policy", { method: "PUT", body }),
// Benutzer (Admin)
listUsers: () => request("/users"),
createUser: (body) => request("/users", { method: "POST", body }),
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
resetUserTwoFactor: (id) => request(`/users/${id}/2fa/reset`, { method: "POST" }),
// Einstellungen (je Benutzer)
listSettings: () => request("/settings"),
@@ -218,4 +236,19 @@ export const api = {
exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`),
exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`),
exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`),
exportBackupJson: () =>
downloadFile("/export/backup.json", `certain-finance-backup_${zeitstempel()}.json`),
exportSystemBackupJson: () =>
downloadFile("/export/system-backup.json", `certain-finance-system-backup_${zeitstempel()}.json`),
restorePreview: (file) => {
const fd = new FormData();
fd.append("file", file);
return request("/backup/restore/preview", { method: "POST", formData: fd });
},
restoreBackup: (file, mode) => {
const fd = new FormData();
fd.append("file", file);
fd.append("mode", mode);
return request("/backup/restore", { method: "POST", formData: fd });
},
};
+21 -3
View File
@@ -44,12 +44,29 @@ export function AuthProvider({ children }) {
return () => setUnauthorizedHandler(null);
}, []);
/**
* Erster Anmeldeschritt. Liefert `{ status, user }`:
* `"ok"` heisst angemeldet, `"2fa_required"` heisst, es fehlt noch der Code
* aus der Authenticator-App – dann ist `user` leer und der Aufrufer muss
* `verifyTwoFactor` nachlegen.
*/
async function login(username, password) {
const me = await api.login(username, password);
setUser(me);
const res = await api.login(username, password);
if (res.status === "ok") {
setUser(res.user);
setAbgelaufen(false);
setStartFehler(null);
}
return res;
}
/** Zweiter Anmeldeschritt: TOTP-Code oder Wiederherstellungscode. */
async function verifyTwoFactor(code, recovery = false) {
const res = await api.verifyTwoFactor(code, recovery);
setUser(res.user);
setAbgelaufen(false);
setStartFehler(null);
return me;
return res;
}
async function logout() {
@@ -81,6 +98,7 @@ export function AuthProvider({ children }) {
isAdmin,
loading,
login,
verifyTwoFactor,
logout,
aktualisieren,
abgelaufen,
+25
View File
@@ -110,6 +110,31 @@ const PATHS = {
<line x1="2" x2="22" y1="2" y2="22" />
</>
),
lock: (
<>
<rect width="18" height="11" x="3" y="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</>
),
smartphone: (
<>
<rect width="14" height="20" x="5" y="2" rx="2" />
<path d="M12 18h.01" />
</>
),
copy: (
<>
<rect width="14" height="14" x="8" y="8" rx="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</>
),
upload: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" />
<line x1="12" x2="12" y1="3" y2="15" />
</>
),
// --- Finance-Grundausstattung ------------------------------------------
wallet: (
+53
View File
@@ -0,0 +1,53 @@
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
/**
* Passwortfeld mit Augensymbol zum Ein-/Ausblenden.
*
* Bewusst ein echtes ``type="password"``, solange nichts eingeblendet ist –
* nur so funktionieren Passwortmanager und die Autofill-Erkennung des Browsers
* zuverlässig.
*/
export default function PasswordInput({
value,
onChange,
autoComplete = "current-password",
required = false,
autoFocus = false,
id,
...rest
}) {
const { t } = useTranslation();
const [sichtbar, setSichtbar] = useState(false);
const eigeneId = useId();
const feldId = id || eigeneId;
const beschriftung = sichtbar ? t("password.hide") : t("password.show");
return (
<div className="input-with-button">
<input
id={feldId}
type={sichtbar ? "text" : "password"}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...rest}
/>
<button
type="button"
className="btn-icon"
onClick={() => setSichtbar((v) => !v)}
title={beschriftung}
aria-label={beschriftung}
aria-pressed={sichtbar}
tabIndex={-1}
>
<Icon name={sichtbar ? "eye-off" : "eye"} size={17} />
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import { ruleList } from "../passwordPolicy";
/**
* Anforderungsliste unter einem Passwortfeld, die sich beim Tippen abhakt.
*
* Solange nichts eingegeben ist, bleibt sie neutral – ein Feld, das man noch
* gar nicht angefasst hat, soll nicht wie ein Fehler aussehen.
*/
export default function PasswordRules({ policy, value }) {
const { t } = useTranslation();
const regeln = ruleList(policy, value, t);
const unberuehrt = !value;
return (
<ul className="pw-rules" aria-live="polite">
{regeln.map((r) => (
<li key={r.id} className={r.ok ? "pw-rule erfuellt" : "pw-rule"}>
<span className="pw-rule-mark" aria-hidden="true">
{r.ok ? <Icon name="check" size={13} strokeWidth={3} /> : <span className="pw-rule-dot" />}
</span>
<span className={unberuehrt ? "muted" : undefined}>{r.text}</span>
<span className="sr-only">{r.ok ? t("password.met") : t("password.unmet")}</span>
</li>
))}
</ul>
);
}
+13 -3
View File
@@ -19,6 +19,9 @@ import Icon from "./components/Icon";
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
*
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
*
* `inputType: "password"` blendet die Eingabe aus (Passwortabfrage). Dort wird
* bewusst nicht getrimmt – Leerzeichen dürfen Teil eines Passworts sein.
*/
const ConfirmContext = createContext(async () => false);
@@ -43,10 +46,15 @@ export function ConfirmProvider({ children }) {
antwort.current = null;
}
/** Passwörter unverändert lassen, sonst Rand-Leerzeichen wegschneiden. */
function wert() {
return frage?.inputType === "password" ? eingabe : eingabe.trim();
}
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
function bestaetigen() {
if (!frage?.prompt) { schliessen(true); return; }
const text = eingabe.trim();
const text = wert();
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
schliessen(text);
}
@@ -67,7 +75,9 @@ export function ConfirmProvider({ children }) {
{frage.prompt && (
<div className="modal-body">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
<input autoFocus type={frage.inputType || "text"}
autoComplete={frage.inputType === "password" ? "current-password" : undefined}
value={eingabe} onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") bestaetigen();
if (e.key === "Escape") schliessen(null);
@@ -81,7 +91,7 @@ export function ConfirmProvider({ children }) {
</button>
<button type="button"
className={frage.danger ? "btn danger-solid" : "btn primary"}
disabled={frage.prompt && !eingabe.trim()}
disabled={frage.prompt && !wert()}
onClick={bestaetigen}>
{frage.confirmLabel || "Fortfahren"}
</button>
+133
View File
@@ -812,3 +812,136 @@ tr.row-dup {
.kw-flag-word {
font-weight: 600;
}
/* ---------- Passwort: Anforderungsliste, Zwei-Faktor ---------- */
/* Nur für Screenreader – optisch weg, aber vorlesbar. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Hakt sich beim Tippen ab. Farben ausschliesslich aus Tokens, sonst bricht
das dunkle Thema. */
.pw-rules {
list-style: none;
margin: var(--sp-2) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.82rem;
}
.pw-rule {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--muted);
transition: color 0.16s ease;
}
.pw-rule.erfuellt {
color: var(--ok);
}
.pw-rule-mark {
flex: 0 0 auto;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* Offener Punkt, solange die Regel nicht erfüllt ist. */
.pw-rule-dot {
width: 7px;
height: 7px;
border-radius: 50%;
border: 1.5px solid currentColor;
opacity: 0.55;
}
/* Kurzer Impuls, wenn das Häkchen erscheint. */
.pw-rule.erfuellt .pw-rule-mark {
animation: pw-rule-pop 0.18s ease-out;
}
@keyframes pw-rule-pop {
from { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.18); opacity: 1; }
to { transform: scale(1); opacity: 1; }
}
/* styles.css hat keinen globalen Bewegungs-Reset, nur eine Aufzählung –
deshalb hier noch einmal ausdrücklich für die neuen Bewegungen. */
@media (prefers-reduced-motion: reduce) {
.pw-rule { transition: none; }
.pw-rule.erfuellt .pw-rule-mark { animation: none; }
}
/* Zwei-Faktor: QR-Code, Secret und Wiederherstellungscodes. */
.tfa-qr {
display: block;
width: 190px;
height: 190px;
/* Der QR-Code braucht weissen Grund, damit er auch im dunklen Thema
zuverlässig gescannt wird. */
background: #fff;
padding: var(--sp-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.tfa-secret {
display: inline-block;
padding: 6px var(--sp-2);
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9rem;
letter-spacing: 0.06em;
/* styles.css setzt body { user-select: none } – ohne das hier liesse sich
das Secret nicht markieren und kopieren. */
user-select: text;
}
.tfa-codes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--sp-2);
margin: var(--sp-3) 0;
padding: var(--sp-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9rem;
user-select: text;
}
.tfa-codes .verbraucht {
color: var(--muted);
text-decoration: line-through;
}
/* Zusammenfassung der Prüfansicht beim Wiederherstellen. */
.restore-summary {
width: 100%;
border-collapse: collapse;
margin-top: var(--sp-2);
font-size: 0.88rem;
}
.restore-summary td {
padding: 3px 0;
border-bottom: 1px solid var(--border);
}
.restore-summary td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.restore-summary tr:last-child td {
border-bottom: none;
}
+93 -3
View File
@@ -40,7 +40,8 @@
"repeat": "Neues Passwort wiederholen",
"submit": "Passwort speichern",
"mismatch": "Die neuen Passwörter stimmen nicht überein.",
"saved": "Passwort geändert."
"saved": "Passwort geändert.",
"policyHint": "Die Passwortrichtlinie wurde geändert. Dein bisheriges Passwort erfüllt sie nicht mehr – bitte lege ein neues fest."
},
"users": {
"title": "Benutzer",
@@ -56,7 +57,12 @@
"resetPassword": "Passwort zurücksetzen",
"deleteConfirm": "Diesen Benutzer wirklich löschen?",
"created": "Benutzer angelegt.",
"you": "(du)"
"you": "(du)",
"twoFactor": "Zwei-Faktor",
"resetTwoFactor": "Zwei-Faktor zurücksetzen",
"resetTwoFactorConfirm": "Zwei-Faktor für „{{name}}“ abschalten? Nur nötig, wenn Gerät und Wiederherstellungscodes verloren sind.",
"twoFactorReset": "Zwei-Faktor zurückgesetzt.",
"generatePassword": "Passwort erzeugen"
},
"settings": {
"title": "Einstellungen",
@@ -84,7 +90,14 @@
"keywordReviewHint": "Diese Schlagwörter werden auffällig oft wieder wegkorrigiert – stimmen sie vielleicht nicht mehr?",
"keywordCorrected": "{{overridden}} von {{applied}} Buchungen korrigiert",
"keep": "Behalten",
"remove": "Entfernen"
"remove": "Entfernen",
"security": "Sicherheit",
"securityHint": "Zwei-Faktor-Authentisierung für dein eigenes Konto.",
"twoFactor": "Zwei-Faktor verwalten",
"backupHint": "Das Voll-Backup enthält alle deine Daten und lässt sich unten wieder einspielen.",
"exportBackup": "Voll-Backup (JSON)",
"systemBackup": "Systemweites Voll-Backup (JSON)",
"systemBackupWarning": "Das systemweite Backup enthält Passwort-Hashes und Zwei-Faktor-Geheimnisse aller Benutzer. Behandle die Datei wie ein Passwort."
},
"dashboard": {
"title": "Übersicht",
@@ -280,5 +293,82 @@
"undone": "rückgängig gemacht",
"needFile": "Bitte zuerst eine CSV-Datei wählen.",
"needFileAccount": "Bitte Konto und Datei wählen."
},
"password": {
"show": "Passwort anzeigen",
"hide": "Passwort verbergen",
"met": "(erfüllt)",
"unmet": "(offen)",
"ruleLength": "Mindestens {{n}} Zeichen",
"ruleUpper": "Mindestens ein Grossbuchstabe",
"ruleLower": "Mindestens ein Kleinbuchstabe",
"ruleDigit": "Mindestens eine Ziffer",
"ruleSpecial": "Mindestens ein Sonderzeichen"
},
"twofactor": {
"title": "Zwei-Faktor-Authentisierung",
"intro": "Zusätzlich zum Passwort ein Einmalcode aus einer Authenticator-App. Wer nur dein Passwort kennt, kommt damit nicht mehr hinein.",
"on": "Aktiv",
"off": "Nicht eingerichtet",
"start": "Einrichten",
"scan": "Scanne den Code mit deiner Authenticator-App (z. B. Aegis, 1Password, Google Authenticator).",
"qrAlt": "QR-Code zur Einrichtung der Authenticator-App",
"manual": "Geht der Scan nicht, gib diesen Schlüssel von Hand ein:",
"code": "Code aus der App",
"codeHint": "Gib den sechsstelligen Code aus deiner Authenticator-App ein.",
"activate": "Aktivieren",
"verify": "Bestätigen",
"useRecovery": "Wiederherstellungscode verwenden",
"useApp": "Doch Code aus der App",
"recoveryCode": "Wiederherstellungscode",
"recoveryHint": "Gib einen deiner Wiederherstellungscodes ein. Jeder funktioniert nur einmal.",
"codesTitle": "Wiederherstellungscodes",
"codesHint": "Bewahre diese Codes sicher auf – zum Beispiel ausgedruckt. Sie sind der einzige Weg hinein, wenn dein Gerät verloren geht. Jeder Code funktioniert genau einmal, und angezeigt werden sie nur dieses eine Mal.",
"codesSaved": "Habe ich gesichert",
"codesLeft": "Noch {{n}} unbenutzte Wiederherstellungscodes.",
"copy": "Kopieren",
"copied": "In die Zwischenablage kopiert.",
"download": "Als Datei sichern",
"newCodes": "Neue Codes erzeugen",
"newCodesTitle": "Neue Wiederherstellungscodes",
"newCodesHint": "Die bisherigen Codes werden dabei ungültig. Zur Bestätigung dein Passwort eingeben.",
"disable": "Zwei-Faktor abschalten",
"disableTitle": "Zwei-Faktor abschalten",
"disableHint": "Danach genügt wieder das Passwort allein. Zur Bestätigung dein Passwort eingeben.",
"disabled": "Zwei-Faktor abgeschaltet.",
"requiredHint": "Die Richtlinie verlangt Zwei-Faktor. Bis zur Einrichtung bleibt der Rest der Anwendung gesperrt."
},
"policy": {
"title": "Passwortrichtlinie",
"hint": "Gilt für alle Benutzer dieser Instanz.",
"minLength": "Mindestlänge",
"require_upper": "Grossbuchstabe verlangen",
"require_lower": "Kleinbuchstabe verlangen",
"require_digit": "Ziffer verlangen",
"require_special": "Sonderzeichen verlangen",
"mfaRequired": "Zwei-Faktor für alle verpflichtend",
"mfaHint": "Benutzer ohne zweiten Faktor müssen ihn beim nächsten Anmelden einrichten.",
"tightenWarning": "Verschärfung: Benutzer, deren Passwort die neue Richtlinie nicht erfüllt, müssen es beim nächsten Anmelden ändern.",
"mfaWarning": "Alle Benutzer ohne Zwei-Faktor werden beim nächsten Anmelden zur Einrichtung geführt – auch du."
},
"restore": {
"title": "Backup zurückladen",
"hint": "Wähle eine Backup-Datei. Sie wird zuerst nur geprüft; eingespielt wird erst nach deiner Bestätigung.",
"file": "Backup-Datei",
"empty": "Die Datei enthält keine Daten.",
"scopeUser": "Backup der Daten von „{{name}}“.",
"scopeSystem": "Systemweites Backup mit {{n}} Benutzern.",
"exportedAt": "Erstellt am {{when}}",
"mode": "Vorgehen",
"modeReplace": "Ersetzen",
"modeMerge": "Ergänzen",
"modeReplaceHint": "Vorhandene Daten werden gelöscht und durch das Backup ersetzt. Vorher legt der Server automatisch eine Sicherungskopie der Datenbank an.",
"modeMergeHint": "Nichts wird gelöscht. Nur was fehlt, kommt hinzu; Budget, Regeln und Einstellungen bleiben unangetastet, wenn dazu schon etwas vorhanden ist.",
"start": "Wiederherstellen",
"confirmTitle": "Wirklich wiederherstellen?",
"confirmReplace": "Alle deine Konten, Buchungen, Kategorien und Einstellungen werden gelöscht und durch das Backup ersetzt.",
"confirmSystem": "Alle Benutzer und deren Daten werden gelöscht und durch das Backup ersetzt. Anschliessend sind alle Sitzungen beendet.",
"done": "Wiederherstellung abgeschlossen.",
"doneSystem": "Wiederherstellung abgeschlossen. Bitte neu anmelden."
}
}
+93 -3
View File
@@ -40,7 +40,8 @@
"repeat": "Repeat new password",
"submit": "Save password",
"mismatch": "The new passwords do not match.",
"saved": "Password changed."
"saved": "Password changed.",
"policyHint": "The password policy has changed. Your current password no longer meets it – please choose a new one."
},
"users": {
"title": "Users",
@@ -56,7 +57,12 @@
"resetPassword": "Reset password",
"deleteConfirm": "Really delete this user?",
"created": "User created.",
"you": "(you)"
"you": "(you)",
"twoFactor": "Two-factor",
"resetTwoFactor": "Reset two-factor",
"resetTwoFactorConfirm": "Turn off two-factor for “{{name}}”? Only needed if both device and recovery codes are lost.",
"twoFactorReset": "Two-factor reset.",
"generatePassword": "Generate password"
},
"settings": {
"title": "Settings",
@@ -84,7 +90,14 @@
"keywordReviewHint": "These keywords keep getting corrected away — maybe they no longer fit?",
"keywordCorrected": "{{overridden}} of {{applied}} transactions corrected",
"keep": "Keep",
"remove": "Remove"
"remove": "Remove",
"security": "Security",
"securityHint": "Two-factor authentication for your own account.",
"twoFactor": "Manage two-factor",
"backupHint": "The full backup contains all of your data and can be restored below.",
"exportBackup": "Full backup (JSON)",
"systemBackup": "System-wide full backup (JSON)",
"systemBackupWarning": "The system-wide backup contains password hashes and two-factor secrets of every user. Treat the file like a password."
},
"dashboard": {
"title": "Overview",
@@ -280,5 +293,82 @@
"undone": "undone",
"needFile": "Please choose a CSV file first.",
"needFileAccount": "Please choose an account and a file."
},
"password": {
"show": "Show password",
"hide": "Hide password",
"met": "(met)",
"unmet": "(not met)",
"ruleLength": "At least {{n}} characters",
"ruleUpper": "At least one uppercase letter",
"ruleLower": "At least one lowercase letter",
"ruleDigit": "At least one digit",
"ruleSpecial": "At least one special character"
},
"twofactor": {
"title": "Two-factor authentication",
"intro": "A one-time code from an authenticator app on top of your password. Knowing your password alone is no longer enough.",
"on": "Active",
"off": "Not set up",
"start": "Set up",
"scan": "Scan the code with your authenticator app (e.g. Aegis, 1Password, Google Authenticator).",
"qrAlt": "QR code for setting up the authenticator app",
"manual": "If scanning does not work, enter this key manually:",
"code": "Code from the app",
"codeHint": "Enter the six-digit code from your authenticator app.",
"activate": "Activate",
"verify": "Confirm",
"useRecovery": "Use a recovery code",
"useApp": "Use the app code instead",
"recoveryCode": "Recovery code",
"recoveryHint": "Enter one of your recovery codes. Each one works only once.",
"codesTitle": "Recovery codes",
"codesHint": "Keep these codes somewhere safe – printed, for example. They are the only way back in if you lose your device. Each code works exactly once and they are shown here only this one time.",
"codesSaved": "I have saved them",
"codesLeft": "{{n}} unused recovery codes left.",
"copy": "Copy",
"copied": "Copied to clipboard.",
"download": "Save as file",
"newCodes": "Generate new codes",
"newCodesTitle": "New recovery codes",
"newCodesHint": "The previous codes stop working. Enter your password to confirm.",
"disable": "Turn off two-factor",
"disableTitle": "Turn off two-factor",
"disableHint": "Your password alone will be enough again. Enter your password to confirm.",
"disabled": "Two-factor turned off.",
"requiredHint": "The policy requires two-factor. The rest of the application stays locked until you set it up."
},
"policy": {
"title": "Password policy",
"hint": "Applies to every user of this instance.",
"minLength": "Minimum length",
"require_upper": "Require an uppercase letter",
"require_lower": "Require a lowercase letter",
"require_digit": "Require a digit",
"require_special": "Require a special character",
"mfaRequired": "Require two-factor for everyone",
"mfaHint": "Users without a second factor must set one up at their next sign-in.",
"tightenWarning": "Tightening: users whose password no longer meets the policy will have to change it at their next sign-in.",
"mfaWarning": "Every user without two-factor will be taken to the setup at their next sign-in – including you."
},
"restore": {
"title": "Restore a backup",
"hint": "Pick a backup file. It is only inspected first; nothing is written until you confirm.",
"file": "Backup file",
"empty": "The file contains no data.",
"scopeUser": "Backup of the data of “{{name}}”.",
"scopeSystem": "System-wide backup with {{n}} users.",
"exportedAt": "Created on {{when}}",
"mode": "How to proceed",
"modeReplace": "Replace",
"modeMerge": "Add missing",
"modeReplaceHint": "Existing data is deleted and replaced by the backup. The server writes a database snapshot beforehand.",
"modeMergeHint": "Nothing is deleted. Only missing entries are added; budget, rules and settings stay untouched if anything is already there.",
"start": "Restore",
"confirmTitle": "Really restore?",
"confirmReplace": "All your accounts, transactions, categories and settings will be deleted and replaced by the backup.",
"confirmSystem": "All users and their data will be deleted and replaced by the backup. Every session ends afterwards.",
"done": "Restore finished.",
"doneSystem": "Restore finished. Please sign in again."
}
}
+33 -13
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../auth";
@@ -6,6 +6,9 @@ import { api, ApiError } from "../api";
import { useToast } from "../toast";
import BrandMark from "../components/BrandMark";
import Icon from "../components/Icon";
import PasswordInput from "../components/PasswordInput";
import PasswordRules from "../components/PasswordRules";
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
export default function ChangePassword() {
const { user, aktualisieren } = useAuth();
@@ -15,11 +18,21 @@ export default function ChangePassword() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [policy, setPolicy] = useState(DEFAULT_POLICY);
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Fällt der Abruf aus, bleibt die Vorgabe stehen – verbindlich prüft
// ohnehin der Server, hier geht es nur um die Anzeige.
api.getPolicy().then(setPolicy).catch(() => {});
}, []);
if (!user) return <Navigate to="/login" replace />;
const forced = user.must_change_password;
const wegenRichtlinie = user.must_change_password_reason === "policy";
const regelnErfuellt = meetsPolicy(policy, next, t);
const bereit = regelnErfuellt && next === repeat && current.length > 0;
async function onSubmit(e) {
e.preventDefault();
@@ -49,7 +62,7 @@ export default function ChangePassword() {
{forced && (
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="key" size={16} />
<span>{t("changepw.forcedHint")}</span>
<span>{wegenRichtlinie ? t("changepw.policyHint") : t("changepw.forcedHint")}</span>
</div>
)}
{fehler && (
@@ -59,24 +72,31 @@ export default function ChangePassword() {
</div>
)}
<label>
<label className="field">
<span>{t("changepw.current")}</span>
<input type="password" autoComplete="current-password" value={current}
onChange={(e) => setCurrent(e.target.value)} required />
<PasswordInput value={current} onChange={setCurrent}
autoComplete="current-password" required />
</label>
<label>
<label className="field">
<span>{t("changepw.new")}</span>
<input type="password" autoComplete="new-password" value={next}
onChange={(e) => setNext(e.target.value)} required />
<PasswordInput value={next} onChange={setNext}
autoComplete="new-password" required />
</label>
<label>
<PasswordRules policy={policy} value={next} />
<label className="field" style={{ marginTop: "var(--sp-3)" }}>
<span>{t("changepw.repeat")}</span>
<input type="password" autoComplete="new-password" value={repeat}
onChange={(e) => setRepeat(e.target.value)} required />
<PasswordInput value={repeat} onChange={setRepeat}
autoComplete="new-password" required />
</label>
{repeat && next !== repeat && (
<span className="hint-danger">
<Icon name="alert" size={12} />
{t("changepw.mismatch")}
</span>
)}
<button className="btn primary" type="submit" disabled={busy}
style={{ width: "100%", marginTop: "var(--sp-2)" }}>
<button className="btn primary" type="submit" disabled={busy || !bereit}
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
{busy ? t("common.loading") : t("changepw.submit")}
</button>
</form>
+91 -18
View File
@@ -5,24 +5,33 @@ import { useAuth } from "../auth";
import { ApiError } from "../api";
import BrandMark from "../components/BrandMark";
import Icon from "../components/Icon";
import PasswordInput from "../components/PasswordInput";
export default function Login() {
const { login, abgelaufen } = useAuth();
const { login, verifyTwoFactor, abgelaufen } = useAuth();
const { t } = useTranslation();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [zeigePw, setZeigePw] = useState(false);
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
// Zweiter Schritt: erst gesetzt, wenn der Server nach dem Code fragt.
const [zweiterSchritt, setZweiterSchritt] = useState(false);
const [code, setCode] = useState("");
const [wiederherstellung, setWiederherstellung] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setFehler(null);
setBusy(true);
try {
await login(username, password);
navigate("/");
const res = await login(username, password);
if (res.status === "2fa_required") {
setZweiterSchritt(true);
setPassword("");
} else {
navigate("/");
}
} catch (err) {
if (err instanceof ApiError && err.status === 401) setFehler(t("login.error"));
else setFehler(err.message || t("server.unreachable"));
@@ -31,6 +40,81 @@ export default function Login() {
}
}
async function onVerify(e) {
e.preventDefault();
setFehler(null);
setBusy(true);
try {
await verifyTwoFactor(code.trim(), wiederherstellung);
navigate("/");
} catch (err) {
setFehler(err.message || t("server.unreachable"));
setCode("");
} finally {
setBusy(false);
}
}
function zurueck() {
// Das Zwischen-Cookie läuft von allein ab; für den Neustart genügt es,
// wieder das Anmeldeformular zu zeigen.
setZweiterSchritt(false);
setCode("");
setWiederherstellung(false);
setFehler(null);
}
const meldung = fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{fehler}</span>
</div>
);
if (zweiterSchritt) {
return (
<div className="auth-screen">
<form className="card auth-card" onSubmit={onVerify}>
<BrandMark size={40} />
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
{wiederherstellung ? t("twofactor.recoveryHint") : t("twofactor.codeHint")}
</p>
{meldung}
<label className="field">
<span>{wiederherstellung ? t("twofactor.recoveryCode") : t("twofactor.code")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoComplete="one-time-code"
inputMode={wiederherstellung ? "text" : "numeric"}
maxLength={wiederherstellung ? 32 : 6}
placeholder={wiederherstellung ? "XXXXX-XXXXX" : "123456"}
/>
</label>
<button className="btn primary" type="submit" disabled={busy}
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
{busy ? t("common.loading") : t("twofactor.verify")}
</button>
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
<button type="button" className="btn sm ghost"
onClick={() => { setWiederherstellung((v) => !v); setCode(""); setFehler(null); }}>
{wiederherstellung ? t("twofactor.useApp") : t("twofactor.useRecovery")}
</button>
<button type="button" className="btn sm ghost" onClick={zurueck}>
{t("common.cancel")}
</button>
</div>
</form>
</div>
);
}
return (
<div className="auth-screen">
<form className="card auth-card" onSubmit={onSubmit}>
@@ -43,12 +127,7 @@ export default function Login() {
<span>{t("login.expired")}</span>
</div>
)}
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{fehler}</span>
</div>
)}
{meldung}
<label className="field">
<span>{t("login.username")}</span>
@@ -59,14 +138,8 @@ export default function Login() {
<label className="field">
<span>{t("login.password")}</span>
<div className="input-with-button">
<input type={zeigePw ? "text" : "password"} autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} required />
<button type="button" className="btn-icon" onClick={() => setZeigePw((v) => !v)}
title={zeigePw ? t("common.close") : ""} tabIndex={-1}>
<Icon name={zeigePw ? "eye-off" : "eye"} size={17} />
</button>
</div>
<PasswordInput value={password} onChange={setPassword}
autoComplete="current-password" required />
</label>
<button className="btn primary" type="submit" disabled={busy}
+37 -5
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useAuth } from "../auth";
import { useToast } from "../toast";
import { api } from "../api";
import Icon from "../components/Icon";
import PolicyCard from "./settings/PolicyCard";
import RestoreCard from "./settings/RestoreCard";
export default function Settings() {
const { t } = useTranslation();
@@ -116,9 +119,26 @@ export default function Settings() {
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
</div>
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="shield" size={18} /> {t("settings.security")}
</h2>
<p className="muted small">{t("settings.securityHint")}</p>
<Link className="btn" to="/zwei-faktor">
<Icon name="smartphone" size={15} /> {t("settings.twoFactor")}
</Link>
</div>
{isAdmin && <PolicyCard />}
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2>{t("settings.backup")}</h2>
<p className="muted small">{t("settings.exportHint")}</p>
<p className="muted small">{t("settings.backupHint")}</p>
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
<Icon name="download" size={15} /> {t("settings.exportBackup")}
</button>
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.exportHint")}</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
@@ -130,15 +150,27 @@ export default function Settings() {
<Icon name="download" size={15} /> {t("settings.exportAll")}
</button>
</div>
{isAdmin && (
<>
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>{t("settings.dbBackupHint")}</p>
<button className="btn primary" onClick={() => dl(api.downloadDbBackup)}>
<Icon name="download" size={15} /> {t("settings.dbBackup")}
</button>
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.dbBackupHint")}</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<button className="btn" onClick={() => dl(api.downloadDbBackup)}>
<Icon name="download" size={15} /> {t("settings.dbBackup")}
</button>
<button className="btn" onClick={() => dl(api.exportSystemBackupJson)}>
<Icon name="download" size={15} /> {t("settings.systemBackup")}
</button>
</div>
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{t("settings.systemBackupWarning")}</span>
</div>
</>
)}
</div>
<RestoreCard />
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
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("/"); }}
/>
)}
</>
);
}
+46 -12
View File
@@ -6,13 +6,8 @@ import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
function randomPassword(len = 12) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
const arr = new Uint32Array(len);
crypto.getRandomValues(arr);
return Array.from(arr, (n) => chars[n % chars.length]).join("");
}
import PasswordRules from "../components/PasswordRules";
import { DEFAULT_POLICY, meetsPolicy, randomPassword } from "../passwordPolicy";
export default function Users() {
const { t } = useTranslation();
@@ -21,6 +16,7 @@ export default function Users() {
const toast = useToast();
const [users, setUsers] = useState([]);
const [policy, setPolicy] = useState(DEFAULT_POLICY);
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
@@ -35,6 +31,8 @@ export default function Users() {
useEffect(() => {
laden();
// Die Richtlinie bestimmt, was das erzeugte Temporär-Passwort erfüllen muss.
api.getPolicy().then(setPolicy).catch(() => {});
}, []);
async function anlegen(e) {
@@ -63,13 +61,31 @@ export default function Users() {
title: t("users.resetPassword"),
message: u.username,
prompt: true,
defaultValue: randomPassword(),
defaultValue: randomPassword(policy),
confirmLabel: t("common.save"),
});
if (!neu) return;
try {
await api.updateUser(u.id, { password: neu });
toast(t("settings.saved"));
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function zweiFaktorZuruecksetzen(u) {
const ok = await confirm({
title: t("users.resetTwoFactor"),
message: t("users.resetTwoFactorConfirm", { name: u.username }),
confirmLabel: t("users.resetTwoFactor"),
danger: true,
});
if (!ok) return;
try {
await api.resetUserTwoFactor(u.id);
toast(t("users.twoFactorReset"));
laden();
} catch (err) {
toast(err.message, "error");
}
@@ -123,12 +139,26 @@ export default function Users() {
),
},
{
key: "actions", header: t("common.actions"), width: 132, fixed: true,
key: "twofactor", header: t("users.twoFactor"), width: 110,
filterText: (u) => (u.totp_enabled ? t("twofactor.on") : t("twofactor.off")),
render: (u) => (
<span className={`badge ${u.totp_enabled ? "ok" : ""}`}>
{u.totp_enabled ? t("twofactor.on") : t("twofactor.off")}
</span>
),
},
{
key: "actions", header: t("common.actions"), width: 168, fixed: true,
render: (u) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.totp_enabled && (
<button className="btn sm ghost" title={t("users.resetTwoFactor")} onClick={() => zweiFaktorZuruecksetzen(u)}>
<Icon name="shield" size={15} />
</button>
)}
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
@@ -142,8 +172,9 @@ export default function Users() {
</div>
),
},
// policy muss mit hinein: das erzeugte Vorschlagspasswort richtet sich danach.
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, self]);
], [t, self, policy]);
return (
<div>
@@ -174,10 +205,12 @@ export default function Users() {
<span>{t("users.password")}</span>
<div className="input-with-button">
<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<button type="button" className="btn sm" onClick={() => setForm({ ...form, password: randomPassword() })}>
<button type="button" className="btn sm" title={t("users.generatePassword")}
onClick={() => setForm({ ...form, password: randomPassword(policy) })}>
<Icon name="refresh-ccw" size={15} />
</button>
</div>
<PasswordRules policy={policy} value={form.password} />
</label>
<label style={{ minWidth: 160 }}>
<span>{t("users.role")}</span>
@@ -188,7 +221,8 @@ export default function Users() {
</label>
</div>
<p className="muted small">{t("users.passwordHint")}</p>
<button className="btn primary" type="submit" disabled={busy}>
<button className="btn primary" type="submit"
disabled={busy || !meetsPolicy(policy, form.password, t)}>
{busy ? t("common.loading") : t("common.create")}
</button>
</form>
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api";
import { useToast } from "../../toast";
import Icon from "../../components/Icon";
import { DEFAULT_POLICY } from "../../passwordPolicy";
const SCHALTER = ["require_upper", "require_lower", "require_digit", "require_special"];
/**
* Passwortrichtlinie – gilt für die ganze Instanz, deshalb nur für Admins.
*
* Bewusst mit Speichern-Knopf statt „sofort übernehmen" wie bei den
* persönlichen Einstellungen: eine Verschärfung wirkt auf alle Benutzer, das
* soll ein bewusster Schritt sein.
*/
export default function PolicyCard() {
const { t } = useTranslation();
const toast = useToast();
const [policy, setPolicy] = useState(null);
const [entwurf, setEntwurf] = useState(DEFAULT_POLICY);
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState(null);
useEffect(() => {
api.getPolicy()
.then((p) => { setPolicy(p); setEntwurf(p); })
.catch((err) => setFehler(err.message));
}, []);
const geaendert = policy && SCHALTER.concat("min_length", "mfa_required")
.some((k) => policy[k] !== entwurf[k]);
const verschaerft = policy && (
entwurf.min_length > policy.min_length ||
SCHALTER.some((k) => entwurf[k] && !policy[k])
);
const mfaNeu = policy && entwurf.mfa_required && !policy.mfa_required;
async function speichern() {
setBusy(true);
setFehler(null);
try {
const gespeichert = await api.setPolicy(entwurf);
setPolicy(gespeichert);
setEntwurf(gespeichert);
toast(t("settings.saved"));
} catch (err) {
setFehler(err.message);
} finally {
setBusy(false);
}
}
if (!policy) {
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2 style={{ marginTop: 0 }}>{t("policy.title")}</h2>
{fehler ? <div className="alert error"><Icon name="alert" size={16} /><span>{fehler}</span></div>
: <p className="muted small">{t("common.loading")}</p>}
</div>
);
}
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="lock" size={18} /> {t("policy.title")}
</h2>
<p className="muted small">{t("policy.hint")}</p>
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} /><span>{fehler}</span>
</div>
)}
<label>
<span>{t("policy.minLength")}</span>
<input type="number" min="8" max="128" value={entwurf.min_length}
onChange={(e) => {
const n = Math.max(8, Math.min(128, Number(e.target.value) || 8));
setEntwurf({ ...entwurf, min_length: n });
}} />
</label>
<div style={{ marginTop: "var(--sp-3)" }}>
{SCHALTER.map((k) => (
<label key={k} className="field-inline" style={{ marginBottom: 6 }}>
<input type="checkbox" checked={entwurf[k]}
onChange={(e) => setEntwurf({ ...entwurf, [k]: e.target.checked })} />
<span>{t(`policy.${k}`)}</span>
</label>
))}
</div>
<div style={{ marginTop: "var(--sp-4)", paddingTop: "var(--sp-3)", borderTop: "1px solid var(--border)" }}>
<label className="field-inline">
<input type="checkbox" checked={entwurf.mfa_required}
onChange={(e) => setEntwurf({ ...entwurf, mfa_required: e.target.checked })} />
<span>{t("policy.mfaRequired")}</span>
</label>
<p className="muted small" style={{ marginTop: 4 }}>{t("policy.mfaHint")}</p>
</div>
{verschaerft && (
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} /><span>{t("policy.tightenWarning")}</span>
</div>
)}
{mfaNeu && (
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="shield" size={16} /><span>{t("policy.mfaWarning")}</span>
</div>
)}
<button className="btn primary" style={{ marginTop: "var(--sp-3)" }}
disabled={busy || !geaendert} onClick={speichern}>
{busy ? t("common.loading") : t("common.save")}
</button>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../../api";
import { useAuth } from "../../auth";
import { useConfirm } from "../../confirm";
import { useToast } from "../../toast";
import Icon from "../../components/Icon";
/**
* Backup zurückladen – zweistufig wie der CSV-Import: erst prüfen lassen, was
* in der Datei steckt, dann bewusst bestätigen. Der Server schreibt beim Prüfen
* nichts.
*/
export default function RestoreCard() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const navigate = useNavigate();
const { logout } = useAuth();
const dateiFeld = useRef(null);
const [datei, setDatei] = useState(null);
const [vorschau, setVorschau] = useState(null);
const [modus, setModus] = useState("replace");
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
function zuruecksetzen() {
setDatei(null);
setVorschau(null);
setFehler(null);
if (dateiFeld.current) dateiFeld.current.value = "";
}
async function pruefen(f) {
setDatei(f);
setVorschau(null);
setFehler(null);
if (!f) return;
setBusy(true);
try {
setVorschau(await api.restorePreview(f));
} catch (err) {
setFehler(err.message);
} finally {
setBusy(false);
}
}
async function einspielen() {
const systemweit = vorschau.scope === "system";
if (modus === "replace") {
const ok = await confirm({
title: t("restore.confirmTitle"),
message: systemweit ? t("restore.confirmSystem") : t("restore.confirmReplace"),
confirmLabel: t("restore.start"),
danger: true,
});
if (!ok) return;
}
setBusy(true);
setFehler(null);
try {
const res = await api.restoreBackup(datei, systemweit ? "replace" : modus);
if (res.logout_required) {
// Die eigene Sitzung wurde beim Ersetzen der Benutzertabelle mit
// gelöscht – sauber abmelden statt in 401er zu laufen.
toast(t("restore.doneSystem"));
await logout();
navigate("/login");
return;
}
toast(t("restore.done"));
zuruecksetzen();
// Konten, Kategorien und Einstellungen sind komplett andere – neu laden
// ist ehrlicher als jeden Zwischenspeicher einzeln zu erneuern.
window.location.reload();
} catch (err) {
setFehler(err.message);
setBusy(false);
}
}
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="upload" size={18} /> {t("restore.title")}
</h2>
<p className="muted small">{t("restore.hint")}</p>
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} /><span>{fehler}</span>
</div>
)}
<label>
<span>{t("restore.file")}</span>
<input ref={dateiFeld} type="file" accept="application/json,.json"
onChange={(e) => pruefen(e.target.files?.[0] || null)} />
</label>
{busy && !vorschau && <p className="muted small">{t("common.loading")}</p>}
{vorschau && (
<>
<div className="alert" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="check" size={16} />
<div>
<div>
{vorschau.scope === "system"
? t("restore.scopeSystem", { n: vorschau.user_count })
: t("restore.scopeUser", { name: vorschau.username || "?" })}
</div>
<div className="muted small" style={{ marginTop: 2 }}>
{t("restore.exportedAt", { when: vorschau.exported_at || "?" })}
</div>
</div>
</div>
<table className="restore-summary">
<tbody>
{vorschau.tables.map((z) => (
<tr key={z.key}>
<td>{z.label}</td>
<td>{z.count}</td>
</tr>
))}
{vorschau.tables.length === 0 && (
<tr><td colSpan={2} className="muted">{t("restore.empty")}</td></tr>
)}
</tbody>
</table>
{vorschau.notes.map((n) => (
<div className="alert warn" key={n} style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} /><span>{n}</span>
</div>
))}
{vorschau.scope === "user" && (
<label style={{ marginTop: "var(--sp-3)" }}>
<span>{t("restore.mode")}</span>
<select value={modus} onChange={(e) => setModus(e.target.value)}>
<option value="replace">{t("restore.modeReplace")}</option>
<option value="merge">{t("restore.modeMerge")}</option>
</select>
</label>
)}
<p className="muted small">
{vorschau.scope === "system" || modus === "replace"
? t("restore.modeReplaceHint")
: t("restore.modeMergeHint")}
</p>
<div className="btn-pair">
<button className="btn danger-solid" disabled={busy} onClick={einspielen}>
{busy ? t("common.loading") : t("restore.start")}
</button>
<button className="btn ghost" disabled={busy} onClick={zuruecksetzen}>
{t("common.cancel")}
</button>
</div>
</>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
/* Auswertung der Passwortrichtlinie im Browser – nur für die Anzeige.
*
* Verbindlich prüft der Server (backend/app/services/policy.py); hier geht es
* allein darum, beim Tippen sofort zu zeigen, was noch fehlt. Die Regeln müssen
* deshalb mit der Backend-Fassung übereinstimmen:
* Grossbuchstabe = \p{Lu}, Kleinbuchstabe = \p{Ll}, Ziffer = \p{Nd},
* Sonderzeichen = weder Buchstabe noch Ziffer noch Leerraum.
*/
export const DEFAULT_POLICY = {
min_length: 10,
require_upper: true,
require_lower: true,
require_digit: true,
require_special: false,
mfa_required: false,
};
const PRUEFUNGEN = {
upper: (pw) => /\p{Lu}/u.test(pw),
lower: (pw) => /\p{Ll}/u.test(pw),
digit: (pw) => /\p{Nd}/u.test(pw),
special: (pw) => /[^\p{L}\p{N}\s]/u.test(pw),
};
/**
* Liefert die aktiven Regeln als Liste – jeweils mit Kennung und Erfüllt-Zustand.
* `t` ist die i18n-Funktion; die Texte liegen unter `password.rule*`.
*/
export function ruleList(policy, password, t) {
const p = policy || DEFAULT_POLICY;
const pw = password || "";
const regeln = [
{ id: "length", text: t("password.ruleLength", { n: p.min_length }), ok: pw.length >= p.min_length },
];
for (const id of ["upper", "lower", "digit", "special"]) {
if (p[`require_${id}`]) {
regeln.push({
id,
text: t(`password.rule${id[0].toUpperCase()}${id.slice(1)}`),
ok: PRUEFUNGEN[id](pw),
});
}
}
return regeln;
}
/** True, wenn das Passwort alle aktiven Regeln erfüllt. */
export function meetsPolicy(policy, password, t) {
return ruleList(policy, password, t).every((r) => r.ok);
}
const GROSS = "ABCDEFGHJKMNPQRSTUVWXYZ"; // ohne I, O, L
const KLEIN = "abcdefghijkmnpqrstuvwxyz";
const ZIFFERN = "23456789";
const SONDER = "!?%+*#$";
function ziehen(alphabet, anzahl) {
const werte = new Uint32Array(anzahl);
crypto.getRandomValues(werte);
return Array.from(werte, (v) => alphabet[v % alphabet.length]);
}
/**
* Erzeugt ein zufälliges Passwort, das die Richtlinie garantiert erfüllt.
* Zeichen, die man leicht verwechselt (0/O, 1/I/l), bleiben draussen – solche
* Passwörter werden vom Admin abgetippt oder weitergegeben.
*/
export function randomPassword(policy) {
const p = policy || DEFAULT_POLICY;
const laenge = Math.max(p.min_length, 12);
const pflicht = [];
if (p.require_upper) pflicht.push(...ziehen(GROSS, 1));
if (p.require_lower) pflicht.push(...ziehen(KLEIN, 1));
if (p.require_digit) pflicht.push(...ziehen(ZIFFERN, 1));
if (p.require_special) pflicht.push(...ziehen(SONDER, 1));
const rest = GROSS + KLEIN + ZIFFERN + (p.require_special ? SONDER : "");
const zeichen = [...pflicht, ...ziehen(rest, Math.max(0, laenge - pflicht.length))];
// Mischen, damit die Pflichtzeichen nicht immer vorne stehen (Fisher-Yates
// mit kryptografischer Quelle).
const misch = new Uint32Array(zeichen.length);
crypto.getRandomValues(misch);
for (let i = zeichen.length - 1; i > 0; i--) {
const j = misch[i] % (i + 1);
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
}
return zeichen.join("");
}