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:
+127
@@ -0,0 +1,127 @@
|
||||
// Zentraler API-Client. Alles läuft über /api (im Dev leitet der Vite-Proxy an
|
||||
// das Backend, in Produktion liefert derselbe FastAPI-Prozess API und SPA aus).
|
||||
// Die Anmeldung liegt in einem HttpOnly-Cookie – deshalb IMMER credentials:"include"
|
||||
// und kein Token im JavaScript.
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
// Wird bei einer abgelaufenen/ungültigen Sitzung (HTTP 401) gerufen, damit die
|
||||
// Oberfläche nicht „angemeldet" bleibt, während jede Abfrage scheitert.
|
||||
let beiAbmeldung = null;
|
||||
export function setUnauthorizedHandler(fn) {
|
||||
beiAbmeldung = fn;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", body, form, formData, skipAuthHandler = false } = {}) {
|
||||
const headers = {};
|
||||
let payload;
|
||||
if (formData) {
|
||||
payload = formData; // Content-Type setzt der Browser selbst
|
||||
} else if (form) {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
payload = new URLSearchParams(form).toString();
|
||||
} else if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: payload,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// Abgelaufene Sitzung zentral behandeln – aber nicht beim Login selbst und
|
||||
// nicht bei der stillen Startprüfung (dort ist 401 = „noch nicht angemeldet").
|
||||
if (resp.status === 401 && path !== "/auth/login" && !skipAuthHandler) {
|
||||
beiAbmeldung?.();
|
||||
}
|
||||
|
||||
if (resp.status === 204) return null;
|
||||
|
||||
let data = null;
|
||||
const text = await resp.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let detail;
|
||||
if (data && data.detail) {
|
||||
detail = Array.isArray(data.detail) ? data.detail.map((d) => d.msg).join(", ") : data.detail;
|
||||
} else if (resp.status === 502 || resp.status === 503 || resp.status === 504) {
|
||||
detail = `Server nicht erreichbar (${resp.status}).`;
|
||||
} else if (typeof data === "string" && data.trim().startsWith("<")) {
|
||||
detail = `Server-Fehler (${resp.status}).`;
|
||||
} else if (typeof data === "string" && data) {
|
||||
detail = data;
|
||||
} else {
|
||||
detail = `Fehler ${resp.status}`;
|
||||
}
|
||||
throw new ApiError(detail, resp.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Ortszeit als YYYY-MM-DD_HHMM für Dateinamen. */
|
||||
function zeitstempel() {
|
||||
const jetzt = new Date();
|
||||
const zwei = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${jetzt.getFullYear()}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}` +
|
||||
`_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten. */
|
||||
export async function downloadFile(path, filename) {
|
||||
const resp = await fetch(`${API_BASE}${path}`, { credentials: "include" });
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export { zeitstempel };
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (username, password) =>
|
||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||
logout: () => request("/auth/logout", { method: "POST" }),
|
||||
me: (opts) => request("/auth/me", { skipAuthHandler: opts?.silent }),
|
||||
changePassword: (current_password, new_password) =>
|
||||
request("/auth/change-password", { method: "POST", body: { current_password, new_password } }),
|
||||
version: () => request("/version"),
|
||||
|
||||
// Benutzer (Admin)
|
||||
listUsers: () => request("/users"),
|
||||
createUser: (body) => request("/users", { method: "POST", body }),
|
||||
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Einstellungen (je Benutzer)
|
||||
listSettings: () => request("/settings"),
|
||||
setSetting: (key, value) =>
|
||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||
};
|
||||
Reference in New Issue
Block a user