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:
Guido
2026-08-19 18:27:42 +02:00
co-authored by Claude Opus 4.8
commit 2c795a57c8
59 changed files with 6419 additions and 0 deletions
+195
View File
@@ -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>
);
}