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