feat: Konto-Sicherheit gebündelt + Einstellungen aufgeräumt

- Neue Seite „Mein Konto" (/konto, für alle Nutzer): eigenes Passwort ändern
  (inkl. Richtlinien-Anzeige) + Zwei-Faktor verwalten; erreichbar über den
  Nutzer-Chip in der Seitenleisten-Fußzeile
- Einstellungen: 2FA-Karte entfernt (liegt jetzt bei „Mein Konto"), Karten in
  ein mehrspaltiges Raster gruppiert
- Menüpunkt „Einstellungen" unter die Überschrift „System" verschoben (für alle
  Nutzer sichtbar; „Benutzer" bleibt Admin-only)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-20 17:27:40 +02:00
co-authored by Claude Opus 4.8
parent 39bf7ea11c
commit 794a6aeb91
8 changed files with 234 additions and 102 deletions
+6 -8
View File
@@ -15,6 +15,7 @@ import Import from "./pages/Import";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
import TwoFactor from "./pages/TwoFactor";
import Account from "./pages/Account";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
@@ -49,19 +50,15 @@ function Sidebar() {
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
<NavItem to="/import" icon="download" label={t("nav.import")} />
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && (
<>
<div className="nav-section">{t("nav.system")}</div>
<NavItem to="/users" icon="users" label={t("nav.users")} />
</>
)}
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && <NavItem to="/users" icon="users" label={t("nav.users")} />}
</nav>
<div className="sidebar-foot">
<div className="sidebar-user">
<NavLink to="/konto" className="sidebar-user" title={t("account.title")}>
<span className="name">{user?.display_name || user?.username}</span>
<span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span>
</div>
</NavLink>
<button className="btn-icon" onClick={handleLogout} title={t("common.logout")}>
<Icon name="logout" size={18} />
</button>
@@ -140,6 +137,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="/konto" element={<Protected><Account /></Protected>} />
<Route path="/zwei-faktor" element={<Protected><TwoFactor /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
+24
View File
@@ -284,6 +284,30 @@
min-width: 0;
}
/* Einstellungen & Mein Konto: Karten in ein mehrspaltiges Raster (nutzt die
Bildschirmbreite besser als das bisherige Untereinander). */
.settings-grid {
display: grid;
gap: var(--sp-4);
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: start;
}
/* Nutzer-Chip in der Seitenleisten-Fußzeile ist ein Link auf „Mein Konto". */
a.sidebar-user {
text-decoration: none;
border-radius: var(--radius-sm);
padding: 2px 4px;
margin: -2px -4px;
transition: background .12s;
}
a.sidebar-user:hover {
background: var(--surface-2);
}
a.sidebar-user:hover .name {
color: var(--accent);
}
/* Budget: mehrere Monate nebeneinander. */
.budget-months {
display: grid;
+7
View File
@@ -64,8 +64,15 @@
"twoFactorReset": "Zwei-Faktor zurückgesetzt.",
"generatePassword": "Passwort erzeugen"
},
"account": {
"title": "Mein Konto",
"password": "Passwort ändern",
"twoFactor": "Zwei-Faktor-Authentifizierung",
"manage2fa": "2FA verwalten"
},
"settings": {
"title": "Einstellungen",
"general": "Darstellung & Sprache",
"baseCurrency": "Hauptwährung",
"baseCurrencyHint": "Währung für Budget-Summen und Dashboard-Gesamtwerte.",
"locale": "Sprache",
+7
View File
@@ -64,8 +64,15 @@
"twoFactorReset": "Two-factor reset.",
"generatePassword": "Generate password"
},
"account": {
"title": "My account",
"password": "Change password",
"twoFactor": "Two-factor authentication",
"manage2fa": "Manage 2FA"
},
"settings": {
"title": "Settings",
"general": "Appearance & language",
"baseCurrency": "Base currency",
"baseCurrencyHint": "Currency for budget totals and dashboard aggregates.",
"locale": "Language",
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../auth";
import { api, ApiError } from "../api";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import PasswordInput from "../components/PasswordInput";
import PasswordRules from "../components/PasswordRules";
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
/**
* „Mein Konto" – persönlicher Sicherheitsbereich jedes Benutzers: eigenes
* Passwort ändern und Zwei-Faktor verwalten (das eigentliche 2FA-Setup lebt
* unter /zwei-faktor). Fremde Nutzer verwaltet der Admin unter „Benutzer".
*/
export default function Account() {
const { aktualisieren } = useAuth();
const { t } = useTranslation();
const toast = useToast();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [policy, setPolicy] = useState(DEFAULT_POLICY);
const [tfa, setTfa] = useState(null);
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.getPolicy().then(setPolicy).catch(() => {});
api.twoFactorStatus().then(setTfa).catch(() => {});
}, []);
const regelnErfuellt = meetsPolicy(policy, next, t);
const bereit = regelnErfuellt && next === repeat && current.length > 0 && !busy;
async function onSubmit(e) {
e.preventDefault();
setFehler(null);
if (next !== repeat) { setFehler(t("changepw.mismatch")); return; }
setBusy(true);
try {
const updated = await api.changePassword(current, next);
aktualisieren(updated);
setCurrent(""); setNext(""); setRepeat("");
toast(t("changepw.saved"));
} catch (err) {
setFehler(err instanceof ApiError ? err.message : t("server.unreachable"));
} finally {
setBusy(false);
}
}
return (
<div>
<h1>{t("account.title")}</h1>
<div className="settings-grid">
<form className="card" onSubmit={onSubmit}>
<h2 style={{ marginTop: 0 }}>{t("account.password")}</h2>
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} /><span>{fehler}</span>
</div>
)}
<label className="field">
<span>{t("changepw.current")}</span>
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" required />
</label>
<label className="field">
<span>{t("changepw.new")}</span>
<PasswordInput value={next} onChange={setNext} autoComplete="new-password" required />
</label>
<PasswordRules policy={policy} value={next} />
<label className="field" style={{ marginTop: "var(--sp-3)" }}>
<span>{t("changepw.repeat")}</span>
<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={!bereit} style={{ marginTop: "var(--sp-3)" }}>
{busy ? t("common.loading") : t("changepw.submit")}
</button>
</form>
<div className="card">
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
<Icon name="shield" size={18} /> {t("account.twoFactor")}
</h2>
<p className="muted small">{t("settings.securityHint")}</p>
<p>
<span className={`badge ${tfa?.enabled ? "ok" : ""}`}>
{tfa?.enabled ? t("twofactor.on") : t("twofactor.off")}
</span>
</p>
<Link className="btn" to="/zwei-faktor">
<Icon name="smartphone" size={15} /> {tfa?.enabled ? t("account.manage2fa") : t("settings.twoFactor")}
</Link>
</div>
</div>
</div>
);
}
+7 -15
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useAuth } from "../auth";
@@ -53,7 +52,7 @@ export default function Settings() {
<h1>{t("settings.title")}</h1>
{flagged.length > 0 && (
<div className="card" style={{ maxWidth: 520, marginBottom: "var(--sp-4)", borderColor: "var(--danger)" }}>
<div className="card" style={{ marginBottom: "var(--sp-4)", borderColor: "var(--danger)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="alert" size={18} /> {t("settings.keywordReview")}
</h2>
@@ -76,7 +75,9 @@ export default function Settings() {
</div>
)}
<div className="card" style={{ maxWidth: 520 }}>
<div className="settings-grid">
<div className="card">
<h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
<label>
<span>{t("settings.baseCurrency")}</span>
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
@@ -119,20 +120,10 @@ 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>
<div className="card">
<h2 style={{ marginTop: 0 }}>{t("settings.backup")}</h2>
<p className="muted small">{t("settings.backupHint")}</p>
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
<Icon name="download" size={15} /> {t("settings.exportBackup")}
@@ -172,5 +163,6 @@ export default function Settings() {
<RestoreCard />
</div>
</div>
);
}
+2 -2
View File
@@ -53,7 +53,7 @@ export default function PolicyCard() {
if (!policy) {
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<div className="card">
<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>}
@@ -62,7 +62,7 @@ export default function PolicyCard() {
}
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<div className="card">
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="lock" size={18} /> {t("policy.title")}
</h2>
+1 -1
View File
@@ -83,7 +83,7 @@ export default function RestoreCard() {
}
return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
<div className="card">
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="upload" size={18} /> {t("restore.title")}
</h2>