feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)

Grundgerüst für Certain Finance:
- Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode),
  argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin,
  Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer.
- Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus,
  inkl. SPA-Deeplink-Fallback.
- Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN,
  Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter,
  Benutzer- und Einstellungsseiten.
- Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku.

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