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
+30
View File
@@ -0,0 +1,30 @@
import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG
// (aufsteigende Trendlinie) im Akzentton – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) {
return (
<div className="brand-mark">
<svg
width={size}
height={size}
viewBox="0 0 32 32"
role="img"
aria-label={APP_NAME}
focusable="false"
>
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
<path
d="M8 21 L14 15 L18 18 L24 10"
fill="none"
stroke="#ffffff"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="24" cy="10" r="2.1" fill="#ffffff" />
</svg>
{showName && <span className="brand-name">{APP_NAME}</span>}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Component } from "react";
import Icon from "./Icon";
/**
* Fängt Fehler beim Rendern ab.
*
* Ohne so eine Grenze hängt React den kompletten Baum aus, sobald irgendwo eine
* Ausnahme fliegt – die Seite wird schlagartig weiß und nur ein Neuladen hilft.
* Man sieht dann NICHT, was passiert ist, und kann es folglich auch nicht
* melden. Genau das war hier das Problem.
*
* Die Grenze zeigt deshalb die Meldung selbst an, nicht nur ein „Ups". Wer sie
* einmal sieht, kann sie weitergeben, und dann ist die Ursache in einer Minute
* gefunden statt in einer Stunde.
*
* `bereich` beschreibt, was gekapselt ist – für die Meldung.
*/
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { fehler: null, info: null };
}
static getDerivedStateFromError(fehler) {
return { fehler };
}
componentDidCatch(fehler, info) {
// React schreibt selbst schon in die Konsole; der Komponentenpfad hilft
// zusätzlich beim Einordnen.
console.error("Fehler beim Rendern:", fehler, info?.componentStack);
this.setState({ info });
}
render() {
const { fehler, info } = this.state;
if (!fehler) return this.props.children;
return (
<div style={{ padding: "var(--sp-4)" }}>
<div className="alert error" style={{ alignItems: "flex-start" }}>
<Icon name="alert" size={16} />
<div style={{ minWidth: 0 }}>
<div className="strong">
{this.props.bereich ? `${this.props.bereich} konnte nicht angezeigt werden.` : "Etwas ist schiefgelaufen."}
</div>
<div className="small" style={{ marginTop: 4, wordBreak: "break-word" }}>
{fehler.message || String(fehler)}
</div>
</div>
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
<button className="btn primary" onClick={() => this.setState({ fehler: null, info: null })}>
Nochmal versuchen
</button>
<button className="btn" onClick={() => window.location.reload()}>
Seite neu laden
</button>
</div>
{/* Aufgeklappt nur auf Wunsch: die Angaben sind für einen Fehlerbericht
gedacht, nicht für den Alltag. */}
<details style={{ marginTop: "var(--sp-3)" }}>
<summary className="muted small">Technische Angaben</summary>
<pre className="muted small" style={{ whiteSpace: "pre-wrap", overflowX: "auto" }}>
{fehler.stack || String(fehler)}
{info?.componentStack || ""}
</pre>
</details>
</div>
);
}
}
+178
View File
@@ -0,0 +1,178 @@
// Schlankes Inline-SVG-Icon-Set (Lucide-Stil). Bewusst KEINE Emojis.
// Verwendung: <Icon name="wallet" /> oder <Icon name="trash" size={16} />
// Weitere Kategorie-/Kontotyp-Icons kommen in späteren Phasen dazu.
const PATHS = {
// --- App-Rahmen ---------------------------------------------------------
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</>
),
users: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
settings: (
<>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</>
),
logout: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</>
),
// --- Rückmeldungen / Aktionen ------------------------------------------
alert: (
<>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4M12 17h.01" />
</>
),
check: <path d="M20 6 9 17l-5-5" />,
close: <path d="M18 6 6 18M6 6l12 12" />,
edit: (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
),
plus: <path d="M5 12h14M12 5v14" />,
trash: (
<>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</>
),
download: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M7 10l5 5 5-5M12 15V3" />
</>
),
"chevron-right": <path d="m9 18 6-6-6-6" />,
"refresh-ccw": (
<>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</>
),
// --- Auth / Passwort ----------------------------------------------------
key: (
<>
<circle cx="7.5" cy="15.5" r="5.5" />
<path d="m21 2-9.6 9.6" />
<path d="m15.5 7.5 3 3L22 7l-3-3" />
</>
),
eye: (
<>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</>
),
"eye-off": (
<>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</>
),
// --- Finance-Grundausstattung ------------------------------------------
wallet: (
<>
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4" />
<path d="M4 6v12c0 1.1.9 2 2 2h14v-4" />
<path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z" />
</>
),
landmark: (
<>
<line x1="3" x2="21" y1="22" y2="22" />
<line x1="6" x2="6" y1="18" y2="11" />
<line x1="10" x2="10" y1="18" y2="11" />
<line x1="14" x2="14" y1="18" y2="11" />
<line x1="18" x2="18" y1="18" y2="11" />
<polygon points="12 2 20 7 4 7" />
</>
),
list: (
<>
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" />
<line x1="3" x2="3.01" y1="12" y2="12" />
<line x1="3" x2="3.01" y1="18" y2="18" />
</>
),
"pie-chart": (
<>
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
<path d="M22 12A10 10 0 0 0 12 2v10z" />
</>
),
"arrow-left-right": (
<>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</>
),
coins: (
<>
<circle cx="8" cy="8" r="6" />
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
<path d="M7 6h1v4" />
<path d="m16.71 13.88.7.71-2.82 2.82" />
</>
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
const path = PATHS[name];
if (!path) return null;
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{path}
</svg>
);
}