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:
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Python
|
||||||
|
backend/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Daten (SQLite-Datenbank + Backups)
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Node / Build-Artefakte
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# Konfiguration mit Geheimnissen
|
||||||
|
.env
|
||||||
|
deploy/.env
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Certain Finance
|
||||||
|
|
||||||
|
Selbstgehostete Finanzverwaltung für Privathaushalte. Budgets, mehrere Konten
|
||||||
|
(EUR **und** CHF), ein konfigurierbares Dashboard und Import von Kontoauszügen –
|
||||||
|
lauffähig in einem Proxmox-LXC, erreichbar über eine Web-UI hinter dem eigenen
|
||||||
|
Nginx Proxy Manager.
|
||||||
|
|
||||||
|
> Der interne Codename/Anzeigename steht zentral an einer Stelle
|
||||||
|
> (`web/src/brand.js`, `backend/app/branding.py`); der Rest ist generisch.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend:** FastAPI · SQLAlchemy · **SQLite** · Alembic · argon2 · Server-Sitzungen (Cookie)
|
||||||
|
- **Frontend:** React 18 · Vite · reines JSX · react-i18next (DE/EN) · Inline-SVG-Icons
|
||||||
|
- **Betrieb:** ein systemd-Dienst (uvicorn) liefert API **und** gebautes Frontend auf **einem** Port aus
|
||||||
|
|
||||||
|
## Entwicklung (lokal)
|
||||||
|
|
||||||
|
Backend (Terminal 1):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv .venv && . .venv/bin/activate # Python 3.11+
|
||||||
|
pip install -r requirements.txt
|
||||||
|
APP_DATA_DIR=./data uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend (Terminal 2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 (leitet /api an :8000 weiter)
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start wird ein Admin angelegt (Standard `admin` / `changeme`, per
|
||||||
|
`APP_ADMIN_USERNAME` / `APP_ADMIN_PASSWORD` änderbar).
|
||||||
|
|
||||||
|
## Installation im LXC (Proxmox, Debian/Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /opt/certain-finance
|
||||||
|
cd /opt/certain-finance
|
||||||
|
sudo ./deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach im **Nginx Proxy Manager** einen Proxy-Host auf `http://<LXC-IP>:<APP_PORT>`
|
||||||
|
anlegen (HTTPS via Let's Encrypt). Updates: `sudo ./deploy/update.sh`.
|
||||||
|
|
||||||
|
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Fahrplan: [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Alembic-Konfiguration. Die Datenbank-URL wird NICHT hier gesetzt, sondern in
|
||||||
|
# migrations/env.py aus app.database (SQLite, aus der App-Konfiguration) gelesen –
|
||||||
|
# so gibt es genau eine Quelle der Wahrheit.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Zentrale Namens-/Versionskonstanten (Backend-Seite).
|
||||||
|
|
||||||
|
Der Anzeigename der Anwendung steht bewusst NUR hier (und im Frontend in
|
||||||
|
``web/src/brand.js``). Überall sonst werden generische Bezeichner verwendet
|
||||||
|
(Paket ``app``, Env-Präfix ``APP_``), damit ein späteres Umbenennen genau eine
|
||||||
|
Konstante betrifft.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Anzeigename – die einzige Stelle im Backend, an der der Produktname steht.
|
||||||
|
APP_NAME = "Certain Finance"
|
||||||
|
|
||||||
|
# Programmversion. Wird bei /api/version ausgegeben.
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def git_sha() -> str | None:
|
||||||
|
"""Kurzer Git-Commit, falls vom Build/Env gesetzt (``APP_GIT_SHA``)."""
|
||||||
|
sha = os.environ.get("APP_GIT_SHA", "").strip()
|
||||||
|
return sha or None
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Konfiguration über Umgebungsvariablen (Präfix ``APP_``).
|
||||||
|
|
||||||
|
Beispiel: ``APP_DATA_DIR=/var/lib/certain-finance`` setzt ``data_dir``.
|
||||||
|
Die Standardwerte sind bewusst entwicklungsfreundlich (relatives Datenverzeichnis,
|
||||||
|
unsichere Cookies erlaubt). Für den Produktivbetrieb setzt das Install-Skript die
|
||||||
|
sicheren Werte in ``/etc/certain-finance/env`` (siehe ``deploy/env.example``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
# --- Ablageort für Datenbank und Backups ---------------------------------
|
||||||
|
# Produktiv per Env auf einen absoluten Pfad setzen (z. B. /var/lib/...).
|
||||||
|
data_dir: str = "./data"
|
||||||
|
# Leer = SQLite-Datei unter data_dir/app.db. Sonst vollständige URL.
|
||||||
|
database_url: str = ""
|
||||||
|
|
||||||
|
# --- Sitzungen / Cookies -------------------------------------------------
|
||||||
|
session_secret: str = "change-me-in-production"
|
||||||
|
session_ttl_days: int = 30
|
||||||
|
cookie_name: str = "cf_session"
|
||||||
|
# Standard False, damit die Anmeldung auch über http://localhost im Dev
|
||||||
|
# funktioniert. Produktiv hinter HTTPS (Nginx Proxy Manager) auf true setzen.
|
||||||
|
cookie_secure: bool = False
|
||||||
|
cookie_samesite: str = "lax"
|
||||||
|
|
||||||
|
# --- Erster Admin, nur beim allerersten Start angelegt -------------------
|
||||||
|
admin_username: str = "admin"
|
||||||
|
admin_password: str = "changeme"
|
||||||
|
|
||||||
|
# --- Währung -------------------------------------------------------------
|
||||||
|
default_base_currency: str = "EUR"
|
||||||
|
# Beim Start die aktuellen EZB-Kurse nachladen (benötigt Internet).
|
||||||
|
ecb_fetch_on_start: bool = True
|
||||||
|
|
||||||
|
# --- CORS ----------------------------------------------------------------
|
||||||
|
# Leer = kein Cross-Origin (gleiche Herkunft, da FastAPI das SPA selbst
|
||||||
|
# ausliefert). Kommagetrennte Liste oder "*" nur bei Bedarf.
|
||||||
|
cors_origins: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_database_url(self) -> str:
|
||||||
|
if self.database_url:
|
||||||
|
return self.database_url
|
||||||
|
return f"sqlite:///{self.data_dir.rstrip('/')}/app.db"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Datenbank-Anbindung (SQLite).
|
||||||
|
|
||||||
|
SQLite braucht ein paar PRAGMAs, damit es sich unter FastAPIs Thread-Pool
|
||||||
|
vernünftig verhält: WAL für gleichzeitige Leser, ``foreign_keys=ON`` (sonst
|
||||||
|
greifen die ON-DELETE-Regeln nicht), und ein Busy-Timeout, damit kurze
|
||||||
|
Schreibsperren nicht sofort krachen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_url = settings.effective_database_url
|
||||||
|
|
||||||
|
# Für die SQLite-Datei das Datenverzeichnis sicherstellen.
|
||||||
|
if _url.startswith("sqlite:///"):
|
||||||
|
_path = _url.replace("sqlite:///", "", 1)
|
||||||
|
_dir = os.path.dirname(_path)
|
||||||
|
if _dir:
|
||||||
|
os.makedirs(_dir, exist_ok=True)
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if _url.startswith("sqlite") else {}
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
_url,
|
||||||
|
connect_args=connect_args,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_connection, _record):
|
||||||
|
"""Bei jeder neuen SQLite-Verbindung sinnvolle Voreinstellungen setzen."""
|
||||||
|
if not _url.startswith("sqlite"):
|
||||||
|
return
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht.
|
||||||
|
|
||||||
|
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||||
|
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||||
|
und liefert den Benutzer – oder 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .database import get_db
|
||||||
|
from .models import Role, User
|
||||||
|
from .services import sessions
|
||||||
|
|
||||||
|
_credentials_exc = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nicht angemeldet oder Sitzung abgelaufen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
|
user = sessions.validate(db, token)
|
||||||
|
if user is None:
|
||||||
|
raise _credentials_exc
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
if current_user.role != Role.admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Nur Administratoren dürfen diese Aktion ausführen",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""FastAPI-Anwendung.
|
||||||
|
|
||||||
|
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
|
||||||
|
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
|
||||||
|
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .branding import APP_NAME, VERSION, git_sha
|
||||||
|
from .config import get_settings
|
||||||
|
from .database import SessionLocal
|
||||||
|
from .migrate import upgrade_to_head
|
||||||
|
from .routers import auth, settings as settings_router, users
|
||||||
|
from .seed import ensure_first_admin
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Schema auf den aktuellen Stand bringen (idempotent).
|
||||||
|
upgrade_to_head()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ensure_first_admin(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
|
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
|
||||||
|
if settings.cors_origins.strip():
|
||||||
|
origins = (
|
||||||
|
["*"]
|
||||||
|
if settings.cors_origins.strip() == "*"
|
||||||
|
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- API -------------------------------------------------------------------
|
||||||
|
api = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/health", tags=["meta"])
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/version", tags=["meta"])
|
||||||
|
def version() -> dict:
|
||||||
|
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
|
||||||
|
|
||||||
|
|
||||||
|
api.include_router(auth.router)
|
||||||
|
api.include_router(users.router)
|
||||||
|
api.include_router(settings_router.router)
|
||||||
|
|
||||||
|
app.include_router(api)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Frontend (SPA) --------------------------------------------------------
|
||||||
|
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
|
||||||
|
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
|
||||||
|
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
|
||||||
|
if DIST_DIR.exists():
|
||||||
|
assets_dir = DIST_DIR / "assets"
|
||||||
|
if assets_dir.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}", include_in_schema=False)
|
||||||
|
async def spa(full_path: str):
|
||||||
|
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||||||
|
if full_path.startswith("api"):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
candidate = DIST_DIR / full_path
|
||||||
|
if full_path and candidate.is_file():
|
||||||
|
return FileResponse(candidate)
|
||||||
|
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Programmatisches Anwenden der Alembic-Migrationen.
|
||||||
|
|
||||||
|
Wird beim App-Start aufgerufen, damit ein frisch geklontes/gestartetes System
|
||||||
|
sofort das aktuelle Schema hat – ohne dass man ``alembic`` von Hand ausführen muss.
|
||||||
|
Das Install-/Update-Skript ruft ``alembic upgrade head`` zusätzlich auf; das ist
|
||||||
|
idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
# backend/ (eine Ebene über app/)
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> Config:
|
||||||
|
cfg = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||||
|
cfg.set_main_option("script_location", str(BACKEND_DIR / "migrations"))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_to_head() -> None:
|
||||||
|
command.upgrade(_config(), "head")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""SQLAlchemy-Modelle.
|
||||||
|
|
||||||
|
Konventionen (wie in der Vorlage): ``Mapped[...] = mapped_column(...)``, Geld als
|
||||||
|
Ganzzahl in Cent/Rappen, Enums als kurze VARCHAR-Strings (``native_enum=False``,
|
||||||
|
ohne DB-CHECK) – das hält Alembic auf SQLite einfach.
|
||||||
|
|
||||||
|
Phase 0 umfasst nur Benutzer, Sitzungen und Benutzer-Einstellungen. Fachliche
|
||||||
|
Tabellen (Konten, Kategorien, Transaktionen, Budget …) kommen in späteren Phasen
|
||||||
|
hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Enum as SAEnum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(enum_cls: type[enum.Enum]):
|
||||||
|
"""Enum als VARCHAR ohne DB-CHECK – Alembic-freundlich auf SQLite."""
|
||||||
|
return SAEnum(enum_cls, native_enum=False, create_constraint=False, length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, enum.Enum):
|
||||||
|
admin = "admin"
|
||||||
|
user = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
# Vom Admin mit Temporär-Passwort angelegt: beim ersten Login ändern lassen.
|
||||||
|
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
sessions: Mapped[list[Session]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
settings: Mapped[list[UserSetting]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
|
class UserSetting(Base):
|
||||||
|
"""Freie Schlüssel-Wert-Einstellungen je Benutzer.
|
||||||
|
|
||||||
|
Bekannte Schlüssel: ``base_currency`` (EUR|CHF), ``budget_start_day`` (int),
|
||||||
|
``locale`` (de|en), ``theme`` (system|light|dark), ``date_format``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_settings"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="settings")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Anmeldung über server-seitige Sitzung im Cookie."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import PasswordChange, UserOut
|
||||||
|
from ..security import hash_password, verify_password
|
||||||
|
from ..services import sessions
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.cookie_name,
|
||||||
|
value=raw_token,
|
||||||
|
max_age=settings.session_ttl_days * 86400,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=UserOut)
|
||||||
|
def login(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
form: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
user = db.query(User).filter(User.username == form.username).first()
|
||||||
|
if user is None or not verify_password(form.password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Benutzername oder Passwort falsch",
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Dieses Konto ist deaktiviert",
|
||||||
|
)
|
||||||
|
raw = sessions.create_session(db, user, request.headers.get("user-agent"))
|
||||||
|
_set_session_cookie(response, raw)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
|
||||||
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
|
if token:
|
||||||
|
sessions.revoke(db, token)
|
||||||
|
response.delete_cookie(get_settings().cookie_name, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", response_model=UserOut)
|
||||||
|
def change_password(
|
||||||
|
payload: PasswordChange,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
if not verify_password(payload.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Aktuelles Passwort ist falsch",
|
||||||
|
)
|
||||||
|
current_user.password_hash = hash_password(payload.new_password)
|
||||||
|
current_user.must_change_password = False
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
return current_user
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Anzeige-/Verhaltens-Einstellungen je Benutzer (Schlüssel-Wert).
|
||||||
|
|
||||||
|
Jeder Benutzer hat seine eigenen Einstellungen. Unbekannte Schlüssel werden
|
||||||
|
ebenfalls gespeichert; die hier bekannten liefern sinnvolle Vorgaben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user
|
||||||
|
from ..models import User, UserSetting
|
||||||
|
from ..schemas import SettingOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
BASE_CURRENCY_KEY = "base_currency"
|
||||||
|
BUDGET_START_DAY_KEY = "budget_start_day"
|
||||||
|
LOCALE_KEY = "locale"
|
||||||
|
THEME_KEY = "theme"
|
||||||
|
DATE_FORMAT_KEY = "date_format"
|
||||||
|
|
||||||
|
|
||||||
|
def known_defaults() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
BASE_CURRENCY_KEY: get_settings().default_base_currency,
|
||||||
|
BUDGET_START_DAY_KEY: "1",
|
||||||
|
LOCALE_KEY: "de",
|
||||||
|
THEME_KEY: "system",
|
||||||
|
DATE_FORMAT_KEY: "de",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_setting(db: Session, user: User, key: str) -> str | None:
|
||||||
|
row = db.get(UserSetting, (user.id, key))
|
||||||
|
if row is not None and row.value is not None:
|
||||||
|
return row.value
|
||||||
|
return known_defaults().get(key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SettingOut])
|
||||||
|
def list_settings(
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> list[SettingOut]:
|
||||||
|
rows = db.query(UserSetting).filter(UserSetting.user_id == user.id).all()
|
||||||
|
merged: dict[str, str | None] = dict(known_defaults())
|
||||||
|
for r in rows:
|
||||||
|
merged[r.key] = r.value
|
||||||
|
return [SettingOut(key=k, value=v) for k, v in merged.items()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key}", response_model=SettingOut)
|
||||||
|
def set_setting(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> SettingOut:
|
||||||
|
row = db.get(UserSetting, (user.id, key))
|
||||||
|
if row is None:
|
||||||
|
row = UserSetting(user_id=user.id, key=key, value=value)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.value = value
|
||||||
|
db.commit()
|
||||||
|
return SettingOut(key=key, value=value)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Benutzerverwaltung (nur Admin). Neue Nutzer bekommen ein Temporär-Passwort."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import require_admin
|
||||||
|
from ..models import Role, User
|
||||||
|
from ..schemas import UserCreate, UserOut, UserUpdate
|
||||||
|
from ..security import hash_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserOut])
|
||||||
|
def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||||
|
return db.query(User).order_by(User.username).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||||
|
if db.query(User).filter(User.username == payload.username).first():
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||||
|
user = User(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role=payload.role,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
is_active=True,
|
||||||
|
# Vom Admin gesetztes Temporär-Passwort: beim ersten Login ändern lassen.
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserOut)
|
||||||
|
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)) -> User:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||||
|
if payload.password is not None:
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
# Vom Admin gesetztes Passwort gilt als temporär.
|
||||||
|
user.must_change_password = True
|
||||||
|
if payload.role is not None:
|
||||||
|
user.role = payload.role
|
||||||
|
if payload.display_name is not None:
|
||||||
|
user.display_name = payload.display_name
|
||||||
|
if payload.is_active is not None:
|
||||||
|
user.is_active = payload.is_active
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||||
|
if user.id == current_admin.id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Du kannst dich nicht selbst löschen")
|
||||||
|
if user.role == Role.admin:
|
||||||
|
admin_count = db.query(User).filter(User.role == Role.admin).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Der letzte Administrator kann nicht gelöscht werden",
|
||||||
|
)
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .models import Role
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: Role
|
||||||
|
display_name: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
must_change_password: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=64)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
role: Role = Role.user
|
||||||
|
display_name: str | None = Field(default=None, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
password: str | None = Field(default=None, min_length=1, max_length=1024)
|
||||||
|
role: Role | None = None
|
||||||
|
display_name: str | None = Field(default=None, max_length=120)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
new_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: str | None = None
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Passwort-Hashing (argon2id) und Token-Helfer für Sitzungen.
|
||||||
|
|
||||||
|
argon2id ist der aktuelle Empfehlungsstandard und hat – anders als bcrypt – keine
|
||||||
|
72-Byte-Grenze. Die Sitzungs-Tokens selbst werden nur als SHA-256-Hash gespeichert,
|
||||||
|
damit ein Blick in die Datenbank keine gültigen Cookies verrät.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except (VerifyMismatchError, VerificationError, InvalidHashError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(password_hash: str) -> bool:
|
||||||
|
"""True, wenn der Hash mit veralteten Parametern erzeugt wurde."""
|
||||||
|
try:
|
||||||
|
return _hasher.check_needs_rehash(password_hash)
|
||||||
|
except (InvalidHashError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_token() -> str:
|
||||||
|
"""Zufälliges, langlebiges Sitzungs-Token (Klartext, nur im Cookie)."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_session_token(token: str) -> str:
|
||||||
|
"""SHA-256 – schnell genug, um bei jeder Anfrage geprüft zu werden."""
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Startup-Seeds. Phase 0: nur der erste Admin-Benutzer.
|
||||||
|
|
||||||
|
Fachliche Startdaten (Standard-Kategorien usw.) folgen in späteren Phasen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .models import Role, User
|
||||||
|
from .security import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_first_admin(db: Session) -> None:
|
||||||
|
"""Legt einen Admin an, solange noch gar kein Benutzer existiert."""
|
||||||
|
if db.query(User).first() is not None:
|
||||||
|
return
|
||||||
|
settings = get_settings()
|
||||||
|
admin = User(
|
||||||
|
username=settings.admin_username,
|
||||||
|
password_hash=hash_password(settings.admin_password),
|
||||||
|
role=Role.admin,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Server-seitige Sitzungen: anlegen, prüfen, widerrufen.
|
||||||
|
|
||||||
|
Im Cookie liegt nur das Klartext-Token; in der Datenbank nur dessen SHA-256-Hash.
|
||||||
|
So lässt sich eine Sitzung serverseitig beenden (Logout, „überall abmelden"),
|
||||||
|
und ein Blick in die DB verrät kein gültiges Cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..models import Session as UserSession, User
|
||||||
|
from ..security import generate_session_token, hash_session_token
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(dt: datetime) -> datetime:
|
||||||
|
"""SQLite gibt datetimes teils ohne tzinfo zurück – als UTC interpretieren."""
|
||||||
|
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(db: DBSession, user: User, user_agent: str | None = None) -> str:
|
||||||
|
"""Legt eine Sitzung an und gibt das Klartext-Token zurück (nur hier sichtbar)."""
|
||||||
|
settings = get_settings()
|
||||||
|
raw = generate_session_token()
|
||||||
|
entry = UserSession(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=hash_session_token(raw),
|
||||||
|
expires_at=_now() + timedelta(days=settings.session_ttl_days),
|
||||||
|
last_seen_at=_now(),
|
||||||
|
user_agent=(user_agent or "")[:255] or None,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def validate(db: DBSession, raw_token: str) -> User | None:
|
||||||
|
"""Gibt den Benutzer zurück, wenn das Token gültig und nicht abgelaufen ist."""
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
entry = (
|
||||||
|
db.query(UserSession)
|
||||||
|
.filter(UserSession.token_hash == hash_session_token(raw_token))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if _aware(entry.expires_at) < _now():
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
user = db.get(User, entry.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
return None
|
||||||
|
entry.last_seen_at = _now()
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(db: DBSession, raw_token: str) -> None:
|
||||||
|
entry = (
|
||||||
|
db.query(UserSession)
|
||||||
|
.filter(UserSession.token_hash == hash_session_token(raw_token))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if entry is not None:
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all(db: DBSession, user: User) -> None:
|
||||||
|
db.query(UserSession).filter(UserSession.user_id == user.id).delete()
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Alembic-Umgebung.
|
||||||
|
|
||||||
|
Nutzt die Engine und die Metadaten der Anwendung (SQLite), damit Migrationen und
|
||||||
|
Laufzeit garantiert dieselbe Datenbank/dasselbe Schema verwenden. ``render_as_batch``
|
||||||
|
ist für SQLite Pflicht, damit ALTER-TABLE über Tabellen-Kopien läuft.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.database import Base, engine
|
||||||
|
from app import models # noqa: F401 – registriert alle Tabellen an Base.metadata
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=str(engine.url),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Initiales Schema: Benutzer, Sitzungen, Benutzer-Einstellungen.
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column(
|
||||||
|
"must_change_password",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("0"),
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("username", name="uq_users_username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
op.create_index("ix_sessions_token_hash", "sessions", ["token_hash"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_settings",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("key", sa.String(length=64), primary_key=True),
|
||||||
|
sa.Column("value", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user_settings")
|
||||||
|
op.drop_index("ix_sessions_token_hash", table_name="sessions")
|
||||||
|
op.drop_index("ix_sessions_user_id", table_name="sessions")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
httpx==0.28.1
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
pytest==8.3.4
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Deployment (Proxmox-LXC + Nginx Proxy Manager)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Im LXC (Debian/Ubuntu) das Repo klonen und als root installieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /opt/certain-finance
|
||||||
|
cd /opt/certain-finance
|
||||||
|
sudo ./deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript installiert Python/Node, richtet eine venv ein, baut das Frontend,
|
||||||
|
erzeugt `/etc/certain-finance/env` (mit Zufallsgeheimnissen), legt den systemd-
|
||||||
|
Dienst `certain-finance` an und startet ihn. Node wird **nur zum Bauen** gebraucht;
|
||||||
|
der laufende Dienst ist reines Python.
|
||||||
|
|
||||||
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
|
Neuen **Proxy Host** anlegen:
|
||||||
|
|
||||||
|
- **Domain Names:** deine (Sub-)Domain
|
||||||
|
- **Scheme:** `http`
|
||||||
|
- **Forward Hostname / IP:** IP dieses LXC
|
||||||
|
- **Forward Port:** Wert von `APP_PORT` (Standard `8071`)
|
||||||
|
- **Block Common Exploits:** an
|
||||||
|
- **Websockets Support:** nicht nötig
|
||||||
|
- **SSL:** Let's Encrypt-Zertifikat anfordern, „Force SSL" an
|
||||||
|
|
||||||
|
Für spätere CSV-/PDF-Uploads in den *Advanced*-Einstellungen des Hosts
|
||||||
|
`client_max_body_size 20m;` setzen.
|
||||||
|
|
||||||
|
Da HTTPS extern terminiert, sind die Sitzungs-Cookies `Secure`
|
||||||
|
(`APP_COOKIE_SECURE=true`). Der Dienst selbst lauscht nur auf `127.0.0.1`.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status certain-finance # Status
|
||||||
|
journalctl -u certain-finance -f # Logs
|
||||||
|
systemctl restart certain-finance # Neustart
|
||||||
|
sudo ./deploy/update.sh # Update (git pull + build + Migration + Neustart)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Die Datenbank liegt unter `/var/lib/certain-finance/app.db`. `update.sh` legt vor
|
||||||
|
jeder Migration automatisch eine Kopie unter `/var/lib/certain-finance/backups/`
|
||||||
|
an. Zusätzlich empfiehlt sich ein Proxmox-`vzdump`-Snapshot des Containers.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# systemd-Unit für Certain Finance (Referenz).
|
||||||
|
# install.sh erzeugt die tatsächliche Unit unter /etc/systemd/system/certain-finance.service
|
||||||
|
# mit den konkreten Pfaden deines Klons. Diese Datei zeigt die Standardform
|
||||||
|
# (Repo unter /opt/certain-finance).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Certain Finance
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=certain-finance
|
||||||
|
Group=certain-finance
|
||||||
|
WorkingDirectory=/opt/certain-finance/backend
|
||||||
|
EnvironmentFile=/etc/certain-finance/env
|
||||||
|
# Der Port kommt aus der EnvironmentFile (APP_PORT). Nur an 127.0.0.1 gebunden –
|
||||||
|
# von außen ist der Dienst ausschließlich über den Nginx Proxy Manager erreichbar.
|
||||||
|
ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port ${APP_PORT}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# Härtung
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/certain-finance
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Konfiguration für Certain Finance (Umgebungsvariablen, Präfix APP_).
|
||||||
|
# install.sh erzeugt daraus /etc/certain-finance/env mit sicheren Zufallswerten.
|
||||||
|
# Nach Änderungen den Dienst neu starten: systemctl restart certain-finance
|
||||||
|
|
||||||
|
# Ablageort für SQLite-Datenbank und Backups (muss dem Dienst-Benutzer gehören).
|
||||||
|
APP_DATA_DIR=/var/lib/certain-finance
|
||||||
|
|
||||||
|
# Interner Port, auf dem der Dienst lauscht. Der Nginx Proxy Manager leitet
|
||||||
|
# von außen (HTTPS) hierher weiter. Nur an 127.0.0.1 gebunden (siehe Unit).
|
||||||
|
APP_PORT=8071
|
||||||
|
|
||||||
|
# Signiert/HMACt Sitzungen. UNBEDINGT zufällig setzen (install.sh macht das).
|
||||||
|
APP_SESSION_SECRET=change-me-please
|
||||||
|
|
||||||
|
# Cookies nur über HTTPS senden. Hinter dem Nginx Proxy Manager: true.
|
||||||
|
APP_COOKIE_SECURE=true
|
||||||
|
|
||||||
|
# Erster Admin – wird NUR beim allerersten Start angelegt (wenn noch kein Nutzer da ist).
|
||||||
|
APP_ADMIN_USERNAME=admin
|
||||||
|
APP_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Standard-Hauptwährung für neue Benutzer (EUR oder CHF).
|
||||||
|
APP_DEFAULT_BASE_CURRENCY=EUR
|
||||||
|
|
||||||
|
# Beim Start die aktuellen EZB-Wechselkurse laden (ab Phase 1 relevant).
|
||||||
|
APP_ECB_FETCH_ON_START=true
|
||||||
|
|
||||||
|
# Wird vom Install-/Update-Skript gesetzt (kurzer Git-Commit, für /api/version).
|
||||||
|
APP_GIT_SHA=
|
||||||
Executable
+191
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Certain Finance – nativer Installer für einen Debian/Ubuntu-LXC (Proxmox).
|
||||||
|
# Richtet Python-venv, gebautes Frontend, systemd-Dienst und Konfiguration ein.
|
||||||
|
# HTTPS macht dein vorhandener Nginx Proxy Manager – hier wird KEIN Reverse-Proxy
|
||||||
|
# installiert; der Dienst lauscht nur intern auf 127.0.0.1:<APP_PORT>.
|
||||||
|
#
|
||||||
|
# Nutzung (als root im geklonten Repo):
|
||||||
|
# ./deploy/install.sh interaktiv (fragt Port/Admin)
|
||||||
|
# ./deploy/install.sh --yes nicht-interaktiv (Zufalls-Admin-Passwort)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_SLUG="certain-finance" # Einzige Stelle mit dem Betriebs-Slug (Dienst/Pfade)
|
||||||
|
DEFAULT_PORT=8071
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ETC_DIR="/etc/$APP_SLUG"
|
||||||
|
ENV_FILE="$ETC_DIR/env"
|
||||||
|
DATA_DIR="/var/lib/$APP_SLUG"
|
||||||
|
SERVICE_USER="$APP_SLUG"
|
||||||
|
UNIT_FILE="/etc/systemd/system/$APP_SLUG.service"
|
||||||
|
|
||||||
|
ASSUME_YES=0
|
||||||
|
[[ "${1:-}" == "--yes" || "${1:-}" == "-y" ]] && ASSUME_YES=1
|
||||||
|
|
||||||
|
info() { printf '\033[0;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[0;33m[!]\033[0m %s\n' "$*"; }
|
||||||
|
error() { printf '\033[0;31m[x]\033[0m %s\n' "$*" >&2; }
|
||||||
|
rand() { LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-32}"; }
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
error "Bitte als root ausführen (z. B. 'sudo ./deploy/install.sh')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
error "Dieses Skript ist für Debian/Ubuntu (apt) gedacht. Kein apt-get gefunden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Systempakete ------------------------------------------------------------
|
||||||
|
info "Installiere Systempakete (Python, Build-Tools, git, curl) …"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y python3 python3-venv python3-dev build-essential curl ca-certificates git
|
||||||
|
|
||||||
|
# --- Node.js (nur zum Bauen des Frontends nötig) -----------------------------
|
||||||
|
ensure_node() {
|
||||||
|
local major=0
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
||||||
|
fi
|
||||||
|
if [[ "$major" -ge 18 ]]; then
|
||||||
|
info "Node.js gefunden: $(node --version)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
warn "Node.js fehlt oder ist zu alt – installiere Node 20 LTS (NodeSource) …"
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
info "Node.js installiert: $(node --version)"
|
||||||
|
}
|
||||||
|
ensure_node
|
||||||
|
|
||||||
|
# --- Dienst-Benutzer und Verzeichnisse --------------------------------------
|
||||||
|
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
info "Lege Dienst-Benutzer '$SERVICE_USER' an …"
|
||||||
|
useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$DATA_DIR" "$ETC_DIR"
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
|
||||||
|
|
||||||
|
# --- Python-venv + Abhängigkeiten -------------------------------------------
|
||||||
|
info "Richte Python-venv ein und installiere Abhängigkeiten …"
|
||||||
|
python3 -m venv "$REPO_DIR/backend/.venv"
|
||||||
|
"$REPO_DIR/backend/.venv/bin/pip" install --upgrade pip >/dev/null
|
||||||
|
"$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt"
|
||||||
|
|
||||||
|
# --- Frontend bauen ----------------------------------------------------------
|
||||||
|
info "Baue das Frontend (Node wird nur hier gebraucht) …"
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR/web"
|
||||||
|
if [[ -f package-lock.json ]]; then npm ci; else npm install; fi
|
||||||
|
npm run build
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Konfiguration (.env) ----------------------------------------------------
|
||||||
|
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
info "Konfiguration existiert bereits: $ENV_FILE (unverändert übernommen)."
|
||||||
|
# Git-Commit aktualisieren (für /api/version).
|
||||||
|
if grep -q '^APP_GIT_SHA=' "$ENV_FILE"; then
|
||||||
|
sed -i "s|^APP_GIT_SHA=.*|APP_GIT_SHA=$GIT_SHA|" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
ADMIN_USER="$(grep -E '^APP_ADMIN_USERNAME=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
|
GENERATED_PASS=""
|
||||||
|
else
|
||||||
|
local_port="$DEFAULT_PORT"
|
||||||
|
admin_user="admin"
|
||||||
|
admin_pass="$(rand 16)"
|
||||||
|
if [[ "$ASSUME_YES" -eq 0 ]]; then
|
||||||
|
read -r -p "Interner Port [$DEFAULT_PORT]: " x; local_port="${x:-$DEFAULT_PORT}"
|
||||||
|
read -r -p "Admin-Benutzername [admin]: " x; admin_user="${x:-admin}"
|
||||||
|
read -r -s -p "Admin-Passwort [leer = zufällig]: " x; echo
|
||||||
|
[[ -n "$x" ]] && admin_pass="$x"
|
||||||
|
fi
|
||||||
|
info "Erzeuge Konfiguration: $ENV_FILE"
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
APP_DATA_DIR=$DATA_DIR
|
||||||
|
APP_PORT=$local_port
|
||||||
|
APP_SESSION_SECRET=$(rand 48)
|
||||||
|
APP_COOKIE_SECURE=true
|
||||||
|
APP_ADMIN_USERNAME=$admin_user
|
||||||
|
APP_ADMIN_PASSWORD=$admin_pass
|
||||||
|
APP_DEFAULT_BASE_CURRENCY=EUR
|
||||||
|
APP_ECB_FETCH_ON_START=true
|
||||||
|
APP_GIT_SHA=$GIT_SHA
|
||||||
|
EOF
|
||||||
|
ADMIN_USER="$admin_user"
|
||||||
|
GENERATED_PASS="$admin_pass"
|
||||||
|
fi
|
||||||
|
chown root:root "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# --- systemd-Unit ------------------------------------------------------------
|
||||||
|
info "Schreibe systemd-Unit: $UNIT_FILE"
|
||||||
|
cat > "$UNIT_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Certain Finance
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$SERVICE_USER
|
||||||
|
Group=$SERVICE_USER
|
||||||
|
WorkingDirectory=$REPO_DIR/backend
|
||||||
|
EnvironmentFile=$ENV_FILE
|
||||||
|
ExecStart=$REPO_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port \${APP_PORT}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=$DATA_DIR
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$APP_SLUG" >/dev/null 2>&1 || true
|
||||||
|
info "Starte Dienst … (wendet Migrationen an und legt ggf. den ersten Admin an)"
|
||||||
|
systemctl restart "$APP_SLUG"
|
||||||
|
|
||||||
|
# --- Gesundheitscheck --------------------------------------------------------
|
||||||
|
PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
|
info "Warte auf das Backend (127.0.0.1:$PORT) …"
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ok=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [[ "$ok" -eq 1 ]]; then
|
||||||
|
info "Fertig – Certain Finance läuft. 🎉"
|
||||||
|
else
|
||||||
|
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
|
||||||
|
fi
|
||||||
|
echo " Interner Dienst: http://127.0.0.1:$PORT (nur lokal)"
|
||||||
|
echo " Admin-Login: ${ADMIN_USER:-admin}"
|
||||||
|
if [[ -n "${GENERATED_PASS:-}" ]]; then
|
||||||
|
echo " Admin-Passwort: ${GENERATED_PASS}"
|
||||||
|
warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo " Nginx Proxy Manager: neuen Proxy-Host anlegen ->"
|
||||||
|
echo " Forward Hostname/IP: <IP-dieses-LXC>"
|
||||||
|
echo " Forward Port: $PORT"
|
||||||
|
echo " SSL: Let's Encrypt aktivieren; 'Block Common Exploits' ok."
|
||||||
|
echo " (Websockets nicht nötig. Für spätere Uploads client_max_body_size >= 20M.)"
|
||||||
|
echo
|
||||||
|
echo " Logs: journalctl -u $APP_SLUG -f"
|
||||||
|
echo " Neustart: systemctl restart $APP_SLUG"
|
||||||
|
echo " Update: ./deploy/update.sh"
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Certain Finance – Update-Skript.
|
||||||
|
# Holt den neuesten Stand aus Git, baut das Frontend neu, sichert die Datenbank
|
||||||
|
# und startet den Dienst neu (der beim Start die Migrationen anwendet).
|
||||||
|
#
|
||||||
|
# Nutzung (als root im geklonten Repo):
|
||||||
|
# ./deploy/update.sh Update holen, bauen, neu starten
|
||||||
|
# ./deploy/update.sh --no-pull nur neu bauen (ohne git pull)
|
||||||
|
# ./deploy/update.sh --no-build ohne Frontend-Neubau
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_SLUG="certain-finance"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ETC_DIR="/etc/$APP_SLUG"
|
||||||
|
ENV_FILE="$ETC_DIR/env"
|
||||||
|
DATA_DIR="/var/lib/$APP_SLUG"
|
||||||
|
|
||||||
|
DO_PULL=1
|
||||||
|
DO_BUILD=1
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-pull) DO_PULL=0 ;;
|
||||||
|
--no-build) DO_BUILD=0 ;;
|
||||||
|
-h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||||
|
*) echo "Unbekannte Option: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
info() { printf '\033[0;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[0;33m[!]\033[0m %s\n' "$*"; }
|
||||||
|
error() { printf '\033[0;31m[x]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
error "Bitte als root ausführen (z. B. 'sudo ./deploy/update.sh')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
error "Keine Konfiguration in $ENV_FILE – bitte zuerst ./deploy/install.sh ausführen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Git-Update --------------------------------------------------------------
|
||||||
|
if [[ "$DO_PULL" -eq 1 ]]; then
|
||||||
|
if [[ ! -d "$REPO_DIR/.git" ]]; then
|
||||||
|
warn "$REPO_DIR ist kein Git-Repository – überspringe git pull."
|
||||||
|
else
|
||||||
|
info "Hole Updates aus Git …"
|
||||||
|
before="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '?')"
|
||||||
|
if ! git -C "$REPO_DIR" pull --ff-only; then
|
||||||
|
error "git pull fehlgeschlagen (lokale Änderungen?)."
|
||||||
|
echo " Stand ansehen: git -C \"$REPO_DIR\" status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
after="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
|
||||||
|
[[ "$before" == "$after" ]] && info "Bereits aktuell ($after)." || info "Aktualisiert: $before -> $after"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Datenbank sichern (vor der Migration) ----------------------------------
|
||||||
|
if [[ -f "$DATA_DIR/app.db" ]]; then
|
||||||
|
mkdir -p "$DATA_DIR/backups"
|
||||||
|
ts="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$DATA_DIR/app.db" "$DATA_DIR/backups/pre-update-$ts.db"
|
||||||
|
info "Datenbank gesichert: backups/pre-update-$ts.db"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Python-Abhängigkeiten ---------------------------------------------------
|
||||||
|
info "Aktualisiere Python-Abhängigkeiten …"
|
||||||
|
"$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt" >/dev/null
|
||||||
|
|
||||||
|
# --- Frontend neu bauen ------------------------------------------------------
|
||||||
|
if [[ "$DO_BUILD" -eq 1 ]]; then
|
||||||
|
info "Baue das Frontend neu …"
|
||||||
|
( cd "$REPO_DIR/web"; if [[ -f package-lock.json ]]; then npm ci; else npm install; fi; npm run build )
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Git-Commit in die Konfiguration schreiben (für /api/version) -----------
|
||||||
|
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||||
|
if grep -q '^APP_GIT_SHA=' "$ENV_FILE"; then
|
||||||
|
sed -i "s|^APP_GIT_SHA=.*|APP_GIT_SHA=$GIT_SHA|" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Neu starten (wendet Migrationen an) ------------------------------------
|
||||||
|
info "Starte Dienst neu …"
|
||||||
|
systemctl restart "$APP_SLUG"
|
||||||
|
|
||||||
|
PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
|
PORT="${PORT:-8071}"
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ok=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [[ "$ok" -eq 1 ]]; then
|
||||||
|
info "Update fertig – Dienst läuft."
|
||||||
|
else
|
||||||
|
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Architektur
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Ein einzelner Python-Prozess (uvicorn/FastAPI) liefert sowohl die JSON-API unter
|
||||||
|
`/api/*` als auch das gebaute Frontend (`web/dist`, mit SPA-Fallback) auf **einem**
|
||||||
|
Port aus. HTTPS terminiert extern der Nginx Proxy Manager; der Dienst lauscht nur
|
||||||
|
auf `127.0.0.1:<APP_PORT>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──HTTPS──> Nginx Proxy Manager ──HTTP──> 127.0.0.1:APP_PORT (uvicorn)
|
||||||
|
├── /api/* → FastAPI-Router
|
||||||
|
└── /* → web/dist (SPA)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ app/ (config, database, security, deps, main, models, schemas, seed,
|
||||||
|
migrate, routers/*, services/*), migrations/ (Alembic), requirements.txt
|
||||||
|
web/ src/ (main.jsx, App.jsx, brand.js, api.js, auth.jsx, settings.jsx,
|
||||||
|
i18n.js, locales/, format.js, theme.js, styles.css, finance.css,
|
||||||
|
components/, pages/), package.json, vite.config.js
|
||||||
|
deploy/ certain-finance.service, install.sh, update.sh, env.example
|
||||||
|
docs/ ARCHITECTURE.md, ROADMAP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **Konfiguration** (`app/config.py`): Umgebungsvariablen mit Präfix `APP_`.
|
||||||
|
Standardwerte sind dev-freundlich; Produktivwerte kommen aus `/etc/certain-finance/env`.
|
||||||
|
- **Datenbank** (`app/database.py`): SQLite mit WAL, `foreign_keys=ON`, Busy-Timeout.
|
||||||
|
- **Migrationen**: Alembic (`migrations/`), `render_as_batch=True` (Pflicht für SQLite).
|
||||||
|
Werden beim App-Start automatisch angewandt (`app/migrate.py`) – und vom Install-/
|
||||||
|
Update-Skript.
|
||||||
|
- **Auth** (`app/security.py`, `app/services/sessions.py`, `app/deps.py`): argon2-
|
||||||
|
Passwort-Hashing; server-seitige Sitzungen (im Cookie nur das Klartext-Token,
|
||||||
|
in der DB nur dessen SHA-256-Hash). Erst-Admin beim ersten Start; weitere Nutzer
|
||||||
|
legt der Admin mit Temporär-Passwort an (Änderung beim ersten Login erzwungen).
|
||||||
|
- **Isolation**: Ab Phase 1 trägt jede fachliche Tabelle ein `owner_id`; jeder
|
||||||
|
Nutzer sieht ausschließlich seine eigenen Daten.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- React 18 + Vite + reines JSX, Router mit `Protected`-Wrapper (Login immer nötig).
|
||||||
|
- **Design-Tokens** in `styles.css` (aus der Vorlage übernommen), Ergänzungen in
|
||||||
|
`finance.css`. Volle Hell/Dunkel-Unterstützung (`prefers-color-scheme` + `data-theme`).
|
||||||
|
- **i18n** (react-i18next): Deutsch Standard, Englisch optional; Sprache aus der
|
||||||
|
Benutzer-Einstellung `locale`.
|
||||||
|
- **Icons**: ausschließlich Inline-SVG (`components/Icon.jsx`), niemals Emojis.
|
||||||
|
- **API-Client** (`api.js`): alles über `/api`, immer `credentials:"include"`
|
||||||
|
(Cookie-Sitzung), zentrales 401-Handling.
|
||||||
|
|
||||||
|
## Namensgebung
|
||||||
|
|
||||||
|
Der Anzeigename „Certain Finance" steht nur in `web/src/brand.js` und
|
||||||
|
`backend/app/branding.py`. Intern ist alles generisch (Paket `app`, Env-Präfix
|
||||||
|
`APP_`). Der Betriebs-Slug `certain-finance` (systemd-Dienst, Pfade) steht in den
|
||||||
|
Deploy-Skripten je einmal oben als Variable.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Fahrplan
|
||||||
|
|
||||||
|
Jede Phase ist für sich lauffähig und wird nach Fertigstellung committet/gepusht.
|
||||||
|
|
||||||
|
## Phase 0 – Gerüst ✅
|
||||||
|
Repo-Struktur, FastAPI + SQLite + Alembic, Multi-User-Login (Server-Sitzungen,
|
||||||
|
Cookie), Erst-Admin + Benutzerverwaltung (Temporär-Passwort), React-App-Shell,
|
||||||
|
Design-Tokens (Light/Dark), i18n (DE/EN), Einstellungen je Benutzer, ein
|
||||||
|
systemd-Dienst mit API + SPA auf einem Port, Install-/Update-Skript.
|
||||||
|
|
||||||
|
## Phase 1 – Konten, Kategorien, Transaktionen, Währung
|
||||||
|
Konten (feste Währung EUR/CHF, Typ, mehrere Identifikatoren, Anfangssaldo+Stichtag),
|
||||||
|
Kategorien (Gruppen→Kategorien, Einnahme/Ausgabe, SVG-Icons), Transaktionen
|
||||||
|
(Ausgabe/Einnahme/Transfer), EZB-Wechselkurse zum Buchungsdatum (lokal gecacht,
|
||||||
|
pro Buchung überschreibbar), Kontostände.
|
||||||
|
|
||||||
|
## Phase 2 – Budget-Engine
|
||||||
|
Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on-
|
||||||
|
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
|
||||||
|
kein Rollover in v1.
|
||||||
|
|
||||||
|
## Phase 3 – Dashboard
|
||||||
|
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
|
||||||
|
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
|
||||||
|
|
||||||
|
## Phase 4 – CSV-Import
|
||||||
|
Bank-Templates (Trennzeichen/Dezimal/Datum/Spaltenmapping/Vorzeichen), lernende
|
||||||
|
Auto-Kategorisierung, Prüfansicht, Duplikat-Erkennung, interne-Transfer-Erkennung
|
||||||
|
via IBAN.
|
||||||
|
|
||||||
|
## Phase 5 – PDF/Bild-Import (Stretch)
|
||||||
|
Lokal (pdfplumber/pypdf + Tesseract-OCR), speist dieselbe Prüfansicht.
|
||||||
|
|
||||||
|
## Querschnitt – Backup/Export
|
||||||
|
DB-Backup-Download + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>Certain Finance</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1985
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"i18next": "^24.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.4.0",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="8" fill="#4d5cd4" />
|
||||||
|
<path d="M8 21 L14 15 L18 18 L24 10" fill="none" stroke="#ffffff" stroke-width="2.6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="24" cy="10" r="2.1" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
+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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Im Dev-Modus wird /api an das lokale Backend weitergeleitet. Anders als in der
|
||||||
|
// Vorlage wird der /api-Präfix NICHT entfernt – das Backend bedient die Routen
|
||||||
|
// bewusst unter /api/... (ein Prozess liefert API und SPA gemeinsam aus).
|
||||||
|
// In Produktion liefert derselbe FastAPI-Prozess das gebaute Frontend mit aus.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user