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
+127
View File
@@ -0,0 +1,127 @@
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "./auth";
import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import ChangePassword from "./pages/ChangePassword";
import Dashboard from "./pages/Dashboard";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
function NavItem({ to, icon, label, end }) {
return (
<NavLink to={to} end={end} className={navClass}>
<Icon name={icon} size={17} />
<span className="label">{label}</span>
</NavLink>
);
}
function Sidebar() {
const { user, isAdmin, logout } = useAuth();
const { t } = useTranslation();
const navigate = useNavigate();
async function handleLogout() {
await logout();
navigate("/login");
}
return (
<aside className="sidebar">
<div className="sidebar-brand">
<BrandMark />
</div>
<nav className="sidebar-nav">
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
<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")} />
</>
)}
</nav>
<div className="sidebar-foot">
<div className="sidebar-user">
<span className="name">{user?.display_name || user?.username}</span>
<span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span>
</div>
<button className="btn-icon" onClick={handleLogout} title={t("common.logout")}>
<Icon name="logout" size={18} />
</button>
</div>
</aside>
);
}
/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */
function StartFehler({ text, erneut }) {
const { t } = useTranslation();
return (
<div className="center" style={{ padding: 60 }}>
<div className="alert error" style={{ maxWidth: 520 }}>
<Icon name="alert" size={16} />
<div>
<div>{text}</div>
<div className="muted small" style={{ marginTop: 4 }}>
{t("server.sessionKept")}
</div>
</div>
</div>
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
{t("common.retry")}
</button>
</div>
);
}
function Protected({ children, adminOnly = false, wide = false }) {
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
const { pathname } = useLocation();
const { t } = useTranslation();
if (loading) return <div className="center muted" style={{ padding: 60 }}>{t("common.loading")}</div>;
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
if (!user) return <Navigate to="/login" replace />;
// Vom Admin gesetztes Temporär-Passwort: erst ändern, dann weiter.
if (user.must_change_password && pathname !== "/passwort-aendern") {
return <Navigate to="/passwort-aendern" replace />;
}
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
return (
<div className="app-shell">
<Sidebar />
<div className="main">
<div className={wide ? "content content--wide" : "content"}>
<ErrorBoundary key={pathname} bereich="Diese Seite">
{children}
</ErrorBoundary>
</div>
</div>
</div>
);
}
export default function App() {
const { user, loading } = useAuth();
return (
<Routes>
<Route
path="/login"
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route path="/passwort-aendern" element={<ChangePassword />} />
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}