From 2c795a57c8c7f19b217debd03702d09736734e77 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 19 Aug 2026 18:27:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=200=20=E2=80=93=20Ger=C3=BCst=20(?= =?UTF-8?q?FastAPI+SQLite,=20Multi-User-Login,=20React-App-Shell,=20Deploy?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 23 + README.md | 50 + backend/alembic.ini | 42 + backend/app/__init__.py | 0 backend/app/branding.py | 21 + backend/app/config.py | 55 + backend/app/database.py | 62 + backend/app/deps.py | 36 + backend/app/main.py | 97 + backend/app/migrate.py | 25 + backend/app/models.py | 98 + backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 82 + backend/app/routers/settings.py | 67 + backend/app/routers/users.py | 78 + backend/app/schemas.py | 43 + backend/app/security.py | 43 + backend/app/seed.py | 26 + backend/app/services/__init__.py | 0 backend/app/services/sessions.py | 78 + backend/migrations/env.py | 50 + backend/migrations/script.py.mako | 23 + backend/migrations/versions/0001_initial.py | 71 + backend/requirements.txt | 11 + deploy/README.md | 49 + deploy/certain-finance.service | 31 + deploy/env.example | 29 + deploy/install.sh | 191 ++ deploy/update.sh | 103 + docs/ARCHITECTURE.md | 59 + docs/ROADMAP.md | 35 + web/index.html | 13 + web/package-lock.json | 1985 +++++++++++++++++++ web/package.json | 22 + web/public/favicon.svg | 6 + web/src/App.jsx | 127 ++ web/src/api.js | 127 ++ web/src/auth.jsx | 98 + web/src/brand.js | 4 + web/src/components/BrandMark.jsx | 30 + web/src/components/ErrorBoundary.jsx | 74 + web/src/components/Icon.jsx | 178 ++ web/src/confirm.jsx | 98 + web/src/finance.css | 65 + web/src/format.js | 33 + web/src/i18n.js | 20 + web/src/locales/de.json | 85 + web/src/locales/en.json | 85 + web/src/main.jsx | 30 + web/src/pages/ChangePassword.jsx | 85 + web/src/pages/Dashboard.jsx | 18 + web/src/pages/Login.jsx | 79 + web/src/pages/Settings.jsx | 67 + web/src/pages/Users.jsx | 195 ++ web/src/settings.jsx | 75 + web/src/styles.css | 1142 +++++++++++ web/src/theme.js | 12 + web/src/toast.jsx | 69 + web/vite.config.js | 19 + 59 files changed, 6419 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/alembic.ini create mode 100644 backend/app/__init__.py create mode 100644 backend/app/branding.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/app/migrate.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/app/seed.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/sessions.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/0001_initial.py create mode 100644 backend/requirements.txt create mode 100644 deploy/README.md create mode 100644 deploy/certain-finance.service create mode 100644 deploy/env.example create mode 100755 deploy/install.sh create mode 100755 deploy/update.sh create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ROADMAP.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.jsx create mode 100644 web/src/api.js create mode 100644 web/src/auth.jsx create mode 100644 web/src/brand.js create mode 100644 web/src/components/BrandMark.jsx create mode 100644 web/src/components/ErrorBoundary.jsx create mode 100644 web/src/components/Icon.jsx create mode 100644 web/src/confirm.jsx create mode 100644 web/src/finance.css create mode 100644 web/src/format.js create mode 100644 web/src/i18n.js create mode 100644 web/src/locales/de.json create mode 100644 web/src/locales/en.json create mode 100644 web/src/main.jsx create mode 100644 web/src/pages/ChangePassword.jsx create mode 100644 web/src/pages/Dashboard.jsx create mode 100644 web/src/pages/Login.jsx create mode 100644 web/src/pages/Settings.jsx create mode 100644 web/src/pages/Users.jsx create mode 100644 web/src/settings.jsx create mode 100644 web/src/styles.css create mode 100644 web/src/theme.js create mode 100644 web/src/toast.jsx create mode 100644 web/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e87497d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a823e4 --- /dev/null +++ b/README.md @@ -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 /opt/certain-finance +cd /opt/certain-finance +sudo ./deploy/install.sh +``` + +Danach im **Nginx Proxy Manager** einen Proxy-Host auf `http://:` +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) diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..2352014 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/branding.py b/backend/app/branding.py new file mode 100644 index 0000000..97095f2 --- /dev/null +++ b/backend/app/branding.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..fb57822 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..c17acc1 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..3c52ec7 --- /dev/null +++ b/backend/app/deps.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1aeead7 --- /dev/null +++ b/backend/app/main.py @@ -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"}) diff --git a/backend/app/migrate.py b/backend/app/migrate.py new file mode 100644 index 0000000..7695a29 --- /dev/null +++ b/backend/app/migrate.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..8aacbad --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..ff44017 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..3259241 --- /dev/null +++ b/backend/app/routers/settings.py @@ -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) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..97c8716 --- /dev/null +++ b/backend/app/routers/users.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..542ce98 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..c756b70 --- /dev/null +++ b/backend/app/security.py @@ -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() diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..f049f43 --- /dev/null +++ b/backend/app/seed.py @@ -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() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/sessions.py b/backend/app/services/sessions.py new file mode 100644 index 0000000..5242cc3 --- /dev/null +++ b/backend/app/services/sessions.py @@ -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() diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..1b972c2 --- /dev/null +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..ba43f13 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..0078d0d --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..45e01f7 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..737836b --- /dev/null +++ b/deploy/README.md @@ -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 /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. diff --git a/deploy/certain-finance.service b/deploy/certain-finance.service new file mode 100644 index 0000000..bd9ba86 --- /dev/null +++ b/deploy/certain-finance.service @@ -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 diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 0000000..2f9d60e --- /dev/null +++ b/deploy/env.example @@ -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= diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..b8515e3 --- /dev/null +++ b/deploy/install.sh @@ -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:. +# +# 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/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" < "$UNIT_FILE" </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: " +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" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100755 index 0000000..75340ac --- /dev/null +++ b/deploy/update.sh @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..644ee97 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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:`. + +``` +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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..319faab --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..eb01b76 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Certain Finance + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5f95c7a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1985 @@ +{ + "name": "web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.411", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..81158e6 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..a9e5d17 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..694b446 --- /dev/null +++ b/web/src/App.jsx @@ -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 ( + + + {label} + + ); +} + +function Sidebar() { + const { user, isAdmin, logout } = useAuth(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate("/login"); + } + + return ( + + ); +} + +/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */ +function StartFehler({ text, erneut }) { + const { t } = useTranslation(); + return ( +
+
+ +
+
{text}
+
+ {t("server.sessionKept")} +
+
+
+ +
+ ); +} + +function Protected({ children, adminOnly = false, wide = false }) { + const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth(); + const { pathname } = useLocation(); + const { t } = useTranslation(); + + if (loading) return
{t("common.loading")}
; + if (startFehler) return ; + if (!user) return ; + // Vom Admin gesetztes Temporär-Passwort: erst ändern, dann weiter. + if (user.must_change_password && pathname !== "/passwort-aendern") { + return ; + } + if (adminOnly && !isAdmin) return ; + + return ( +
+ +
+
+ + {children} + +
+
+
+ ); +} + +export default function App() { + const { user, loading } = useAuth(); + + return ( + + : } + /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..f93c645 --- /dev/null +++ b/web/src/api.js @@ -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" }), +}; diff --git a/web/src/auth.jsx b/web/src/auth.jsx new file mode 100644 index 0000000..23cf9f5 --- /dev/null +++ b/web/src/auth.jsx @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/web/src/brand.js b/web/src/brand.js new file mode 100644 index 0000000..a780881 --- /dev/null +++ b/web/src/brand.js @@ -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"; diff --git a/web/src/components/BrandMark.jsx b/web/src/components/BrandMark.jsx new file mode 100644 index 0000000..e6a03a3 --- /dev/null +++ b/web/src/components/BrandMark.jsx @@ -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 ( +
+ + + + + + {showName && {APP_NAME}} +
+ ); +} diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..728722d --- /dev/null +++ b/web/src/components/ErrorBoundary.jsx @@ -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 ( +
+
+ +
+
+ {this.props.bereich ? `${this.props.bereich} konnte nicht angezeigt werden.` : "Etwas ist schiefgelaufen."} +
+
+ {fehler.message || String(fehler)} +
+
+
+ +
+ + +
+ + {/* Aufgeklappt nur auf Wunsch: die Angaben sind für einen Fehlerbericht + gedacht, nicht für den Alltag. */} +
+ Technische Angaben +
+            {fehler.stack || String(fehler)}
+            {info?.componentStack || ""}
+          
+
+
+ ); + } +} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx new file mode 100644 index 0000000..c3f9b29 --- /dev/null +++ b/web/src/components/Icon.jsx @@ -0,0 +1,178 @@ +// Schlankes Inline-SVG-Icon-Set (Lucide-Stil). Bewusst KEINE Emojis. +// Verwendung: oder +// Weitere Kategorie-/Kontotyp-Icons kommen in späteren Phasen dazu. + +const PATHS = { + // --- App-Rahmen --------------------------------------------------------- + dashboard: ( + <> + + + + + + ), + users: ( + <> + + + + + + ), + settings: ( + <> + + + + ), + logout: ( + <> + + + + + ), + + // --- Rückmeldungen / Aktionen ------------------------------------------ + alert: ( + <> + + + + ), + check: , + close: , + edit: ( + <> + + + + ), + plus: , + trash: ( + <> + + + + + + ), + search: ( + <> + + + + ), + download: ( + <> + + + + ), + "chevron-right": , + "refresh-ccw": ( + <> + + + + + + ), + + // --- Auth / Passwort ---------------------------------------------------- + key: ( + <> + + + + + ), + eye: ( + <> + + + + ), + "eye-off": ( + <> + + + + + + ), + + // --- Finance-Grundausstattung ------------------------------------------ + wallet: ( + <> + + + + + ), + landmark: ( + <> + + + + + + + + ), + list: ( + <> + + + + + + + + ), + "pie-chart": ( + <> + + + + ), + "arrow-left-right": ( + <> + + + + + + ), + coins: ( + <> + + + + + + ), +}; + +export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) { + const path = PATHS[name]; + if (!path) return null; + return ( + + ); +} diff --git a/web/src/confirm.jsx b/web/src/confirm.jsx new file mode 100644 index 0000000..46e97b6 --- /dev/null +++ b/web/src/confirm.jsx @@ -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 ( + + {children} + {frage && ( +
e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}> +
+
+ +

{frage.title || "Bitte bestätigen"}

+
+ {frage.message &&

{frage.message}

} + {frage.prompt && ( +
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + setEingabe(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") bestaetigen(); + if (e.key === "Escape") schliessen(null); + }} /> +
+ )} +
+ + +
+
+
+ )} +
+ ); +} + +export function useConfirm() { + return useContext(ConfirmContext); +} diff --git a/web/src/finance.css b/web/src/finance.css new file mode 100644 index 0000000..0f03a92 --- /dev/null +++ b/web/src/finance.css @@ -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); +} diff --git a/web/src/format.js b/web/src/format.js new file mode 100644 index 0000000..bc05bb7 --- /dev/null +++ b/web/src/format.js @@ -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); +} diff --git a/web/src/i18n.js b/web/src/i18n.js new file mode 100644 index 0000000..c70508f --- /dev/null +++ b/web/src/i18n.js @@ -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; diff --git a/web/src/locales/de.json b/web/src/locales/de.json new file mode 100644 index 0000000..b1e5376 --- /dev/null +++ b/web/src/locales/de.json @@ -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." + } +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json new file mode 100644 index 0000000..0b54ed2 --- /dev/null +++ b/web/src/locales/en.json @@ -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." + } +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..de54100 --- /dev/null +++ b/web/src/main.jsx @@ -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( + + + + + + + + + + + + + + + +); diff --git a/web/src/pages/ChangePassword.jsx b/web/src/pages/ChangePassword.jsx new file mode 100644 index 0000000..04976e6 --- /dev/null +++ b/web/src/pages/ChangePassword.jsx @@ -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 ; + 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 ( +
+
+ +

{t("changepw.title")}

+ {forced && ( +
+ + {t("changepw.forcedHint")} +
+ )} + {fehler && ( +
+ + {fehler} +
+ )} + + + + + + + +
+ ); +} diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx new file mode 100644 index 0000000..058b482 --- /dev/null +++ b/web/src/pages/Dashboard.jsx @@ -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 ( +
+

{t("dashboard.title")}

+
+
+ +

{t("dashboard.empty")}

+
+
+
+ ); +} diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx new file mode 100644 index 0000000..9d61775 --- /dev/null +++ b/web/src/pages/Login.jsx @@ -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 ( +
+
+ +

{t("login.subtitle")}

+ + {abgelaufen && ( +
+ + {t("login.expired")} +
+ )} + {fehler && ( +
+ + {fehler} +
+ )} + + + + + + + +
+ ); +} diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx new file mode 100644 index 0000000..2114e25 --- /dev/null +++ b/web/src/pages/Settings.jsx @@ -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 ( +
+

{t("settings.title")}

+ +
+ +

{t("settings.baseCurrencyHint")}

+ + + + + + +

{t("settings.budgetStartDayHint")}

+
+
+ ); +} diff --git a/web/src/pages/Users.jsx b/web/src/pages/Users.jsx new file mode 100644 index 0000000..1bada36 --- /dev/null +++ b/web/src/pages/Users.jsx @@ -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 ( +
+

{t("users.title")}

+ + {fehler && ( +
+ + {fehler} +
+ )} + +
+

{t("users.new")}

+
+
+ + +
+
+ + +
+

{t("users.passwordHint")}

+ +
+
+ +
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
{t("users.username")}{t("users.role")}{t("users.status")}{t("common.actions")}
+ {u.display_name || u.username} + {u.display_name && · {u.username}} + {u.id === self?.id && {t("users.you")}} + + {u.role === "admin" ? t("common.admin") : t("common.user")} + + + {u.is_active ? t("users.active") : t("users.inactive")} + + + + {u.id !== self?.id && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/web/src/settings.jsx b/web/src/settings.jsx new file mode 100644 index 0000000..39571ca --- /dev/null +++ b/web/src/settings.jsx @@ -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 {children}; +} + +export function useSettings() { + return useContext(SettingsContext); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..bca9783 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,1142 @@ +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f1f3f5; + --border: #e3e6ea; + --border-strong: #d1d6dc; + --text: #1a1f27; + --muted: #6b7480; + --accent: #3f51b5; + --accent-hover: #34429a; + --accent-soft: #eceefb; + --danger: #c0392b; + --danger-soft: #fbeceb; + --warn: #b06f14; + --warn-soft: #fbf3e6; + --ok: #2e7d55; + + --radius: 7px; + --radius-sm: 5px; + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sidebar-w: 236px; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06); + --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + font-family: var(--font); + background: var(--bg); + color: var(--text); + font-size: 14.5px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + /* Pfeil als Standard – sonst zeigt der Browser über jedem Text den Text-Cursor + (I-Beam), und alles sieht aus wie ein Eingabefeld. cursor wird vererbt. */ + cursor: default; +} + +/* Text-Cursor nur dort, wo man wirklich tippt. */ +textarea, +[contenteditable="true"], +input:where([type="text"], [type="number"], [type="search"], [type="email"], + [type="password"], [type="url"], [type="tel"]), +input:not([type]) { cursor: text; } + +/* Hand für klar Klickbares, das noch keine eigene Regel hat (Buttons, Sortier- + Header, Kacheln, Menüoptionen, klickbare Zeilen bringen sie schon selbst mit). */ +a[href], select, summary, +input[type="checkbox"], input[type="radio"], input[type="file"], +.checklist label, .check-inline, .enforce-toggle { cursor: pointer; } + +/* Reiner UI-Text (Titel, Labels, Bedienelemente) lässt sich nicht markieren – beim + Klicken poppt dort sonst ein blinkendes Text-Caret auf, das wie ein Eingabefeld + wirkt. Inhalte, die man wirklich kopieren will, bleiben markierbar: Tabellen- + zellen, Eingabefelder und Code/Token (sowie alles mit .selectable). */ +body { -webkit-user-select: none; user-select: none; } +.table td, input, textarea, [contenteditable="true"], code, pre, .selectable { + -webkit-user-select: text; user-select: text; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ---------- Scrollleisten ans Theme anpassen ---------- + Sonst blitzt in scrollbaren Karten/Tabellen die helle System-Scrollleiste + durch. Nutzt die Theme-Variablen, passt sich also hell/dunkel an. */ +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 8px; + border: 2px solid transparent; /* schlankerer Daumen mit runden Enden */ + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { background: var(--muted); } +::-webkit-scrollbar-corner { background: transparent; } + +h1 { font-size: 1.35rem; font-weight: 650; letter-spacing: -0.01em; margin: 0; } +h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spacing: -0.005em; } + +.icon { flex: 0 0 auto; vertical-align: middle; } + +/* ---------- App shell ---------- */ +.app-shell { display: flex; min-height: 100vh; } + +.sidebar { + width: var(--sidebar-w); + flex: 0 0 var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; +} +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-5) var(--sp-4) var(--sp-4); + font-weight: 680; + font-size: 1.05rem; + letter-spacing: -0.01em; +} +/* Eigenes Logo. Die Deckelung ist Absicht: Ein sehr grosses oder sehr breites + Bild darf die Seitenleiste nicht auseinanderziehen. Das Seitenverhaeltnis + bleibt durch object-fit erhalten, ueberstehendes wird nicht abgeschnitten. */ +.brand-logo { + display: block; + max-width: 100%; + max-height: 36px; + width: auto; + object-fit: contain; +} +/* Vorschau in den Einstellungen: Karomuster zeigt Transparenz. */ +.brand-preview { + display: grid; place-items: center; + min-height: 64px; padding: var(--sp-3); + border: 1px solid var(--border); border-radius: var(--radius-sm); + background-color: var(--bg); + background-image: + linear-gradient(45deg, var(--border) 25%, transparent 25%, transparent 75%, var(--border) 75%), + linear-gradient(45deg, var(--border) 25%, transparent 25%, transparent 75%, var(--border) 75%); + background-size: 14px 14px; + background-position: 0 0, 7px 7px; +} +.brand-preview img { max-width: 100%; max-height: 56px; width: auto; object-fit: contain; } + +.sidebar-brand .mark { + display: grid; place-items: center; + width: 28px; height: 28px; border-radius: var(--radius-sm); + background: var(--accent); color: #fff; +} +.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: var(--sp-2); flex: 1; overflow-y: auto; } +.nav-link { + display: flex; align-items: center; gap: var(--sp-3); + padding: 9px var(--sp-3); + border-radius: var(--radius-sm); + color: var(--muted); + font-weight: 520; + font-size: 0.9rem; +} +.nav-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; } +.nav-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.nav-section { + padding: var(--sp-4) var(--sp-3) var(--sp-1); + font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--muted); opacity: 0.7; +} +.sidebar-foot { + border-top: 1px solid var(--border); + padding: var(--sp-3); + display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); +} +.sidebar-user { display: flex; flex-direction: column; min-width: 0; } +.sidebar-user .name { font-weight: 600; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar-user .role { font-size: 0.72rem; color: var(--muted); } + +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.content { width: 100%; max-width: 1400px; margin: 0 auto; padding: var(--sp-6) var(--sp-5) 64px; } +/* Das Dashboard-Raster soll die volle Breite nutzen, nicht auf Lesebreite kappen. */ +.content--wide { max-width: none; } + +.page-head { + display: flex; align-items: center; justify-content: space-between; + gap: var(--sp-3); margin-bottom: var(--sp-5); flex-wrap: wrap; +} +.page-head .sub { color: var(--muted); font-size: 0.9rem; margin-top: 2px; } +.actions { display: flex; gap: var(--sp-2); } + +/* ---------- Cards ---------- */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-5); + box-shadow: var(--shadow); + margin-bottom: var(--sp-4); +} +.card-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4); } +.card-head h2 { margin: 0; } +.card-head .icon { color: var(--muted); } +/* Zwei Spalten, solange beide genug Platz haben - sonst automatisch + untereinander. auto-fit statt fester Umbruchbreite, damit es auch passt, + wenn das Fenster nur die halbe Bildschirmbreite einnimmt. + min(100%, …) verhindert, dass die Spalte auf schmalen Geraeten breiter + wird als der Bildschirm. */ +.grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: var(--sp-4); +} +/* Ohne min-width:0 schrumpfen Grid-Kinder nie unter ihre Inhaltsbreite - + dadurch lief die Tabelle aus ihrer Karte heraus und wurde abgeschnitten. */ +.grid-2 > * { min-width: 0; } +.form-narrow { max-width: 600px; } +/* Tabelle plus Block daneben: Die Tabelle hat mehr Spalten und bekommt + deshalb den groesseren Anteil - sonst rutscht sie unnoetig frueh in die + Kartenansicht, obwohl daneben Platz frei waere. */ +/* Tabelle bekommt den ganzen restlichen Platz, der Block daneben bleibt schmal + und gedeckelt - so scrollt die (breite) Tabelle nicht seitwaerts. */ +.grid-2.wide-aside { grid-template-columns: minmax(0, 1fr) clamp(320px, 24%, 420px); align-items: start; } +@media (max-width: 1000px) { + .grid-2.wide-aside { grid-template-columns: minmax(0, 1fr); } +} +/* Artikelseite (Bearbeiten): links das Formular - dessen Felder brauchen keine + halbe Seite -, rechts die Chargen-/Bestandstabelle, die den restlichen Platz + bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander + passen. Spiegelbild von .wide-aside (dort steht die Tabelle links). */ +.grid-2.form-plus-table { grid-template-columns: clamp(480px, 38%, 720px) minmax(0, 1fr); align-items: start; } +@media (max-width: 1000px) { + .grid-2.form-plus-table { grid-template-columns: minmax(0, 1fr); } +} + +/* ---------- Forms ---------- */ +label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; } +input, select { + width: 100%; margin-top: 5px; + padding: 8px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: 0.9rem; font-family: inherit; + background: var(--surface); color: var(--text); + transition: border-color .12s, box-shadow .12s; +} +input:focus, select:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +input::placeholder { color: var(--muted); opacity: 0.7; } +.row { display: flex; gap: var(--sp-3); align-items: flex-start; } +.row .grow { flex: 1; } + +/* Artikelbild neben den Kennfeldern. min-width:0 an der Feldspalte, sonst + drueckt ein langer Produktname die Eingaben aus der Karte heraus. */ +.produkt-kopf { display: flex; gap: var(--sp-4); align-items: flex-start; } +.produkt-kopf-felder { flex: 1 1 auto; min-width: 0; } +/* Hochkant: Lebensmittelverpackungen sind fast immer hoeher als breit - + in einem quadratischen Rahmen blieben links und rechts leere Streifen. */ +.produkt-bild { + flex: 0 0 auto; + width: 132px; aspect-ratio: 3 / 4; + object-fit: contain; /* Verpackungen nie beschneiden */ + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-2); +} +.produkt-bild.clickable { cursor: zoom-in; } +/* Auf schmalen Fenstern haben Bild und Felder nebeneinander keinen Platz mehr. */ +@media (max-width: 560px) { + .produkt-kopf { flex-direction: column-reverse; align-items: center; } +} + +/* Bild-Lightbox: Klick aufs Artikelbild zeigt es gross (Schliessen per Knopf, + Klick auf den Hintergrund oder Escape). */ +.lightbox { + position: fixed; inset: 0; z-index: 200; + display: flex; align-items: center; justify-content: center; + padding: var(--sp-5); + background: rgba(0, 0, 0, 0.82); +} +.lightbox-img { + max-width: 92vw; max-height: 92vh; object-fit: contain; + border-radius: var(--radius); + box-shadow: 0 16px 56px rgba(0, 0, 0, 0.5); + cursor: default; +} +.lightbox-close { + position: fixed; top: 16px; right: 16px; + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 50%; + background: var(--surface); color: var(--text); + border: 1px solid var(--border); cursor: pointer; +} +.lightbox-close:hover { background: var(--surface-2); } + +/* Knoepfe neben dem Barcode-Feld: nebeneinander, mit Umbruch auf schmalen + Fenstern. Das Eingabefeld daneben fuellt die restliche Breite. */ +.knopf-spalte { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 1 auto; min-width: 0; } +/* Kein nowrap: in einer schmalen Formularspalte darf ein langer Button-Text + ("Mit Open Food Facts vergleichen") umbrechen statt abgeschnitten zu werden. */ +.knopf-spalte .btn { justify-content: center; white-space: normal; min-width: 0; } + +/* Gegenueberstellung mit Open Food Facts */ +.off-vergleich { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: var(--sp-3); + margin-bottom: var(--sp-4); +} +.off-kopf { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-3); } +.off-kopf h3 { font-size: 0.95rem; margin: 0; flex: 1; } +.off-tabelle { background: var(--surface); } +/* Klein genug, dass die Zeile nicht auseinandergeht, gross genug zum Erkennen. */ +.off-bild { + width: 56px; aspect-ratio: 3 / 4; object-fit: contain; + background: var(--surface-2); border-radius: 4px; display: block; +} +.off-neu { display: flex; align-items: center; gap: var(--sp-2); } +.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); } +/* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst + rutschen daneben stehende Buttons optisch nach unten. */ +.field-inline > label { margin-bottom: 0; } +/* Verschachtelt in einem Label (z.B. Menge + Einheit) traegt das Label den Abstand. */ +label .field-inline { margin-bottom: 0; } +/* In Tabellenzellen (z.B. Chargen-Aktionen) keinen zusaetzlichen Abstand; + bei wenig Platz lieber umbrechen als die Felder zusammenquetschen. */ +td .field-inline { margin-bottom: 0; flex-wrap: wrap; } +/* Eingaben haben global width:100% und wuerden im Flex-Layout zusammenschrumpfen. + Zahlenfelder duerfen wachsen, Einheiten-Dropdowns behalten ihre Textbreite. */ +.field-inline > input { flex: 1 1 auto; min-width: 78px; } +.field-inline > select { flex: 0 0 auto; width: auto; min-width: 132px; } +/* Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der Zelle. */ +/* Auswahlfelder richten sich nach ihrem Text. Die fruehere Mindestbreite von + 132px machte schmale Spalten (z.B. "Einheit") unnoetig breit. */ +td select { width: auto; min-width: 0; max-width: 100%; } +/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */ +/* Mindestbestände: eine Zeile je Eintrag in FESTEN Spalten. + Vorher lag hier ein .field-inline – dort wachsen Name und Eingabefeld beide + (.field-inline > input { flex: 1 1 auto }) und teilen sich den Restplatz. + Die Felder standen dadurch je nach Namenslänge woanders. */ +.min-zeile { + display: grid; + grid-template-columns: minmax(0, 1fr) 110px 150px 150px 160px 36px; + gap: var(--sp-2); + align-items: center; +} +.min-zeile > * { min-width: 0; } +.min-zeile input, .min-zeile select { margin-top: 0; width: 100%; min-width: 0; } +.min-zeile .bestand { text-align: right; } +/* Schmal: Name über die volle Breite, darunter die Felder nebeneinander. */ +@media (max-width: 900px) { + .min-zeile { grid-template-columns: 1fr 1fr 1fr 36px; } + .min-zeile > .name { grid-column: 1 / -1; } + .min-zeile .bestand { text-align: left; } +} + +.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; } +.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: var(--sp-2); + padding: 8px 14px; + border: 1px solid var(--border-strong); + background: var(--surface); + border-radius: var(--radius-sm); + font-size: 0.875rem; font-weight: 560; font-family: inherit; + cursor: pointer; color: var(--text); + transition: background .12s, border-color .12s, opacity .12s; + white-space: nowrap; +} +.btn:hover { background: var(--surface-2); text-decoration: none; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { background: var(--accent-hover); } +.btn.danger { color: var(--danger); border-color: transparent; background: transparent; } +.btn.danger:hover { background: var(--danger-soft); } +.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); } +.btn.ghost:hover { background: var(--surface-2); color: var(--text); } +.btn.sm { padding: 5px 9px; font-size: 0.8rem; } +.btn:disabled { opacity: 0.55; cursor: default; } +.btn-icon { padding: 6px; border-color: transparent; background: transparent; color: var(--muted); } +.btn-icon:hover { background: var(--surface-2); color: var(--text); } +.btn-icon.danger:hover { background: var(--danger-soft); color: var(--danger); } +.link-btn { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; padding: 0; text-align: left; } +.link-btn:hover { text-decoration: underline; } + +/* ---------- Tables ---------- */ +/* container-type: Die Tabelle richtet sich nach dem Platz, den sie wirklich + hat - nicht nach der Bildschirmbreite. So stellt sie sich auch um, wenn das + Fenster nur die halbe Bildschirmbreite einnimmt oder ein Block danebensteht. */ +.table-wrap { max-width: 100%; container-type: inline-size; overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.table th, .table td { + text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); +} +/* Frueher stand hier "overflow-wrap: anywhere" fuer ALLE Zellen. Das zerriss + Woerter mitten drin ("PROD UKTE", "555 Gram m"), sobald eine Spalte knapp + wurde. Umbrechen nur noch dort, wo wirklich lange Zeichenketten stehen. */ +.break-anywhere { overflow-wrap: anywhere; } +.nowrap { white-space: nowrap; } +.table thead th { + color: var(--muted); font-weight: 600; font-size: 0.72rem; + text-transform: uppercase; letter-spacing: 0.04em; + border-bottom: 1px solid var(--border-strong); + white-space: nowrap; +} +/* Werte aus Zahl + Einheit gehoeren zusammen und duerfen nicht umbrechen. */ +.table td.num { white-space: nowrap; } +/* Eingaben in Tabellen kompakt halten - mit voller Feldhoehe blaeht sich jede + Zeile auf und die Spalten werden unnoetig breit. */ +.table td input, .table td select { padding: 5px 8px; font-size: 0.85rem; } +.table td input[type="number"] { width: 8ch; min-width: 0; } +/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das + Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */ +.cell-row { display: flex; align-items: center; gap: 6px; } +/* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */ +.cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; } +/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass + dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */ +.tip { border-bottom: 1px dotted var(--border-strong); } + +/* Auf-/Zuklappen von Unterkategorien */ +.tree-toggle { padding: 2px; flex: 0 0 auto; } +.tree-toggle .icon { transition: transform 0.12s ease; } +.tree-toggle .icon.rotated { transform: rotate(90deg); } +/* Platzhalter, damit Zeilen ohne Unterkategorien buendig zu denen mit stehen. */ +.tree-spacer { display: inline-block; flex: 0 0 auto; width: 24px; } + +/* Aufklappbarer Kategorie-Baum als Auswahlfeld (CategorySelect). Der Knopf sieht + aus wie ein