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 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+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" }),
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { api, ApiError, setUnauthorizedHandler } from "./api";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Sitzung war da, ist aber abgelaufen – der Login-Bildschirm sagt warum.
|
||||
const [abgelaufen, setAbgelaufen] = useState(false);
|
||||
// Start fehlgeschlagen, ABER nicht wegen der Anmeldung (Server/Netz weg).
|
||||
const [startFehler, setStartFehler] = useState(null);
|
||||
|
||||
async function pruefen() {
|
||||
setStartFehler(null);
|
||||
try {
|
||||
// Still prüfen: ein 401 hier heißt „nicht angemeldet", nicht „abgelaufen".
|
||||
const me = await api.me({ silent: true });
|
||||
setUser(me);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
setUser(null);
|
||||
} else if (err instanceof ApiError) {
|
||||
setStartFehler(err.message);
|
||||
} else {
|
||||
setStartFehler("Server nicht erreichbar.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
pruefen();
|
||||
}, []);
|
||||
|
||||
// Läuft die Sitzung während der Nutzung ab, scheitert ab da JEDE Abfrage.
|
||||
// Dieser Griff meldet dann sauber ab, statt leere Seiten zu zeigen.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
setUser(null);
|
||||
setAbgelaufen(true);
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
async function login(username, password) {
|
||||
const me = await api.login(username, password);
|
||||
setUser(me);
|
||||
setAbgelaufen(false);
|
||||
setStartFehler(null);
|
||||
return me;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* Auch wenn der Server-Aufruf scheitert: lokal abmelden. */
|
||||
}
|
||||
setUser(null);
|
||||
setAbgelaufen(false);
|
||||
}
|
||||
|
||||
async function erneutVersuchen() {
|
||||
setLoading(true);
|
||||
await pruefen();
|
||||
}
|
||||
|
||||
// Nach einer Passwortänderung o. Ä. den Benutzer aktualisieren.
|
||||
function aktualisieren(next) {
|
||||
setUser(next);
|
||||
}
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAdmin,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
aktualisieren,
|
||||
abgelaufen,
|
||||
startFehler,
|
||||
erneutVersuchen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Zentraler Anzeigename der Anwendung (Frontend-Seite).
|
||||
// Dies ist – neben backend/app/branding.py – die EINZIGE Stelle mit dem
|
||||
// Produktnamen. Umbenennen = hier (und dort) eine Konstante ändern.
|
||||
export const APP_NAME = "Certain Finance";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import Icon from "./components/Icon";
|
||||
|
||||
/**
|
||||
* Rückfragen innerhalb der Anwendung statt über den Browser.
|
||||
*
|
||||
* `window.confirm` reißt den Benutzer aus der Oberfläche, zeigt den Hostnamen
|
||||
* und lässt sich weder gestalten noch beschriften. Der Dialog hier sieht aus wie
|
||||
* der Rest der Anwendung, kann eine Aktion benennen ("Löschen" statt "OK") und
|
||||
* gefährliche Schritte rot hervorheben.
|
||||
*
|
||||
* Aufruf bleibt so einfach wie vorher:
|
||||
*
|
||||
* const confirm = useConfirm();
|
||||
* if (!(await confirm({ title: "Gruppe löschen?", message: "…" }))) return;
|
||||
*
|
||||
* Mit `prompt: true` wird daraus eine Eingabe; aufgeloest wird dann der
|
||||
* eingetippte Text (oder `null` beim Abbrechen). Auch dafuer gilt der Grund von
|
||||
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
|
||||
*
|
||||
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
|
||||
*/
|
||||
const ConfirmContext = createContext(async () => false);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [frage, setFrage] = useState(null);
|
||||
const [eingabe, setEingabe] = useState("");
|
||||
// Die Zusage des offenen Dialogs, damit `await confirm(...)` funktioniert.
|
||||
const antwort = useRef(null);
|
||||
|
||||
const confirm = useCallback((optionen) => {
|
||||
const gestellt = typeof optionen === "string" ? { message: optionen } : optionen;
|
||||
setFrage(gestellt);
|
||||
setEingabe(gestellt.defaultValue ?? "");
|
||||
return new Promise((resolve) => {
|
||||
antwort.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
function schliessen(ergebnis) {
|
||||
setFrage(null);
|
||||
antwort.current?.(ergebnis);
|
||||
antwort.current = null;
|
||||
}
|
||||
|
||||
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
|
||||
function bestaetigen() {
|
||||
if (!frage?.prompt) { schliessen(true); return; }
|
||||
const text = eingabe.trim();
|
||||
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
|
||||
schliessen(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{frage && (
|
||||
<div className="modal-backdrop"
|
||||
onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
|
||||
<div className="modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-head">
|
||||
<Icon name={frage.prompt ? "edit" : "alert"} size={18}
|
||||
className={frage.danger ? "icon-danger" : ""} />
|
||||
<h2>{frage.title || "Bitte bestätigen"}</h2>
|
||||
</div>
|
||||
{frage.message && <p className="modal-body">{frage.message}</p>}
|
||||
{frage.prompt && (
|
||||
<div className="modal-body">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") bestaetigen();
|
||||
if (e.key === "Escape") schliessen(null);
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
<div className="btn-pair">
|
||||
<button type="button" className="btn" autoFocus={!frage.prompt}
|
||||
onClick={() => schliessen(frage.prompt ? null : false)}>
|
||||
{frage.cancelLabel || "Abbrechen"}
|
||||
</button>
|
||||
<button type="button"
|
||||
className={frage.danger ? "btn danger-solid" : "btn primary"}
|
||||
disabled={frage.prompt && !eingabe.trim()}
|
||||
onClick={bestaetigen}>
|
||||
{frage.confirmLabel || "Fortfahren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
return useContext(ConfirmContext);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Eigene Stil-Ergänzungen für Certain Finance – bewusst getrennt von der aus
|
||||
der Vorlage übernommenen styles.css, damit Basis und Ergänzung sauber bleiben. */
|
||||
|
||||
.brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.brand-name {
|
||||
font-weight: 650;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Beträge: Vorzeichenfarbe. Grün = Einnahme/im Budget, Rot = Ausgabe/über Budget. */
|
||||
.amount.pos { color: var(--ok); }
|
||||
.amount.neg { color: var(--danger); }
|
||||
.amount.muted { color: var(--muted); }
|
||||
|
||||
/* Umgerechneter Zweitbetrag (Originalwährung -> Hauptwährung) klein und gedämpft. */
|
||||
.amount-converted {
|
||||
color: var(--muted);
|
||||
font-size: 0.82em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Schlichtes zentriertes Layout für Login / Passwort ändern. */
|
||||
.auth-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg);
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
.auth-card .brand-mark {
|
||||
justify-content: center;
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
/* Eingabefeld mit angehängtem Icon-Knopf (z. B. Passwort ein-/ausblenden). */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.input-with-button > input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Leerer Zustand (z. B. Dashboard ohne Karten). */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
}
|
||||
.empty-state .icon {
|
||||
opacity: 0.5;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Formatierung von Beträgen, Zahlen und Daten – währungs-/gebietsspezifisch.
|
||||
// EUR im deutschen Format (1.234,56 €), CHF im Schweizer Format (CHF 1'234.56).
|
||||
// Datum als TT.MM.JJJJ. Beträge liegen intern als Ganzzahl in Cent/Rappen vor.
|
||||
|
||||
export function moneyLocale(currency) {
|
||||
return currency === "CHF" ? "de-CH" : "de-DE";
|
||||
}
|
||||
|
||||
/** Formatiert einen Cent-Betrag als Währungstext (z. B. 123456 -> "1.234,56 €"). */
|
||||
export function formatMoney(cents, currency = "EUR") {
|
||||
const value = (Number(cents) || 0) / 100;
|
||||
return new Intl.NumberFormat(moneyLocale(currency), {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Formatiert einen Cent-Betrag ohne Währungssymbol (für Eingaben/Tabellen). */
|
||||
export function formatAmount(cents, currency = "EUR") {
|
||||
const value = (Number(cents) || 0) / 100;
|
||||
return new Intl.NumberFormat(moneyLocale(currency), {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** ISO-Datum (YYYY-MM-DD oder ISO-Zeitstempel) als TT.MM.JJJJ. */
|
||||
export function formatDate(iso, locale = "de") {
|
||||
if (!iso) return "";
|
||||
const d = new Date(String(iso).length <= 10 ? `${iso}T00:00:00` : iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE").format(d);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Mehrsprachigkeit (Deutsch Standard, Englisch optional) via react-i18next.
|
||||
// Die aktive Sprache wird nach dem Login aus der Benutzer-Einstellung "locale"
|
||||
// gesetzt (siehe settings.jsx); vor dem Login gilt Deutsch.
|
||||
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import de from "./locales/de.json";
|
||||
import en from "./locales/en.json";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: "de",
|
||||
fallbackLng: "de",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"create": "Anlegen",
|
||||
"edit": "Bearbeiten",
|
||||
"loading": "Lädt…",
|
||||
"logout": "Abmelden",
|
||||
"admin": "Administrator",
|
||||
"user": "Benutzer",
|
||||
"close": "Schließen",
|
||||
"retry": "Erneut versuchen",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Übersicht",
|
||||
"accounts": "Konten",
|
||||
"categories": "Kategorien",
|
||||
"transactions": "Buchungen",
|
||||
"budget": "Budget",
|
||||
"import": "Import",
|
||||
"system": "System",
|
||||
"users": "Benutzer",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Bitte anmelden",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"submit": "Anmelden",
|
||||
"error": "Benutzername oder Passwort falsch",
|
||||
"expired": "Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
|
||||
},
|
||||
"changepw": {
|
||||
"title": "Passwort ändern",
|
||||
"forcedHint": "Bitte lege ein eigenes Passwort fest, bevor du fortfährst.",
|
||||
"current": "Aktuelles Passwort",
|
||||
"new": "Neues Passwort",
|
||||
"repeat": "Neues Passwort wiederholen",
|
||||
"submit": "Passwort speichern",
|
||||
"mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"saved": "Passwort geändert."
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzer",
|
||||
"new": "Neuer Benutzer",
|
||||
"username": "Benutzername",
|
||||
"displayName": "Anzeigename",
|
||||
"password": "Temporär-Passwort",
|
||||
"passwordHint": "Der Benutzer wird beim ersten Login zum Ändern aufgefordert.",
|
||||
"role": "Rolle",
|
||||
"status": "Status",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Deaktiviert",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"deleteConfirm": "Diesen Benutzer wirklich löschen?",
|
||||
"created": "Benutzer angelegt.",
|
||||
"you": "(du)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"baseCurrency": "Hauptwährung",
|
||||
"baseCurrencyHint": "Währung für Budget-Summen und Dashboard-Gesamtwerte.",
|
||||
"locale": "Sprache",
|
||||
"theme": "Darstellung",
|
||||
"themeSystem": "Automatisch (System)",
|
||||
"themeLight": "Hell",
|
||||
"themeDark": "Dunkel",
|
||||
"budgetStartDay": "Budget-Monat beginnt am",
|
||||
"budgetStartDayHint": "Tag, an dem dein Budget-Monat startet (z. B. 25.).",
|
||||
"dateFormat": "Datumsformat",
|
||||
"saved": "Gespeichert.",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "Englisch"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"empty": "Noch keine Karten. Das Dashboard wird in einer späteren Phase ausgebaut."
|
||||
},
|
||||
"server": {
|
||||
"unreachable": "Server nicht erreichbar.",
|
||||
"sessionKept": "Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet, geht es weiter."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"loading": "Loading…",
|
||||
"logout": "Log out",
|
||||
"admin": "Administrator",
|
||||
"user": "User",
|
||||
"close": "Close",
|
||||
"retry": "Try again",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"accounts": "Accounts",
|
||||
"categories": "Categories",
|
||||
"transactions": "Transactions",
|
||||
"budget": "Budget",
|
||||
"import": "Import",
|
||||
"system": "System",
|
||||
"users": "Users",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Please sign in",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"error": "Wrong username or password",
|
||||
"expired": "Your session has expired. Please sign in again."
|
||||
},
|
||||
"changepw": {
|
||||
"title": "Change password",
|
||||
"forcedHint": "Please set your own password before continuing.",
|
||||
"current": "Current password",
|
||||
"new": "New password",
|
||||
"repeat": "Repeat new password",
|
||||
"submit": "Save password",
|
||||
"mismatch": "The new passwords do not match.",
|
||||
"saved": "Password changed."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"new": "New user",
|
||||
"username": "Username",
|
||||
"displayName": "Display name",
|
||||
"password": "Temporary password",
|
||||
"passwordHint": "The user will be asked to change it on first login.",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Disabled",
|
||||
"resetPassword": "Reset password",
|
||||
"deleteConfirm": "Really delete this user?",
|
||||
"created": "User created.",
|
||||
"you": "(you)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"baseCurrency": "Base currency",
|
||||
"baseCurrencyHint": "Currency for budget totals and dashboard aggregates.",
|
||||
"locale": "Language",
|
||||
"theme": "Appearance",
|
||||
"themeSystem": "Automatic (system)",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"budgetStartDay": "Budget month starts on",
|
||||
"budgetStartDayHint": "Day your budget month starts (e.g. the 25th).",
|
||||
"dateFormat": "Date format",
|
||||
"saved": "Saved.",
|
||||
"languageDe": "German",
|
||||
"languageEn": "English"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Overview",
|
||||
"empty": "No cards yet. The dashboard will be built out in a later phase."
|
||||
},
|
||||
"server": {
|
||||
"unreachable": "Server unreachable.",
|
||||
"sessionKept": "Your session is still valid – once the server responds again, it will continue."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./i18n";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./auth";
|
||||
import { SettingsProvider } from "./settings";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { ToastProvider } from "./toast";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import "./styles.css";
|
||||
import "./finance.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SettingsProvider>
|
||||
<ConfirmProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ConfirmProvider>
|
||||
</SettingsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import i18n from "./i18n";
|
||||
import { api } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { applyTheme } from "./theme";
|
||||
import { formatMoney as fmtMoney, formatDate as fmtDate } from "./format";
|
||||
|
||||
const DEFAULTS = {
|
||||
base_currency: "EUR",
|
||||
locale: "de",
|
||||
theme: "system",
|
||||
date_format: "de",
|
||||
budget_start_day: "1",
|
||||
};
|
||||
|
||||
const SettingsContext = createContext({
|
||||
...DEFAULTS,
|
||||
formatMoney: (cents, currency) => fmtMoney(cents, currency),
|
||||
formatDate: (iso) => fmtDate(iso, "de"),
|
||||
update: async () => {},
|
||||
reload: async () => {},
|
||||
});
|
||||
|
||||
export function SettingsProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [values, setValues] = useState(DEFAULTS);
|
||||
|
||||
// Sprache und Thema anwenden, sobald sich die Werte ändern.
|
||||
useEffect(() => {
|
||||
if (i18n.language !== values.locale) i18n.changeLanguage(values.locale);
|
||||
}, [values.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(values.theme);
|
||||
}, [values.theme]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const rows = await api.listSettings();
|
||||
const map = { ...DEFAULTS };
|
||||
for (const r of rows) {
|
||||
if (r.value != null) map[r.key] = r.value;
|
||||
}
|
||||
setValues(map);
|
||||
} catch {
|
||||
/* Einstellungen sind optional – Standardwerte bleiben bestehen. */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) reload();
|
||||
else setValues(DEFAULTS); // nach Logout auf Standard zurück
|
||||
}, [user, reload]);
|
||||
|
||||
// Eine Einstellung setzen: erst lokal (sofort sichtbar), dann speichern.
|
||||
const update = useCallback(async (key, value) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
await api.setSetting(key, value);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
...values,
|
||||
baseCurrency: values.base_currency,
|
||||
formatMoney: (cents, currency) => fmtMoney(cents, currency ?? values.base_currency),
|
||||
formatDate: (iso) => fmtDate(iso, values.locale),
|
||||
update,
|
||||
reload,
|
||||
};
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
+1142
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
// Hell/Dunkel-Umschaltung. "system" folgt der Betriebssystem-Einstellung
|
||||
// (kein data-theme gesetzt -> die @media-Regel in styles.css greift), "light"/
|
||||
// "dark" erzwingen das jeweilige Thema über das data-theme-Attribut am <html>.
|
||||
|
||||
export function applyTheme(theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === "light" || theme === "dark") {
|
||||
root.dataset.theme = theme;
|
||||
} else {
|
||||
delete root.dataset.theme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import Icon from "./components/Icon";
|
||||
|
||||
/**
|
||||
* Kurze Rückmeldungen als Overlay am unteren Rand.
|
||||
*
|
||||
* Vorher standen sie als Hinweisleiste mitten in der Seite – dadurch sprang
|
||||
* beim Speichern das ganze Layout ein Stück nach unten. Ein Overlay liegt über
|
||||
* dem Inhalt und verschiebt nichts.
|
||||
*
|
||||
* Bewusst nur für *Erfolgsmeldungen*: Fehler bleiben als Leiste an ihrer Stelle
|
||||
* stehen. Eine Fehlermeldung, die nach fünf Sekunden verschwindet, ist genau
|
||||
* dann weg, wenn man sie noch braucht.
|
||||
*
|
||||
* const toast = useToast();
|
||||
* toast("Gespeichert.");
|
||||
*/
|
||||
const ToastContext = createContext(() => {});
|
||||
|
||||
const DAUER = 5000;
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [meldungen, setMeldungen] = useState([]);
|
||||
const uhren = useRef(new Map());
|
||||
|
||||
const entfernen = useCallback((id) => {
|
||||
setMeldungen((alt) => alt.filter((m) => m.id !== id));
|
||||
const uhr = uhren.current.get(id);
|
||||
if (uhr) {
|
||||
clearTimeout(uhr);
|
||||
uhren.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const zeigen = useCallback((text, art = "ok") => {
|
||||
if (!text) return;
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
setMeldungen((alt) => [...alt, { id, text, art }]);
|
||||
uhren.current.set(id, setTimeout(() => entfernen(id), DAUER));
|
||||
}, [entfernen]);
|
||||
|
||||
// Beim Verlassen der Seite laufende Uhren abräumen.
|
||||
useEffect(() => {
|
||||
const laufend = uhren.current;
|
||||
return () => laufend.forEach((uhr) => clearTimeout(uhr));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={zeigen}>
|
||||
{children}
|
||||
{/* aria-live: Bildschirmleser sagen die Meldung an, ohne den Fokus zu stehlen. */}
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{meldungen.map((m) => (
|
||||
<div key={m.id} className={`toast ${m.art}`}>
|
||||
<Icon name={m.art === "ok" ? "check" : "alert"} size={16} />
|
||||
<span className="toast-text">{m.text}</span>
|
||||
<button className="btn-icon" onClick={() => entfernen(m.id)} title="Ausblenden">
|
||||
<Icon name="close" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
Reference in New Issue
Block a user