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 Users from "./pages/Users";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import TwoFactor from "./pages/TwoFactor"; import TwoFactor from "./pages/TwoFactor";
import Account from "./pages/Account";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); 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="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} /> <NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
<NavItem to="/import" icon="download" label={t("nav.import")} /> <NavItem to="/import" icon="download" label={t("nav.import")} />
<div className="nav-section">{t("nav.system")}</div>
<NavItem to="/settings" icon="settings" label={t("nav.settings")} /> <NavItem to="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && ( {isAdmin && <NavItem to="/users" icon="users" label={t("nav.users")} />}
<>
<div className="nav-section">{t("nav.system")}</div>
<NavItem to="/users" icon="users" label={t("nav.users")} />
</>
)}
</nav> </nav>
<div className="sidebar-foot"> <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="name">{user?.display_name || user?.username}</span>
<span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span> <span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span>
</div> </NavLink>
<button className="btn-icon" onClick={handleLogout} title={t("common.logout")}> <button className="btn-icon" onClick={handleLogout} title={t("common.logout")}>
<Icon name="logout" size={18} /> <Icon name="logout" size={18} />
</button> </button>
@@ -140,6 +137,7 @@ export default function App() {
<Route path="/budget" element={<Protected wide><Budget /></Protected>} /> <Route path="/budget" element={<Protected wide><Budget /></Protected>} />
<Route path="/import" element={<Protected wide><Import /></Protected>} /> <Route path="/import" element={<Protected wide><Import /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></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="/zwei-faktor" element={<Protected><TwoFactor /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+24
View File
@@ -284,6 +284,30 @@
min-width: 0; 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: mehrere Monate nebeneinander. */
.budget-months { .budget-months {
display: grid; display: grid;
+7
View File
@@ -64,8 +64,15 @@
"twoFactorReset": "Zwei-Faktor zurückgesetzt.", "twoFactorReset": "Zwei-Faktor zurückgesetzt.",
"generatePassword": "Passwort erzeugen" "generatePassword": "Passwort erzeugen"
}, },
"account": {
"title": "Mein Konto",
"password": "Passwort ändern",
"twoFactor": "Zwei-Faktor-Authentifizierung",
"manage2fa": "2FA verwalten"
},
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"general": "Darstellung & Sprache",
"baseCurrency": "Hauptwährung", "baseCurrency": "Hauptwährung",
"baseCurrencyHint": "Währung für Budget-Summen und Dashboard-Gesamtwerte.", "baseCurrencyHint": "Währung für Budget-Summen und Dashboard-Gesamtwerte.",
"locale": "Sprache", "locale": "Sprache",
+7
View File
@@ -64,8 +64,15 @@
"twoFactorReset": "Two-factor reset.", "twoFactorReset": "Two-factor reset.",
"generatePassword": "Generate password" "generatePassword": "Generate password"
}, },
"account": {
"title": "My account",
"password": "Change password",
"twoFactor": "Two-factor authentication",
"manage2fa": "Manage 2FA"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"general": "Appearance & language",
"baseCurrency": "Base currency", "baseCurrency": "Base currency",
"baseCurrencyHint": "Currency for budget totals and dashboard aggregates.", "baseCurrencyHint": "Currency for budget totals and dashboard aggregates.",
"locale": "Language", "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>
);
}
+83 -91
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
@@ -53,7 +52,7 @@ export default function Settings() {
<h1>{t("settings.title")}</h1> <h1>{t("settings.title")}</h1>
{flagged.length > 0 && ( {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 }}> <h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="alert" size={18} /> {t("settings.keywordReview")} <Icon name="alert" size={18} /> {t("settings.keywordReview")}
</h2> </h2>
@@ -76,101 +75,94 @@ export default function Settings() {
</div> </div>
)} )}
<div className="card" style={{ maxWidth: 520 }}> <div className="settings-grid">
<label> <div className="card">
<span>{t("settings.baseCurrency")}</span> <h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}> <label>
<option value="EUR">Euro (EUR)</option> <span>{t("settings.baseCurrency")}</span>
<option value="CHF">Schweizer Franken (CHF)</option> <select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
</select> <option value="EUR">Euro (EUR)</option>
</label> <option value="CHF">Schweizer Franken (CHF)</option>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p> </select>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p>
<label> <label>
<span>{t("settings.locale")}</span> <span>{t("settings.locale")}</span>
<select value={s.locale} onChange={(e) => set("locale", e.target.value)}> <select value={s.locale} onChange={(e) => set("locale", e.target.value)}>
<option value="de">{t("settings.languageDe")}</option> <option value="de">{t("settings.languageDe")}</option>
<option value="en">{t("settings.languageEn")}</option> <option value="en">{t("settings.languageEn")}</option>
</select> </select>
</label> </label>
<label> <label>
<span>{t("settings.theme")}</span> <span>{t("settings.theme")}</span>
<select value={s.theme} onChange={(e) => set("theme", e.target.value)}> <select value={s.theme} onChange={(e) => set("theme", e.target.value)}>
<option value="system">{t("settings.themeSystem")}</option> <option value="system">{t("settings.themeSystem")}</option>
<option value="light">{t("settings.themeLight")}</option> <option value="light">{t("settings.themeLight")}</option>
<option value="dark">{t("settings.themeDark")}</option> <option value="dark">{t("settings.themeDark")}</option>
</select> </select>
</label> </label>
<label> <label>
<span>{t("settings.budgetStartDay")}</span> <span>{t("settings.budgetStartDay")}</span>
<input <input
type="number" type="number"
min="1" min="1"
max="31" max="31"
value={s.budget_start_day} value={s.budget_start_day}
onChange={(e) => { onChange={(e) => {
const day = Math.max(1, Math.min(31, Number(e.target.value) || 1)); const day = Math.max(1, Math.min(31, Number(e.target.value) || 1));
set("budget_start_day", String(day)); set("budget_start_day", String(day));
}} }}
/> />
</label> </label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p> <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.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")}
</button>
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
</button>
<button className="btn" onClick={() => dl(api.exportAllJson)}>
<Icon name="download" size={15} /> {t("settings.exportAll")}
</button>
</div> </div>
{isAdmin && ( {isAdmin && <PolicyCard />}
<>
<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 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")}
</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")}
</button>
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
</button>
<button className="btn" onClick={() => dl(api.exportAllJson)}>
<Icon name="download" size={15} /> {t("settings.exportAll")}
</button>
</div>
{isAdmin && (
<>
<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>
</div> </div>
); );
} }
+2 -2
View File
@@ -53,7 +53,7 @@ export default function PolicyCard() {
if (!policy) { if (!policy) {
return ( return (
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}> <div className="card">
<h2 style={{ marginTop: 0 }}>{t("policy.title")}</h2> <h2 style={{ marginTop: 0 }}>{t("policy.title")}</h2>
{fehler ? <div className="alert error"><Icon name="alert" size={16} /><span>{fehler}</span></div> {fehler ? <div className="alert error"><Icon name="alert" size={16} /><span>{fehler}</span></div>
: <p className="muted small">{t("common.loading")}</p>} : <p className="muted small">{t("common.loading")}</p>}
@@ -62,7 +62,7 @@ export default function PolicyCard() {
} }
return ( 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 }}> <h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="lock" size={18} /> {t("policy.title")} <Icon name="lock" size={18} /> {t("policy.title")}
</h2> </h2>
+1 -1
View File
@@ -83,7 +83,7 @@ export default function RestoreCard() {
} }
return ( 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 }}> <h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="upload" size={18} /> {t("restore.title")} <Icon name="upload" size={18} /> {t("restore.title")}
</h2> </h2>