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:
+6
-8
@@ -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")} />
|
||||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<div className="nav-section">{t("nav.system")}</div>
|
<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>
|
</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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,7 +75,9 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: 520 }}>
|
<div className="settings-grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
|
||||||
<label>
|
<label>
|
||||||
<span>{t("settings.baseCurrency")}</span>
|
<span>{t("settings.baseCurrency")}</span>
|
||||||
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
|
<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>
|
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
||||||
</div>
|
</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 />}
|
{isAdmin && <PolicyCard />}
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
<div className="card">
|
||||||
<h2>{t("settings.backup")}</h2>
|
<h2 style={{ marginTop: 0 }}>{t("settings.backup")}</h2>
|
||||||
<p className="muted small">{t("settings.backupHint")}</p>
|
<p className="muted small">{t("settings.backupHint")}</p>
|
||||||
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
|
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
|
||||||
<Icon name="download" size={15} /> {t("settings.exportBackup")}
|
<Icon name="download" size={15} /> {t("settings.exportBackup")}
|
||||||
@@ -172,5 +163,6 @@ export default function Settings() {
|
|||||||
|
|
||||||
<RestoreCard />
|
<RestoreCard />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user