feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)
Grundgerüst für Certain Finance: - Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode), argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin, Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer. - Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus, inkl. SPA-Deeplink-Fallback. - Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN, Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter, Benutzer- und Einstellungsseiten. - Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../auth";
|
||||
import { api, ApiError } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function ChangePassword() {
|
||||
const { user, aktualisieren } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
const forced = user.must_change_password;
|
||||
|
||||
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);
|
||||
toast(t("changepw.saved"));
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setFehler(err instanceof ApiError ? err.message : t("server.unreachable"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<BrandMark size={40} />
|
||||
<h1 style={{ textAlign: "center", marginTop: 0 }}>{t("changepw.title")}</h1>
|
||||
{forced && (
|
||||
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="key" size={16} />
|
||||
<span>{t("changepw.forcedHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && (
|
||||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>{t("changepw.current")}</span>
|
||||
<input type="password" autoComplete="current-password" value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("changepw.new")}</span>
|
||||
<input type="password" autoComplete="new-password" value={next}
|
||||
onChange={(e) => setNext(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("changepw.repeat")}</span>
|
||||
<input type="password" autoComplete="new-password" value={repeat}
|
||||
onChange={(e) => setRepeat(e.target.value)} required />
|
||||
</label>
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}
|
||||
style={{ width: "100%", marginTop: "var(--sp-2)" }}>
|
||||
{busy ? t("common.loading") : t("changepw.submit")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
// Platzhalter: das konfigurierbare Karten-Dashboard wird in Phase 3 aufgebaut.
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("dashboard.title")}</h1>
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<Icon name="pie-chart" size={40} />
|
||||
<p>{t("dashboard.empty")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../auth";
|
||||
import { ApiError } from "../api";
|
||||
import BrandMark from "../components/BrandMark";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Login() {
|
||||
const { login, 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);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) setFehler(t("login.error"));
|
||||
else setFehler(err.message || t("server.unreachable"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<BrandMark size={40} />
|
||||
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>{t("login.subtitle")}</p>
|
||||
|
||||
{abgelaufen && (
|
||||
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t("login.username")}</span>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input autoFocus autoComplete="username" value={username}
|
||||
onChange={(e) => setUsername(e.target.value)} required />
|
||||
</label>
|
||||
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}
|
||||
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
|
||||
{busy ? t("common.loading") : t("login.submit")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../settings";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const s = useSettings();
|
||||
const toast = useToast();
|
||||
|
||||
async function set(key, value) {
|
||||
try {
|
||||
await s.update(key, value);
|
||||
toast(t("settings.saved"));
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("settings.title")}</h1>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520 }}>
|
||||
<label>
|
||||
<span>{t("settings.baseCurrency")}</span>
|
||||
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
|
||||
<option value="EUR">Euro (EUR)</option>
|
||||
<option value="CHF">Schweizer Franken (CHF)</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p>
|
||||
|
||||
<label>
|
||||
<span>{t("settings.locale")}</span>
|
||||
<select value={s.locale} onChange={(e) => set("locale", e.target.value)}>
|
||||
<option value="de">{t("settings.languageDe")}</option>
|
||||
<option value="en">{t("settings.languageEn")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{t("settings.theme")}</span>
|
||||
<select value={s.theme} onChange={(e) => set("theme", e.target.value)}>
|
||||
<option value="system">{t("settings.themeSystem")}</option>
|
||||
<option value="light">{t("settings.themeLight")}</option>
|
||||
<option value="dark">{t("settings.themeDark")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{t("settings.budgetStartDay")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={s.budget_start_day}
|
||||
onChange={(e) => {
|
||||
const day = Math.max(1, Math.min(31, Number(e.target.value) || 1));
|
||||
set("budget_start_day", String(day));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { t } = useTranslation();
|
||||
const { user: self } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
setUsers(await api.listUsers());
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
}, []);
|
||||
|
||||
async function anlegen(e) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createUser({
|
||||
username: form.username.trim(),
|
||||
display_name: form.display_name.trim() || null,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
});
|
||||
toast(t("users.created"));
|
||||
setForm({ username: "", display_name: "", password: "", role: "user" });
|
||||
laden();
|
||||
} catch (err) {
|
||||
setFehler(err instanceof ApiError ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function passwortZuruecksetzen(u) {
|
||||
const neu = await confirm({
|
||||
title: t("users.resetPassword"),
|
||||
message: u.username,
|
||||
prompt: true,
|
||||
defaultValue: randomPassword(),
|
||||
confirmLabel: t("common.save"),
|
||||
});
|
||||
if (!neu) return;
|
||||
try {
|
||||
await api.updateUser(u.id, { password: neu });
|
||||
toast(t("settings.saved"));
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function aktivUmschalten(u) {
|
||||
try {
|
||||
await api.updateUser(u.id, { is_active: !u.is_active });
|
||||
laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(u) {
|
||||
if (!(await confirm({ title: t("common.delete"), message: t("users.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteUser(u.id);
|
||||
laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("users.title")}</h1>
|
||||
|
||||
{fehler && (
|
||||
<div className="alert error">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{fehler}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: "var(--sp-4)" }}>
|
||||
<h2>{t("users.new")}</h2>
|
||||
<form onSubmit={anlegen}>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span>{t("users.username")}</span>
|
||||
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("users.displayName")}</span>
|
||||
<input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<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() })}>
|
||||
<Icon name="refresh-ccw" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ minWidth: 160 }}>
|
||||
<span>{t("users.role")}</span>
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="user">{t("common.user")}</option>
|
||||
<option value="admin">{t("common.admin")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">{t("users.passwordHint")}</p>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{busy ? t("common.loading") : t("common.create")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("users.username")}</th>
|
||||
<th>{t("users.role")}</th>
|
||||
<th>{t("users.status")}</th>
|
||||
<th style={{ textAlign: "right" }}>{t("common.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<strong>{u.display_name || u.username}</strong>
|
||||
{u.display_name && <span className="muted small"> · {u.username}</span>}
|
||||
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.is_active ? "ok" : ""}`}>
|
||||
{u.is_active ? t("users.active") : t("users.inactive")}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
|
||||
<Icon name="key" 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)}>
|
||||
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
|
||||
</button>
|
||||
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user