diff --git a/.env.example b/.env.example deleted file mode 100644 index 46fa42a..0000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Nur beim ersten Start mit leerer Benutzertabelle erforderlich. -AUTH_BOOTSTRAP_USERNAME= -AUTH_BOOTSTRAP_PASSWORT= -AUTH_SESSION_HOURS=12 -AUTH_SESSION_MAX_DAYS=7 -# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false. -COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore index b237969..a6e1ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ node_modules/ dist/ .next/ -.env -.env.* -!.env.example # Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. /drizzle.config.js data/*.db diff --git a/AGENTS.md b/AGENTS.md index b01e6b5..93e5108 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,16 +36,6 @@ It must support circuits, device rows, project devices, drag-and-drop restructur - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard repositories expose only active reads. Runtime writes belong in typed command repositories; direct integration fixtures belong under `tests/support`. -- Authentication: session-cookie login with two fixed roles (`admin`, `user`), - no self-signup. `src/server/auth/*` (password hashing, sessions, Express - middleware), `src/domain/services/user.service.ts`, - `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a - signed-in user (`src/server/index.ts`); `/api/users/*` additionally - requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` - (`src/app/layout.tsx`). Every unsafe API request also requires the explicit - `X-Leistungsbilanz-Request: 1` application header, including over plain HTTP. - Account settings are available to every signed-in user; user administration - remains admin-only. See `docs/authentication.md` for the full picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. @@ -742,11 +732,6 @@ for the early-phase power balance by DIN 276 cost group (see - second/replacement board feeds, including changeover semantics for SV/SiBe - Revit prefill proposals for logical board feeds through the Phase 14 preview and explicit-confirmation workflow -- configurable role/rights matrix instead of the fixed `admin`/`user` roles -- permanent account lockout (only temporary per-username login throttling - exists) -- email/one-time-link password provisioning, OIDC/Entra login -- linking user accounts to a personnel/employee master table Do not implement these while working on an unrelated phase. diff --git a/README.md b/README.md index f64ae04..b01ae85 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,7 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein Docker-Server-Stack mit gebautem Anwendungscode und ein separater Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment -für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes -Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind -vorhanden. Revit-CSV-Konfiguration, Vorschau +und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau und bestätigter Erstimport sind implementiert; die Editoroberfläche zur Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import @@ -62,8 +60,6 @@ Voraussetzungen: ```powershell git clone Set-Location leistungsbilanz-ts -$env:AUTH_BOOTSTRAP_USERNAME = "admin" -$env:AUTH_BOOTSTRAP_PASSWORT = "" docker compose up --build --detach ``` @@ -72,15 +68,9 @@ Danach: - Frontend: - API-Healthcheck: (über den Next.js-Rewrite) -Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator, -solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für -einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte -`.env` neben `compose.yaml`; Details stehen unter -[Anmeldung und Benutzerverwaltung](docs/authentication.md). - -Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen -API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den -Rewrite im Web-Dienst. +Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine +Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der +Zugriff läuft über den Rewrite im Web-Dienst. Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start automatisch aus. Die SQLite-Datei liegt auf dem Host unter @@ -126,8 +116,6 @@ Voraussetzungen: npm ci npm run db:migrate npm run db:verify:circuit-schema -$env:AUTH_BOOTSTRAP_USERNAME = "admin" -$env:AUTH_BOOTSTRAP_PASSWORT = "" npm run dev:api ``` @@ -174,7 +162,6 @@ vor der Baseline werden nicht unterstützt. - [Gebäudestruktur und Raumflächen](docs/building-structure.md) - [Grobe Leistungsbilanz](docs/estimate-balance.md) - [Leitungsberechnung](docs/cable-sizing-module.md) -- [Anmeldung und Benutzerverwaltung](docs/authentication.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md) diff --git a/compose.dev.yaml b/compose.dev.yaml index f49e9e6..b9c64b4 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -35,12 +35,8 @@ services: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" - AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" - AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" - AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" - COOKIE_SECURE: "${COOKIE_SECURE:-false}" - # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. + # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf + # nicht aus dem LAN erreichbar sein. ports: - "127.0.0.1:3000:3000" # Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als diff --git a/compose.yaml b/compose.yaml index 1de9104..3438f90 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,16 +45,8 @@ services: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die - # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte - # .env - siehe docs/authentication.md. - AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" - AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" - AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" - AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" - COOKIE_SECURE: "${COOKIE_SECURE:-false}" - # Nicht auf dem Host veroeffentlicht: die API wird ueber den - # Next.js-Rewrite im Web-Dienst erreicht. + # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung + # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. expose: - "3000" # Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit diff --git a/docs/README.md b/docs/README.md index 3569b42..01b5588 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,8 +23,6 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we bewusst noch nicht implementierte oder begrenzte Funktionen - [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul, Rechenweg, Datenbasis und Prüfnachweis der Tabellen -- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim - Anlegen eines Zugangs, Sessions, Bootstrap-Admin ## Entwicklung und Betrieb diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index 32039cf..0000000 --- a/docs/authentication.md +++ /dev/null @@ -1,99 +0,0 @@ -# Anmeldung und Benutzerverwaltung - -Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder -Zugang wird von einem Administrator über **Config → Benutzer** angelegt. - -## Rollen - -Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): - -- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer** -- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, - Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung - -Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine -Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. - -## Ablauf beim Anlegen eines Zugangs - -1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit - Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst - eintippt. -2. Der Zugang startet mit `mustChangePassword = true`. -3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das - ein eigenes Passwort verlangt, bevor es weitergeht. -4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues - Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn - jemand sein Passwort vergessen hat. - -## Technischer Aufbau - -- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) – - `username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`, - `role`, `active`, `mustChangePassword`. -- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie - `lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit - absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein - Neustart der API beendet alle Sessions – für einen Single-Container-Dienst - ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle. -- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*` - (außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und - erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist. - `/api/users/*` verlangt zusätzlich die Rolle `admin`. -- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate` - (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht - angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. - **Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für - Administratoren. - -## Schutzmechanismen - -| Angriff | Schutz | -|---|---| -| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). | -| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash | -| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung | -| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. | -| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. | -| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel | -| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) | -| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen | -| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. | -| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` | -| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt | -| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben | -| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) | -| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt | - -**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im -Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen -Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen. - -## Bootstrap-Admin - -`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe -`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die -`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen -ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte -gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht -in `compose.yaml` selbst. - -```dotenv -AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORT= -AUTH_SESSION_HOURS=12 -AUTH_SESSION_MAX_DAYS=7 -COOKIE_SECURE=false -``` - -Bei Zugriff über HTTPS muss `COOKIE_SECURE=true` gesetzt werden. Bei direktem -HTTP-Zugriff im LAN bleibt es `false`, da der Browser das Session-Cookie sonst -nicht speichert. - -## Bewusst nicht umgesetzt (Stand jetzt) - -- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen -- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich - begrenzte Drosselung, siehe oben) -- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung -- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 20c7ccd..71b7feb 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -33,15 +33,6 @@ mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die Datenbank liegt über einen Host-Mount außerhalb des Containers. -Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus -`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar. -Schreibzugriffe verlangen zusätzlich den Anwendungsheader -`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN -ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren -vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu -Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher -des einzelnen API-Prozesses und enden bei dessen Neustart. - Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls @@ -66,11 +57,6 @@ vor dem Command und bleiben über Undo/Redo unverändert. - `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion, Zellbesitz, Einfügen und Sicherheitsregeln - `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe -- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung - und erzwungener Wechsel eines Startpassworts -- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz -- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` – - kontobezogene Regeln und schmale Persistenzgrenze - `src/server/index.ts` und `src/server/routes/` – API-Komposition - `src/domain/services/` – fachliche Command- und Synchronisierungsregeln - `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten diff --git a/docs/deployment.md b/docs/deployment.md index 89c99a3..0b5baba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen Entwicklungsrechner. -Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen -vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem. -Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt -übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden. +Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten +Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten +Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf +deshalb nicht öffentlich erreichbar gemacht werden. ## Topologie @@ -29,10 +29,9 @@ Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt | Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` | | SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | -Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht -(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive -Session; Schreibzugriffe zusätzlich den Anwendungsheader -`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem +Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht +(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie +erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. @@ -73,26 +72,6 @@ Verwendete Umgebungsvariablen: JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei - leerer Benutzertabelle den ersten Administrator; das Startpasswort hat - mindestens 12 Zeichen. -- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden. -- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage. -- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN - `false`, da Browser das Cookie sonst nicht speichern. - -Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen, -nicht versionierten `.env` neben `compose.yaml` gesetzt werden: - -```dotenv -AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORT= -``` - -Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange -mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in -**Config → Benutzer** an. Details stehen in -[Anmeldung und Benutzerverwaltung](authentication.md). Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung (`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im @@ -158,8 +137,7 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie. Vor einer produktiven Installation werden mindestens benötigt: - TLS-Termination und Reverse Proxy -- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei - festen Rollen später nicht ausreichen +- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung - persistenter, gesicherter Datenbankbetrieb - kontrollierter einmaliger Migrationsschritt vor dem API-Rollout diff --git a/next.config.mjs b/next.config.mjs index a6cb01e..e0610b9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,22 +11,6 @@ const nextConfig = { typescript: { tsconfigPath: "./tsconfig.next.json", }, - poweredByHeader: false, - // Clickjacking: the Config pages hold admin actions, so no foreign page - // may frame the app. Same-origin framing stays allowed. - async headers() { - return [ - { - source: "/:path*", - headers: [ - { key: "X-Frame-Options", value: "SAMEORIGIN" }, - { key: "Content-Security-Policy", value: "frame-ancestors 'self'" }, - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "Referrer-Policy", value: "same-origin" }, - ], - }, - ]; - }, async rewrites() { return [ { diff --git a/src/app/config/benutzer/page.tsx b/src/app/config/benutzer/page.tsx deleted file mode 100644 index 134890d..0000000 --- a/src/app/config/benutzer/page.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api"; -import { useAuth } from "../../../frontend/components/AuthProvider"; -import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; - -const zeitpunkt = new Intl.DateTimeFormat("de-DE", { - dateStyle: "short", - timeStyle: "short", -}); - -export default function BenutzerPage() { - const { user: currentUser } = useAuth(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [busyUserId, setBusyUserId] = useState(null); - const [resetPasswordFor, setResetPasswordFor] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - try { - const rows = await userApi.list(); - setUsers(rows); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden."); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - async function withBusy(userId: string, action: () => Promise) { - setBusyUserId(userId); - setError(null); - try { - await action(); - } catch (err) { - setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); - } finally { - setBusyUserId(null); - } - } - - async function changeRole(item: UserDto, role: UserRole) { - await withBusy(item.id, async () => { - const updated = await userApi.update(item.id, { role }); - setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); - }); - } - - async function toggleActive(item: UserDto) { - await withBusy(item.id, async () => { - const updated = await userApi.update(item.id, { active: !item.active }); - setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); - }); - } - - return ( -
-
-

- Benutzer -

-
-

- Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem - Administrator angelegt. Das Startpasswort wird direkt eingetragen; die - Person muss es beim ersten Anmelden durch ein eigenes ersetzen. -

- - { - setUsers((list) => [...list, created]); - }} - /> - - {error &&

{error}

} - {loading &&

Lädt…

} - - {!loading && users.length > 0 && ( - - - - - - - - - - - - - - {users.map((item) => { - const isSelf = item.id === currentUser?.id; - const busy = busyUserId === item.id; - return ( - - - - - - - - - - ); - })} - -
NameAnmeldenameRolleAktivStatusAngelegtAktionen
{item.name}{item.username} - - - void toggleActive(item)} - /> - {item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}{zeitpunkt.format(new Date(item.createdAt))} - -
- )} - - {!loading && users.length === 0 &&

Noch keine Benutzer angelegt.

} - - {resetPasswordFor && ( - setResetPasswordFor(null)} - onDone={(updated) => { - setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); - setResetPasswordFor(null); - }} - /> - )} -
- ); -} - -function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) { - const [username, setUsername] = useState(""); - const [name, setName] = useState(""); - const [role, setRole] = useState("user"); - const [initialPassword, setInitialPassword] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitting(true); - setError(null); - try { - const created = await userApi.create({ username, name, role, initialPassword }); - onCreated(created); - setUsername(""); - setName(""); - setRole("user"); - setInitialPassword(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden."); - } finally { - setSubmitting(false); - } - } - - return ( -
-
- - setUsername(event.target.value)} - required - /> -
-
- - setName(event.target.value)} - required - /> -
-
- - -
-
- - setInitialPassword(event.target.value)} - minLength={8} - required - /> -
-
- -
- {error && ( -
-

{error}

-
- )} -
- ); -} - -function PasswortZuruecksetzenDialog({ - item, - onClose, - onDone, -}: { - item: UserDto; - onClose: () => void; - onDone: (updated: UserDto) => void; -}) { - const [newPassword, setNewPassword] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitting(true); - setError(null); - try { - const updated = await userApi.resetPassword(item.id, newPassword); - onDone(updated); - } catch (err) { - setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden."); - } finally { - setSubmitting(false); - } - } - - return ( -
-
-

Passwort zurücksetzen für {item.name}

-
- - setNewPassword(event.target.value)} - minLength={8} - autoFocus - required - /> -
- {error &&

{error}

} -
- - -
-
-
- ); -} diff --git a/src/app/config/konto/page.tsx b/src/app/config/konto/page.tsx deleted file mode 100644 index 2b58ee7..0000000 --- a/src/app/config/konto/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { authApi } from "../../../frontend/utils/auth-api"; -import { useAuth } from "../../../frontend/components/AuthProvider"; -import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; - -export default function KontoPage() { - const { user } = useAuth(); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - if (newPassword !== newPasswordRepeat) { - setError("Die beiden neuen Passwörter stimmen nicht überein."); - return; - } - setSubmitting(true); - setError(null); - setSuccess(false); - try { - await authApi.changePassword(currentPassword, newPassword); - setCurrentPassword(""); - setNewPassword(""); - setNewPasswordRepeat(""); - setSuccess(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); - } finally { - setSubmitting(false); - } - } - - return ( -
-

Konto

- {user && ( -

- Angemeldet als {user.name} ({user.username}) ·{" "} - {USER_ROLE_LABEL[user.role]} -

- )} - -

Passwort ändern

-
-
- - setCurrentPassword(event.target.value)} - autoComplete="current-password" - required - /> -
-
- - setNewPassword(event.target.value)} - autoComplete="new-password" - minLength={8} - required - /> -
-
- - setNewPasswordRepeat(event.target.value)} - autoComplete="new-password" - minLength={8} - required - /> -
- {error &&

{error}

} - {success &&

Passwort wurde geändert.

} - -
-
- ); -} diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx deleted file mode 100644 index 72d9b6d..0000000 --- a/src/app/config/layout.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { useAuth } from "../../frontend/components/AuthProvider"; -import { - getConfigRedirect, - getConfigTabs, -} from "../../frontend/utils/config-access"; - -export default function ConfigLayout({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const router = useRouter(); - const { status, isAdmin } = useAuth(); - const tabs = getConfigTabs(isAdmin); - const redirectTarget = getConfigRedirect(pathname, isAdmin); - - useEffect(() => { - if (status !== "signed-in") return; - if (redirectTarget) router.replace(redirectTarget); - }, [status, redirectTarget, router]); - - if ( - status !== "signed-in" || - redirectTarget !== null - ) { - return null; - } - - return ( -
-
-

Config

-
- - - - {children} -
- ); -} diff --git a/src/app/config/page.tsx b/src/app/config/page.tsx deleted file mode 100644 index 3c9d8fd..0000000 --- a/src/app/config/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ConfigPage() { - return null; -} diff --git a/src/app/globals.css b/src/app/globals.css index 8b55c75..88df3e9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -323,24 +323,23 @@ a { } .sidebar-footer { - padding-top: 12px; + padding: 12px; border-top: 1px solid rgba(255, 255, 255, 0.12); } -/* Square and icon-only so it fits next to Abmelden in .sidebar-bottom-row. */ .sidebar-theme-toggle { display: flex; align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 36px; - height: 36px; - padding: 0; + gap: 10px; + padding: 10px 14px; border-radius: 8px; color: #c7d6e0; font-size: 0.92rem; + font-weight: 500; border: 1px solid rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.06); + width: 100%; + text-align: left; cursor: pointer; } @@ -452,25 +451,13 @@ a { background: var(--color-bg); } -.filter-panel-row { +.filter-panel-header { display: flex; align-items: center; gap: 10px; } -/* Expanded: the fields carry a label above their input, so toggle and - count sit on the input line (bottom) instead of the label line. */ -.filter-panel-row-expanded { - align-items: flex-end; -} - -.filter-panel-row-expanded .filter-panel-toggle, -.filter-panel-row-expanded .filter-panel-count { - margin-bottom: calc((2.1rem - 24px) / 2); -} - -.filter-panel-row .filter-panel-toggle { - flex: 0 0 auto; +.filter-panel-header .filter-panel-toggle { width: 24px; height: 24px; padding: 0; @@ -480,7 +467,7 @@ a { color: var(--color-ink-soft); } -.filter-panel-row .filter-panel-toggle:hover { +.filter-panel-header .filter-panel-toggle:hover { background: var(--color-surface); } @@ -515,11 +502,10 @@ a { .filter-bar { display: flex; - flex: 1 1 auto; - min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: var(--space-sm) var(--space-md); + margin-top: 0.5rem; } .filter-bar-field, @@ -2744,128 +2730,3 @@ button.structure-entry-main:focus-visible { justify-content: center; } } - -/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */ - -.sidebar-nav-bottom { - flex: 0 0 auto; - border-top: 1px solid rgba(255, 255, 255, 0.12); -} - -.sidebar-identity { - display: flex; - flex-direction: column; - gap: 1px; - padding: 8px 14px 10px; -} - -.sidebar-identity-label { - font-size: 0.66rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(199, 214, 224, 0.7); -} - -.sidebar-identity-name { - font-size: 0.88rem; - font-weight: 600; - color: #fff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Toggle (square) and Abmelden (fills the rest) share one row. */ -.sidebar-bottom-row { - display: flex; - align-items: stretch; - gap: 8px; - padding: 0 14px 12px; -} - -.sidebar-logout { - display: flex; - align-items: center; - justify-content: center; - flex: 1 1 auto; - min-width: 0; - padding: 8px 14px; - border-radius: 8px; - color: #c7d6e0; - font-size: 0.85rem; - font-weight: 500; - border: 1px solid rgba(255, 255, 255, 0.16); - background: transparent; - cursor: pointer; -} - -.sidebar-logout:hover { - background: rgba(255, 255, 255, 0.1); - color: #fff; -} - -.sidebar-collapsed .sidebar-identity { - display: none; -} - -.sidebar-collapsed .sidebar-bottom-row { - justify-content: center; - padding-inline: 0; -} - -.login-gate-screen { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md); - background: linear-gradient( - 160deg, - var(--color-primary-deep) 0%, - var(--color-primary-dark) 45%, - var(--color-primary) 100% - ); -} - -.login-gate-card { - width: 100%; - max-width: 360px; - padding: 28px 26px; -} - -.config-tabs { - display: flex; - gap: 4px; - border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); - margin-bottom: var(--space-md); -} - -.config-tab { - padding: 8px 16px; - border-radius: 6px 6px 0 0; - color: inherit; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - opacity: 0.7; -} - -.config-tab:hover { - opacity: 1; - text-decoration: none; -} - -.config-tab.active { - opacity: 1; - box-shadow: inset 0 -2px 0 var(--color-primary); -} - -.modal-backdrop-inline { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.45); - display: flex; - align-items: center; - justify-content: center; - z-index: 1050; -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c830c99..cd2407b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,6 @@ import Script from "next/script"; import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; import { AppShell } from "../frontend/components/AppShell"; -import { AuthProvider } from "../frontend/components/AuthProvider"; -import { LoginGate } from "../frontend/components/LoginGate"; export const metadata: Metadata = { title: "Leistungsbilanz", @@ -38,11 +36,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac - - - {children} - - + {children} ); diff --git a/src/db/migrations/0013_users.sql b/src/db/migrations/0013_users.sql deleted file mode 100644 index 6c07010..0000000 --- a/src/db/migrations/0013_users.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE `users` ( - `id` text PRIMARY KEY NOT NULL, - `username` text NOT NULL, - `password_hash` text NOT NULL, - `name` text NOT NULL, - `role` text DEFAULT 'user' NOT NULL, - `active` integer DEFAULT true NOT NULL, - `must_change_password` integer DEFAULT true NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL, - `updated_at` integer DEFAULT (unixepoch()) NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/src/db/migrations/meta/0013_snapshot.json b/src/db/migrations/meta/0013_snapshot.json deleted file mode 100644 index bd091fb..0000000 --- a/src/db/migrations/meta/0013_snapshot.json +++ /dev/null @@ -1,3072 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "a967fdfb-171b-4f1d-aa0c-f7124e28fba3", - "prevId": "8029e6ab-56de-4345-8d38-0ca7462640de", - "tables": { - "buildings": { - "name": "buildings", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "buildings_project_id_projects_id_fk": { - "name": "buildings_project_id_projects_id_fk", - "tableFrom": "buildings", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuit_device_rows": { - "name": "circuit_device_rows", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "circuit_id": { - "name": "circuit_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "linked_project_device_id": { - "name": "linked_project_device_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "phase_type": { - "name": "phase_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "connection_kind": { - "name": "connection_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cost_group": { - "name": "cost_group", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "category": { - "name": "category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "level": { - "name": "level", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "room_id": { - "name": "room_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "room_number_snapshot": { - "name": "room_number_snapshot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "room_name_snapshot": { - "name": "room_name_snapshot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "quantity": { - "name": "quantity", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "manual_quantity": { - "name": "manual_quantity", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "power_per_unit": { - "name": "power_per_unit", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "simultaneity_factor": { - "name": "simultaneity_factor", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cos_phi": { - "name": "cos_phi", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "remark": { - "name": "remark", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "overridden_fields": { - "name": "overridden_fields", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "circuit_device_rows_circuit_id_idx": { - "name": "circuit_device_rows_circuit_id_idx", - "columns": [ - "circuit_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "circuit_device_rows_circuit_id_circuits_id_fk": { - "name": "circuit_device_rows_circuit_id_circuits_id_fk", - "tableFrom": "circuit_device_rows", - "tableTo": "circuits", - "columnsFrom": [ - "circuit_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { - "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", - "tableFrom": "circuit_device_rows", - "tableTo": "project_devices", - "columnsFrom": [ - "linked_project_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "circuit_device_rows_room_id_rooms_id_fk": { - "name": "circuit_device_rows_room_id_rooms_id_fk", - "tableFrom": "circuit_device_rows", - "tableTo": "rooms", - "columnsFrom": [ - "room_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuit_list_equipment_identifiers": { - "name": "circuit_list_equipment_identifiers", - "columns": { - "owner_type": { - "name": "owner_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "circuit_list_id": { - "name": "circuit_list_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "equipment_identifier": { - "name": "equipment_identifier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "circuit_list_equipment_identifiers_owner_unique": { - "name": "circuit_list_equipment_identifiers_owner_unique", - "columns": [ - "owner_type", - "owner_id" - ], - "isUnique": true - }, - "circuit_list_equipment_identifiers_list_identifier_unique": { - "name": "circuit_list_equipment_identifiers_list_identifier_unique", - "columns": [ - "circuit_list_id", - "equipment_identifier" - ], - "isUnique": true - } - }, - "foreignKeys": { - "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { - "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", - "tableFrom": "circuit_list_equipment_identifiers", - "tableTo": "circuit_lists", - "columnsFrom": [ - "circuit_list_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuit_lists": { - "name": "circuit_lists", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "distribution_board_id": { - "name": "distribution_board_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "circuit_lists_distribution_board_id_unique": { - "name": "circuit_lists_distribution_board_id_unique", - "columns": [ - "distribution_board_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "circuit_lists_project_id_projects_id_fk": { - "name": "circuit_lists_project_id_projects_id_fk", - "tableFrom": "circuit_lists", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "circuit_lists_distribution_board_id_distribution_boards_id_fk": { - "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", - "tableFrom": "circuit_lists", - "tableTo": "distribution_boards", - "columnsFrom": [ - "distribution_board_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuit_protection_devices": { - "name": "circuit_protection_devices", - "columns": { - "circuit_id": { - "name": "circuit_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rated_current_a": { - "name": "rated_current_a", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fuse_utilization_category": { - "name": "fuse_utilization_category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trip_characteristic": { - "name": "trip_characteristic", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rcd_type": { - "name": "rcd_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rated_residual_current_ma": { - "name": "rated_residual_current_ma", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "circuit_protection_devices_circuit_id_circuits_id_fk": { - "name": "circuit_protection_devices_circuit_id_circuits_id_fk", - "tableFrom": "circuit_protection_devices", - "tableTo": "circuits", - "columnsFrom": [ - "circuit_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuit_sections": { - "name": "circuit_sections", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "circuit_list_id": { - "name": "circuit_list_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "category": { - "name": "category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_number": { - "name": "group_number", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "circuit_sections_list_key_unique": { - "name": "circuit_sections_list_key_unique", - "columns": [ - "circuit_list_id", - "key" - ], - "isUnique": true - }, - "circuit_sections_list_prefix_unique": { - "name": "circuit_sections_list_prefix_unique", - "columns": [ - "circuit_list_id", - "prefix" - ], - "isUnique": true - }, - "circuit_sections_list_category_group_unique": { - "name": "circuit_sections_list_category_group_unique", - "columns": [ - "circuit_list_id", - "category", - "group_number" - ], - "isUnique": true - } - }, - "foreignKeys": { - "circuit_sections_circuit_list_id_circuit_lists_id_fk": { - "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", - "tableFrom": "circuit_sections", - "tableTo": "circuit_lists", - "columnsFrom": [ - "circuit_list_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "circuits": { - "name": "circuits", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "circuit_list_id": { - "name": "circuit_list_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "section_id": { - "name": "section_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "equipment_identifier": { - "name": "equipment_identifier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "cable_type": { - "name": "cable_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cable_cross_section": { - "name": "cable_cross_section", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cable_length": { - "name": "cable_length", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rcd_assignment": { - "name": "rcd_assignment", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "terminal_designation": { - "name": "terminal_designation", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "voltage": { - "name": "voltage", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "control_requirement": { - "name": "control_requirement", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_reserve": { - "name": "is_reserve", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "remark": { - "name": "remark", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "circuits_section_id_idx": { - "name": "circuits_section_id_idx", - "columns": [ - "section_id" - ], - "isUnique": false - }, - "circuits_list_equipment_identifier_unique": { - "name": "circuits_list_equipment_identifier_unique", - "columns": [ - "circuit_list_id", - "equipment_identifier" - ], - "isUnique": true - } - }, - "foreignKeys": { - "circuits_circuit_list_id_circuit_lists_id_fk": { - "name": "circuits_circuit_list_id_circuit_lists_id_fk", - "tableFrom": "circuits", - "tableTo": "circuit_lists", - "columnsFrom": [ - "circuit_list_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "circuits_section_id_circuit_sections_id_fk": { - "name": "circuits_section_id_circuit_sections_id_fk", - "tableFrom": "circuits", - "tableTo": "circuit_sections", - "columnsFrom": [ - "section_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "distribution_board_component_protection_devices": { - "name": "distribution_board_component_protection_devices", - "columns": { - "component_id": { - "name": "component_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rated_current_a": { - "name": "rated_current_a", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fuse_utilization_category": { - "name": "fuse_utilization_category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trip_characteristic": { - "name": "trip_characteristic", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rcd_type": { - "name": "rcd_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rated_residual_current_ma": { - "name": "rated_residual_current_ma", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { - "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", - "tableFrom": "distribution_board_component_protection_devices", - "tableTo": "distribution_board_components", - "columnsFrom": [ - "component_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "distribution_board_components": { - "name": "distribution_board_components", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "circuit_list_id": { - "name": "circuit_list_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "section_id": { - "name": "section_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "equipment_identifier": { - "name": "equipment_identifier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "placement": { - "name": "placement", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "distribution_board_components_list_identifier_unique": { - "name": "distribution_board_components_list_identifier_unique", - "columns": [ - "circuit_list_id", - "equipment_identifier" - ], - "isUnique": true - }, - "distribution_board_components_section_role_unique": { - "name": "distribution_board_components_section_role_unique", - "columns": [ - "section_id", - "role" - ], - "isUnique": true - } - }, - "foreignKeys": { - "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { - "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", - "tableFrom": "distribution_board_components", - "tableTo": "circuit_lists", - "columnsFrom": [ - "circuit_list_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "distribution_board_components_section_id_circuit_sections_id_fk": { - "name": "distribution_board_components_section_id_circuit_sections_id_fk", - "tableFrom": "distribution_board_components", - "tableTo": "circuit_sections", - "columnsFrom": [ - "section_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "distribution_boards": { - "name": "distribution_boards", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "floor_id": { - "name": "floor_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "supply_type": { - "name": "supply_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "simultaneity_factor": { - "name": "simultaneity_factor", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "board_number": { - "name": "board_number", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "location": { - "name": "location", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fed_from": { - "name": "fed_from", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "remark": { - "name": "remark", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "feeding_distribution_board_id": { - "name": "feeding_distribution_board_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "feeding_circuit_id": { - "name": "feeding_circuit_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "distribution_boards_feeding_board_idx": { - "name": "distribution_boards_feeding_board_idx", - "columns": [ - "feeding_distribution_board_id" - ], - "isUnique": false - }, - "distribution_boards_feeding_circuit_idx": { - "name": "distribution_boards_feeding_circuit_idx", - "columns": [ - "feeding_circuit_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "distribution_boards_project_id_projects_id_fk": { - "name": "distribution_boards_project_id_projects_id_fk", - "tableFrom": "distribution_boards", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "distribution_boards_floor_id_floors_id_fk": { - "name": "distribution_boards_floor_id_floors_id_fk", - "tableFrom": "distribution_boards", - "tableTo": "floors", - "columnsFrom": [ - "floor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk": { - "name": "distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk", - "tableFrom": "distribution_boards", - "tableTo": "distribution_boards", - "columnsFrom": [ - "feeding_distribution_board_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "distribution_boards_feeding_circuit_id_circuits_id_fk": { - "name": "distribution_boards_feeding_circuit_id_circuits_id_fk", - "tableFrom": "distribution_boards", - "tableTo": "circuits", - "columnsFrom": [ - "feeding_circuit_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "document_branding_assets": { - "name": "document_branding_assets", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "blob", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "document_branding_settings": { - "name": "document_branding_settings", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "estimate_balances": { - "name": "estimate_balances", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "work_stage": { - "name": "work_stage", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "date_iso": { - "name": "date_iso", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reserve_av_percent": { - "name": "reserve_av_percent", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "reserve_sv_percent": { - "name": "reserve_sv_percent", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "estimate_balances_project_id_idx": { - "name": "estimate_balances_project_id_idx", - "columns": [ - "project_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "estimate_balances_project_id_projects_id_fk": { - "name": "estimate_balances_project_id_projects_id_fk", - "tableFrom": "estimate_balances", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "estimate_groups": { - "name": "estimate_groups", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "balance_id": { - "name": "balance_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cost_group": { - "name": "cost_group", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "editor": { - "name": "editor", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": { - "estimate_groups_balance_id_idx": { - "name": "estimate_groups_balance_id_idx", - "columns": [ - "balance_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "estimate_groups_balance_id_estimate_balances_id_fk": { - "name": "estimate_groups_balance_id_estimate_balances_id_fk", - "tableFrom": "estimate_groups", - "tableTo": "estimate_balances", - "columnsFrom": [ - "balance_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "estimate_rows": { - "name": "estimate_rows", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "supply": { - "name": "supply", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "quantity": { - "name": "quantity", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "unit": { - "name": "unit", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'piece'" - }, - "connection": { - "name": "connection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "power_per_unit_w": { - "name": "power_per_unit_w", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "simultaneity_factor": { - "name": "simultaneity_factor", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "cos_phi": { - "name": "cos_phi", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "safety_supply": { - "name": "safety_supply", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "remark": { - "name": "remark", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "estimate_rows_group_id_idx": { - "name": "estimate_rows_group_id_idx", - "columns": [ - "group_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "estimate_rows_group_id_estimate_groups_id_fk": { - "name": "estimate_rows_group_id_estimate_groups_id_fk", - "tableFrom": "estimate_rows", - "tableTo": "estimate_groups", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "external_csv_configurations": { - "name": "external_csv_configurations", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "configuration_version": { - "name": "configuration_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "configuration": { - "name": "configuration", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "external_csv_configurations_project_id_unique": { - "name": "external_csv_configurations_project_id_unique", - "columns": [ - "project_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "external_csv_configurations_project_id_projects_id_fk": { - "name": "external_csv_configurations_project_id_projects_id_fk", - "tableFrom": "external_csv_configurations", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "external_import_batches": { - "name": "external_import_batches", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "import_kind": { - "name": "import_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "imported_at_iso": { - "name": "imported_at_iso", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "applied_project_revision": { - "name": "applied_project_revision", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "configuration_version": { - "name": "configuration_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "configuration_snapshot": { - "name": "configuration_snapshot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "original_bytes": { - "name": "original_bytes", - "type": "blob", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "document": { - "name": "document", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "external_import_batches_project_revision_idx": { - "name": "external_import_batches_project_revision_idx", - "columns": [ - "project_id", - "applied_project_revision" - ], - "isUnique": false - }, - "external_import_batches_source_imported_idx": { - "name": "external_import_batches_source_imported_idx", - "columns": [ - "source_id", - "imported_at_iso" - ], - "isUnique": false - } - }, - "foreignKeys": { - "external_import_batches_project_id_projects_id_fk": { - "name": "external_import_batches_project_id_projects_id_fk", - "tableFrom": "external_import_batches", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "external_import_batches_source_id_external_model_sources_id_fk": { - "name": "external_import_batches_source_id_external_model_sources_id_fk", - "tableFrom": "external_import_batches", - "tableTo": "external_model_sources", - "columnsFrom": [ - "source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "external_model_objects": { - "name": "external_model_objects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ifc_guid": { - "name": "ifc_guid", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_seen_import_batch_id": { - "name": "last_seen_import_batch_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_accepted_import_batch_id": { - "name": "last_accepted_import_batch_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "accepted_source_values": { - "name": "accepted_source_values", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "planning_values": { - "name": "planning_values", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "overridden_fields": { - "name": "overridden_fields", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "external_room_mapping_id": { - "name": "external_room_mapping_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "distribution_board_id": { - "name": "distribution_board_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "linked_project_device_id": { - "name": "linked_project_device_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "circuit_device_row_id": { - "name": "circuit_device_row_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "presence_status": { - "name": "presence_status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'present'" - } - }, - "indexes": { - "external_model_objects_source_ifc_guid_unique": { - "name": "external_model_objects_source_ifc_guid_unique", - "columns": [ - "source_id", - "ifc_guid" - ], - "isUnique": true - }, - "external_model_objects_project_presence_idx": { - "name": "external_model_objects_project_presence_idx", - "columns": [ - "project_id", - "presence_status" - ], - "isUnique": false - }, - "external_model_objects_distribution_board_idx": { - "name": "external_model_objects_distribution_board_idx", - "columns": [ - "distribution_board_id" - ], - "isUnique": false - }, - "external_model_objects_circuit_device_row_idx": { - "name": "external_model_objects_circuit_device_row_idx", - "columns": [ - "circuit_device_row_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "external_model_objects_project_id_projects_id_fk": { - "name": "external_model_objects_project_id_projects_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "external_model_objects_source_id_external_model_sources_id_fk": { - "name": "external_model_objects_source_id_external_model_sources_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "external_model_sources", - "columnsFrom": [ - "source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { - "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "external_import_batches", - "columnsFrom": [ - "last_seen_import_batch_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { - "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "external_import_batches", - "columnsFrom": [ - "last_accepted_import_batch_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { - "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "external_room_mappings", - "columnsFrom": [ - "external_room_mapping_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "external_model_objects_distribution_board_id_distribution_boards_id_fk": { - "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "distribution_boards", - "columnsFrom": [ - "distribution_board_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "external_model_objects_linked_project_device_id_project_devices_id_fk": { - "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "project_devices", - "columnsFrom": [ - "linked_project_device_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { - "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", - "tableFrom": "external_model_objects", - "tableTo": "circuit_device_rows", - "columnsFrom": [ - "circuit_device_row_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "external_model_sources": { - "name": "external_model_sources", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_type": { - "name": "source_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'revit_csv'" - } - }, - "indexes": { - "external_model_sources_project_type_unique": { - "name": "external_model_sources_project_type_unique", - "columns": [ - "project_id", - "source_type" - ], - "isUnique": true - } - }, - "foreignKeys": { - "external_model_sources_project_id_projects_id_fk": { - "name": "external_model_sources_project_id_projects_id_fk", - "tableFrom": "external_model_sources", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "external_room_mappings": { - "name": "external_room_mappings", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "normalized_source_room_key": { - "name": "normalized_source_room_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_floor_name": { - "name": "source_floor_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_room_number": { - "name": "source_room_number", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_room_name": { - "name": "source_room_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "room_id": { - "name": "room_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_distribution_board_id": { - "name": "default_distribution_board_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "external_room_mappings_source_key_unique": { - "name": "external_room_mappings_source_key_unique", - "columns": [ - "source_id", - "normalized_source_room_key" - ], - "isUnique": true - }, - "external_room_mappings_project_room_idx": { - "name": "external_room_mappings_project_room_idx", - "columns": [ - "project_id", - "room_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "external_room_mappings_project_id_projects_id_fk": { - "name": "external_room_mappings_project_id_projects_id_fk", - "tableFrom": "external_room_mappings", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "external_room_mappings_source_id_external_model_sources_id_fk": { - "name": "external_room_mappings_source_id_external_model_sources_id_fk", - "tableFrom": "external_room_mappings", - "tableTo": "external_model_sources", - "columnsFrom": [ - "source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "external_room_mappings_room_id_rooms_id_fk": { - "name": "external_room_mappings_room_id_rooms_id_fk", - "tableFrom": "external_room_mappings", - "tableTo": "rooms", - "columnsFrom": [ - "room_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { - "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", - "tableFrom": "external_room_mappings", - "tableTo": "distribution_boards", - "columnsFrom": [ - "default_distribution_board_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "floors": { - "name": "floors", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "building_id": { - "name": "building_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "floors_project_id_projects_id_fk": { - "name": "floors_project_id_projects_id_fk", - "tableFrom": "floors", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "floors_building_id_buildings_id_fk": { - "name": "floors_building_id_buildings_id_fk", - "tableFrom": "floors", - "tableTo": "buildings", - "columnsFrom": [ - "building_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "global_devices": { - "name": "global_devices", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "category": { - "name": "category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "quantity": { - "name": "quantity", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "installed_power_per_unit_kw": { - "name": "installed_power_per_unit_kw", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "demand_factor": { - "name": "demand_factor", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "voltage_v": { - "name": "voltage_v", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "phase_count": { - "name": "phase_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "power_factor": { - "name": "power_factor", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "note": { - "name": "note", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_change_sets": { - "name": "project_change_sets", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_revision_id": { - "name": "project_revision_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "command_type": { - "name": "command_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload_schema_version": { - "name": "payload_schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "forward_payload_json": { - "name": "forward_payload_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "inverse_payload_json": { - "name": "inverse_payload_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_change_sets_revision_unique": { - "name": "project_change_sets_revision_unique", - "columns": [ - "project_revision_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_change_sets_project_revision_id_project_revisions_id_fk": { - "name": "project_change_sets_project_revision_id_project_revisions_id_fk", - "tableFrom": "project_change_sets", - "tableTo": "project_revisions", - "columnsFrom": [ - "project_revision_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_devices": { - "name": "project_devices", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "phase_type": { - "name": "phase_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'single_phase'" - }, - "connection_kind": { - "name": "connection_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cost_group": { - "name": "cost_group", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "category": { - "name": "category", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "quantity": { - "name": "quantity", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "power_per_unit": { - "name": "power_per_unit", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "simultaneity_factor": { - "name": "simultaneity_factor", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 1 - }, - "cos_phi": { - "name": "cos_phi", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "remark": { - "name": "remark", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "voltage_v": { - "name": "voltage_v", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "project_devices_project_id_projects_id_fk": { - "name": "project_devices_project_id_projects_id_fk", - "tableFrom": "project_devices", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_history_stack_entries": { - "name": "project_history_stack_entries", - "columns": { - "change_set_id": { - "name": "change_set_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stack": { - "name": "stack", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_history_stack_entries_position_unique": { - "name": "project_history_stack_entries_position_unique", - "columns": [ - "project_id", - "stack", - "position" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { - "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", - "tableFrom": "project_history_stack_entries", - "tableTo": "project_change_sets", - "columnsFrom": [ - "change_set_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_history_stack_entries_project_id_projects_id_fk": { - "name": "project_history_stack_entries_project_id_projects_id_fk", - "tableFrom": "project_history_stack_entries", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "project_history_stack_entries_stack_check": { - "name": "project_history_stack_entries_stack_check", - "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" - } - } - }, - "project_revisions": { - "name": "project_revisions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "revision_number": { - "name": "revision_number", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at_iso": { - "name": "created_at_iso", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "project_revisions_project_number_unique": { - "name": "project_revisions_project_number_unique", - "columns": [ - "project_id", - "revision_number" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_revisions_project_id_projects_id_fk": { - "name": "project_revisions_project_id_projects_id_fk", - "tableFrom": "project_revisions", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_snapshots": { - "name": "project_snapshots", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_revision": { - "name": "source_revision", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'named'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "payload_json": { - "name": "payload_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload_sha256": { - "name": "payload_sha256", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at_iso": { - "name": "created_at_iso", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_by_actor_id": { - "name": "created_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "project_snapshots_project_created_idx": { - "name": "project_snapshots_project_created_idx", - "columns": [ - "project_id", - "created_at_iso" - ], - "isUnique": false - }, - "project_snapshots_project_kind_revision_idx": { - "name": "project_snapshots_project_kind_revision_idx", - "columns": [ - "project_id", - "kind", - "source_revision" - ], - "isUnique": false - }, - "project_snapshots_project_name_unique": { - "name": "project_snapshots_project_name_unique", - "columns": [ - "project_id", - "name" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_snapshots_project_id_projects_id_fk": { - "name": "project_snapshots_project_id_projects_id_fk", - "tableFrom": "project_snapshots", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "internal_project_number": { - "name": "internal_project_number", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "external_project_number": { - "name": "external_project_number", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "building_owner": { - "name": "building_owner", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_public_building": { - "name": "is_public_building", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "single_phase_voltage_v": { - "name": "single_phase_voltage_v", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 230 - }, - "three_phase_voltage_v": { - "name": "three_phase_voltage_v", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 400 - }, - "enabled_distribution_board_supply_types": { - "name": "enabled_distribution_board_supply_types", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" - }, - "site_address": { - "name": "site_address", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "work_stage": { - "name": "work_stage", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "project_lead": { - "name": "project_lead", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "project_editor": { - "name": "project_editor", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "current_revision": { - "name": "current_revision", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "rooms": { - "name": "rooms", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "floor_id": { - "name": "floor_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "room_number": { - "name": "room_number", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "room_name": { - "name": "room_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "area_m2": { - "name": "area_m2", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "rooms_project_id_projects_id_fk": { - "name": "rooms_project_id_projects_id_fk", - "tableFrom": "rooms", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "rooms_floor_id_floors_id_fk": { - "name": "rooms_floor_id_floors_id_fk", - "tableFrom": "rooms", - "tableTo": "floors", - "columnsFrom": [ - "floor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'user'" - }, - "active": { - "name": "active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "must_change_password": { - "name": "must_change_password", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": [ - "username" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 2a2e076..548734d 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -92,13 +92,6 @@ "when": 1790540142918, "tag": "0012_good_gateway", "breakpoints": true - }, - { - "idx": 13, - "version": "6", - "when": 1790624623732, - "tag": "0013_users", - "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/user.repository.ts b/src/db/repositories/user.repository.ts deleted file mode 100644 index 1fd55c7..0000000 --- a/src/db/repositories/user.repository.ts +++ /dev/null @@ -1,117 +0,0 @@ -import crypto from "node:crypto"; -import { and, asc, eq } from "drizzle-orm"; -import type { AppDatabase } from "../database-context.js"; -import { users } from "../schema/users.js"; -import { - LastActiveAdminStoreError, - UsernameAlreadyExistsStoreError, - type CreateUserAccount, - type UserStore, -} from "../../domain/ports/user.store.js"; -import type { UserRole } from "../../shared/constants/user-roles.js"; - -function isUniqueConstraintError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE" - ); -} - -export class UserRepository implements UserStore { - constructor(private readonly database: AppDatabase) {} - - async list() { - const db = this.database; - return db.select().from(users).orderBy(asc(users.name)); - } - - async count() { - const db = this.database; - const rows = await db.select({ id: users.id }).from(users).limit(1); - return rows.length; - } - - async findById(userId: string) { - const db = this.database; - const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - return row ?? null; - } - - async findByUsername(username: string) { - const db = this.database; - const [row] = await db - .select() - .from(users) - .where(eq(users.username, username.trim().toLowerCase())) - .limit(1); - return row ?? null; - } - - async create(input: CreateUserAccount) { - const db = this.database; - const id = crypto.randomUUID(); - try { - await db.insert(users).values({ - id, - username: input.username.trim().toLowerCase(), - name: input.name, - role: input.role, - passwordHash: input.passwordHash, - active: true, - mustChangePassword: true, - }); - } catch (error) { - if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError(); - throw error; - } - return this.findById(id); - } - - async updateRoleAndActive( - userId: string, - patch: { role?: UserRole; active?: boolean } - ) { - return this.database.transaction((tx) => { - const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all(); - if (!target) return null; - - const removesActiveAdmin = - target.role === "admin" && - target.active && - (patch.role === "user" || patch.active === false); - if (removesActiveAdmin) { - const activeAdmins = tx - .select({ id: users.id }) - .from(users) - .where(and(eq(users.role, "admin"), eq(users.active, true))) - .limit(2) - .all(); - if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError(); - } - - tx.update(users) - .set({ - ...(patch.role !== undefined ? { role: patch.role } : {}), - ...(patch.active !== undefined ? { active: patch.active } : {}), - updatedAt: new Date(), - }) - .where(eq(users.id, userId)) - .run(); - return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null; - }); - } - - async setPassword( - userId: string, - passwordHash: string, - mustChangePassword: boolean - ) { - const db = this.database; - await db - .update(users) - .set({ passwordHash, mustChangePassword, updatedAt: new Date() }) - .where(eq(users.id, userId)); - return this.findById(userId); - } -} diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts deleted file mode 100644 index 7105fb4..0000000 --- a/src/db/schema/users.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { sql } from "drizzle-orm"; -import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; -import type { UserRole } from "../../shared/constants/user-roles.js"; - -export const users = sqliteTable( - "users", - { - id: text("id").primaryKey(), - username: text("username").notNull(), - passwordHash: text("password_hash").notNull(), - name: text("name").notNull(), - role: text("role").$type().notNull().default("user"), - active: integer("active", { mode: "boolean" }).notNull().default(true), - mustChangePassword: integer("must_change_password", { mode: "boolean" }) - .notNull() - .default(true), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(sql`(unixepoch())`), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .default(sql`(unixepoch())`), - }, - (table) => [uniqueIndex("users_username_unique").on(table.username)] -); - -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; diff --git a/src/domain/ports/user.store.ts b/src/domain/ports/user.store.ts deleted file mode 100644 index 9b80023..0000000 --- a/src/domain/ports/user.store.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { UserRole } from "../../shared/constants/user-roles.js"; - -export interface UserAccount { - id: string; - username: string; - passwordHash: string; - name: string; - role: UserRole; - active: boolean; - mustChangePassword: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface CreateUserAccount { - username: string; - name: string; - role: UserRole; - passwordHash: string; -} - -export class UsernameAlreadyExistsStoreError extends Error {} -export class LastActiveAdminStoreError extends Error {} - -export interface UserStore { - list(): Promise; - count(): Promise; - findById(userId: string): Promise; - findByUsername(username: string): Promise; - create(input: CreateUserAccount): Promise; - updateRoleAndActive( - userId: string, - patch: { role?: UserRole; active?: boolean } - ): Promise; - setPassword( - userId: string, - passwordHash: string, - mustChangePassword: boolean - ): Promise; -} diff --git a/src/domain/services/user.service.ts b/src/domain/services/user.service.ts deleted file mode 100644 index 7ea2f0b..0000000 --- a/src/domain/services/user.service.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { UserRole } from "../../shared/constants/user-roles.js"; -import { createLogger } from "../../shared/logging/logger.js"; -import { - LastActiveAdminStoreError, - UsernameAlreadyExistsStoreError, - type UserStore, -} from "../ports/user.store.js"; - -const logger = createLogger("user-service"); - -export class InvalidCredentialsError extends Error { - constructor() { - super("Anmeldung fehlgeschlagen."); - } -} - -export class UsernameTakenError extends Error { - constructor() { - super("Dieser Anmeldename ist bereits vergeben."); - } -} - -export class WrongPasswordError extends Error { - constructor() { - super("Das aktuelle Passwort ist falsch."); - } -} - -export class PasswordUnchangedError extends Error { - constructor() { - super("Das neue Passwort muss sich vom bisherigen unterscheiden."); - } -} - -export class LastAdminError extends Error { - constructor() { - super("Es muss mindestens ein aktiver Administrator bleiben."); - } -} - -export interface PasswordHasher { - hash(password: string): Promise; - verify(password: string, storedHash: string): Promise; -} - -export interface UserServiceOptions { - bootstrap: { username: string; password: string } | null; -} - -export class UserService { - private dummyHash: Promise | null = null; - - constructor( - private readonly repository: UserStore, - private readonly options: UserServiceOptions, - private readonly passwords: PasswordHasher - ) {} - - /** - * Creates the bootstrap admin from env vars, but only while the users - * table is still completely empty. After the first account exists, every - * further account is created through Config -> Benutzer, and the bootstrap - * env vars are ignored - that's the whole point of them being a one-time - * escape hatch instead of a standing backdoor. - */ - async ensureBootstrapAdmin(): Promise { - // Warm the dummy hash at startup so even the first unknown-user login - // costs one scrypt run, not two. - await this.getDummyHash(); - const existingCount = await this.repository.count(); - if (existingCount > 0) return; - if (!this.options.bootstrap) { - logger.warn( - "no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in" - ); - return; - } - const passwordHash = await this.passwords.hash(this.options.bootstrap.password); - await this.repository.create({ - username: this.options.bootstrap.username, - name: "Administrator", - role: "admin", - passwordHash, - }); - logger.info("bootstrap admin created", { - username: this.options.bootstrap.username, - }); - } - - async authenticate(username: string, password: string) { - const user = await this.repository.findByUsername(username); - // Unknown users are verified against a fixed dummy hash: exactly one - // scrypt run either way, so response time doesn't reveal which usernames - // exist. - const passwordHash = user?.passwordHash ?? (await this.getDummyHash()); - const passwordMatches = await this.passwords.verify(password, passwordHash); - if (!user || !user.active || !passwordMatches) { - throw new InvalidCredentialsError(); - } - return user; - } - - async createUser(input: { - username: string; - name: string; - role: UserRole; - initialPassword: string; - }) { - const existing = await this.repository.findByUsername(input.username); - if (existing) throw new UsernameTakenError(); - const passwordHash = await this.passwords.hash(input.initialPassword); - try { - return await this.repository.create({ - username: input.username, - name: input.name, - role: input.role, - passwordHash, - }); - } catch (error) { - // Two admins creating the same name at once: the unique index decides. - if (error instanceof UsernameAlreadyExistsStoreError) throw new UsernameTakenError(); - throw error; - } - } - - async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { - try { - return await this.repository.updateRoleAndActive(userId, patch); - } catch (error) { - if (error instanceof LastActiveAdminStoreError) throw new LastAdminError(); - throw error; - } - } - - /** Admin-triggered reset with a new start password; forces a change on next login. */ - async resetPassword(userId: string, newPassword: string) { - const passwordHash = await this.passwords.hash(newPassword); - return this.repository.setPassword(userId, passwordHash, true); - } - - /** Self-service change by the signed-in user; clears mustChangePassword. */ - async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) { - const user = await this.repository.findById(userId); - if (!user) throw new WrongPasswordError(); - const currentMatches = await this.passwords.verify(currentPassword, user.passwordHash); - if (!currentMatches) throw new WrongPasswordError(); - if (newPassword === currentPassword) throw new PasswordUnchangedError(); - const passwordHash = await this.passwords.hash(newPassword); - return this.repository.setPassword(userId, passwordHash, false); - } - - private getDummyHash(): Promise { - this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login"); - return this.dummyHash; - } -} diff --git a/src/frontend/components/AuthProvider.tsx b/src/frontend/components/AuthProvider.tsx deleted file mode 100644 index 02ab833..0000000 --- a/src/frontend/components/AuthProvider.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { createContext, useCallback, useContext, useEffect, useState } from "react"; -import { authApi, type AuthUserDto } from "../utils/auth-api"; - -type AuthStatus = "loading" | "signed-in" | "signed-out"; - -interface AuthContextValue { - status: AuthStatus; - user: AuthUserDto | undefined; - isAdmin: boolean; - reload: () => Promise; - logout: () => Promise; -} - -const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [status, setStatus] = useState("loading"); - const [user, setUser] = useState(undefined); - - const reload = useCallback(async () => { - try { - const current = await authApi.me(); - setUser(current); - setStatus("signed-in"); - } catch { - setUser(undefined); - setStatus("signed-out"); - } - }, []); - - useEffect(() => { - void reload(); - }, [reload]); - - async function logout() { - try { - await authApi.logout(); - } finally { - setUser(undefined); - setStatus("signed-out"); - } - } - - return ( - - {children} - - ); -} - -export function useAuth(): AuthContextValue { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; -} diff --git a/src/frontend/components/LoginGate.tsx b/src/frontend/components/LoginGate.tsx deleted file mode 100644 index 23a2aeb..0000000 --- a/src/frontend/components/LoginGate.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { authApi } from "../utils/auth-api"; -import { useAuth } from "./AuthProvider"; - -export function LoginGate({ children }: { children: React.ReactNode }) { - const { status, user, reload } = useAuth(); - - if (status === "loading") { - return ( -
-

Lädt…

-
- ); - } - - if (status === "signed-out") { - return ( -
- -
- ); - } - - if (user?.mustChangePassword) { - return ( -
- -
- ); - } - - return <>{children}; -} - -function LoginForm({ onSignedIn }: { onSignedIn: () => Promise }) { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitting(true); - setError(null); - try { - await authApi.login(username, password); - await onSignedIn(); - } catch (err) { - setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen."); - } finally { - setSubmitting(false); - } - } - - return ( -
-

Leistungsbilanz

-
- - setUsername(event.target.value)} - autoFocus - autoComplete="username" - required - /> -
-
- - setPassword(event.target.value)} - autoComplete="current-password" - required - /> -
- {error &&

{error}

} - -
- ); -} - -function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise }) { - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - if (newPassword !== newPasswordRepeat) { - setError("Die beiden neuen Passwörter stimmen nicht überein."); - return; - } - setSubmitting(true); - setError(null); - try { - await authApi.changePassword(currentPassword, newPassword); - await onChanged(); - } catch (err) { - setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); - } finally { - setSubmitting(false); - } - } - - return ( -
-

Neues Passwort setzen

-

- Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der - weiteren Nutzung ein eigenes Passwort vergeben. -

-
- - setCurrentPassword(event.target.value)} - autoComplete="current-password" - autoFocus - required - /> -
-
- - setNewPassword(event.target.value)} - autoComplete="new-password" - minLength={8} - required - /> -
-
- - setNewPasswordRepeat(event.target.value)} - autoComplete="new-password" - minLength={8} - required - /> -
- {error &&

{error}

} - -
- ); -} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index e99b493..395cd7c 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -6,8 +6,6 @@ import { useEffect, useState } from "react"; import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; -import { useAuth } from "./AuthProvider"; -import { getConfigEntry } from "../utils/config-access"; // Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ @@ -32,8 +30,6 @@ interface SidebarProps { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { const pathname = usePathname(); - const { user, isAdmin, logout } = useAuth(); - const configEntry = getConfigEntry(isAdmin); const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const activeProjectId = projectMatch ? projectMatch[1] : null; const projectSubPath = projectMatch?.[2] ?? ""; @@ -183,34 +179,8 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { )} -
- {user && !isCollapsed && ( -
- Angemeldet als - {user.name} -
- )} - {/* Toggle and Abmelden share one row: the toggle stays a fixed-size - square, Abmelden takes the rest of the width. Collapsed, only - the square toggle remains (centered). */} -
- - {user && !isCollapsed && ( - - )} -
+
); diff --git a/src/frontend/components/ThemeToggle.tsx b/src/frontend/components/ThemeToggle.tsx index 2073a5e..355e0e2 100644 --- a/src/frontend/components/ThemeToggle.tsx +++ b/src/frontend/components/ThemeToggle.tsx @@ -48,17 +48,16 @@ export function ThemeToggle() { } return ( - // Square, icon-only: it shares a row with the Abmelden button, so there - // is no room for a text label. The label moves to title/aria-label. ); } diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts index 73582b5..28530d2 100644 --- a/src/frontend/components/cable-sizing-api.ts +++ b/src/frontend/components/cable-sizing-api.ts @@ -6,10 +6,6 @@ import type { CableSizingInput, CableSizingResult, } from "../../cable-sizing/domain/cable-sizing-calculation"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; export interface CalculateCableSizingResponse { result: CableSizingResult; @@ -21,10 +17,7 @@ export async function calculateCableSizing( ): Promise { const response = await fetch("/api/cable-sizing/calculate", { method: "POST", - headers: { - "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), cache: "no-store", }); diff --git a/src/frontend/components/filter-panel.tsx b/src/frontend/components/filter-panel.tsx index 9c9923b..afa1fde 100644 --- a/src/frontend/components/filter-panel.tsx +++ b/src/frontend/components/filter-panel.tsx @@ -55,15 +55,9 @@ export function CollapsibleFilterPanel({ writeCollapsed(storageKey, next); } - // One row: toggle, then either the "Filter" label (collapsed) or the filter - // fields themselves (expanded), and the count at the right edge. return (
-
+
- {isCollapsed ? ( - <> - Filter - {isFilterActive ? Filter aktiv : null} - - ) : ( -
- {children} - {isFilterActive ? ( - - ) : null} -
- )} + Filter + {isCollapsed && isFilterActive ? ( + Filter aktiv + ) : null} {visibleCount} von {totalCount}
+ {isCollapsed ? null : ( +
+ {children} + {isFilterActive ? ( + + ) : null} +
+ )}
); } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 5256195..8f5a878 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,7 +1,3 @@ -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; import type { BuildingDto, CircuitListDto, @@ -126,7 +122,6 @@ async function request(url: string, init?: RequestInit): Promise { ...init, headers: { "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, ...init?.headers, }, cache: "no-store", diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts deleted file mode 100644 index a6f12b3..0000000 --- a/src/frontend/utils/auth-api.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; - -// Small, self-contained fetch helper for the auth/user endpoints - kept -// separate from the large domain api.ts so this feature doesn't have to -// touch that file's shared `request()` internals. - -export type UserRole = "admin" | "user"; - -export interface AuthStatusDto { - signedIn: boolean; - mustChangePassword: boolean; -} - -export interface AuthUserDto { - id: string; - username: string; - name: string; - role: UserRole; - mustChangePassword: boolean; -} - -export interface UserDto { - id: string; - username: string; - name: string; - role: UserRole; - active: boolean; - mustChangePassword: boolean; - createdAt: string; -} - -export class AuthApiError extends Error { - constructor( - message: string, - public readonly status: number - ) { - super(message); - } -} - -async function request(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - headers: { - "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, - ...init?.headers, - }, - cache: "no-store", - }); - if (!response.ok) { - let message = `Anfrage fehlgeschlagen (Status ${response.status})`; - try { - const body = (await response.json()) as { error?: unknown }; - if (typeof body.error === "string") message = body.error; - } catch { - // Keep the generic message for non-JSON error responses. - } - throw new AuthApiError(message, response.status); - } - if (response.status === 204) return undefined as T; - return response.json() as Promise; -} - -export const authApi = { - status: () => request("/api/auth/status"), - login: (username: string, password: string) => - request("/api/auth/login", { - method: "POST", - body: JSON.stringify({ username, password }), - }), - me: () => request("/api/auth/me"), - changePassword: (currentPassword: string, newPassword: string) => - request<{ changed: true }>("/api/auth/change-password", { - method: "POST", - body: JSON.stringify({ currentPassword, newPassword }), - }), - logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }), -}; - -export const userApi = { - list: () => request("/api/users"), - create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) => - request("/api/users", { method: "POST", body: JSON.stringify(input) }), - update: (userId: string, patch: { role?: UserRole; active?: boolean }) => - request(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }), - resetPassword: (userId: string, newPassword: string) => - request(`/api/users/${userId}/reset-password`, { - method: "POST", - body: JSON.stringify({ newPassword }), - }), -}; diff --git a/src/frontend/utils/config-access.ts b/src/frontend/utils/config-access.ts deleted file mode 100644 index 86dd2a2..0000000 --- a/src/frontend/utils/config-access.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface ConfigNavigationEntry { - href: string; - label: string; -} - -const ACCOUNT_ENTRY = { href: "/config/konto", label: "Konto" } as const; -const USERS_ENTRY = { href: "/config/benutzer", label: "Benutzer" } as const; - -export function getConfigTabs(isAdmin: boolean): ConfigNavigationEntry[] { - return isAdmin ? [USERS_ENTRY, ACCOUNT_ENTRY] : [ACCOUNT_ENTRY]; -} - -export function getConfigEntry(isAdmin: boolean): ConfigNavigationEntry { - return isAdmin ? { href: USERS_ENTRY.href, label: "Config" } : ACCOUNT_ENTRY; -} - -export function getConfigRedirect(pathname: string | null, isAdmin: boolean): string | null { - if (pathname === "/config") { - return isAdmin ? USERS_ENTRY.href : ACCOUNT_ENTRY.href; - } - if (!isAdmin && pathname?.startsWith(USERS_ENTRY.href)) { - return ACCOUNT_ENTRY.href; - } - return null; -} diff --git a/src/server/auth/auth-config.ts b/src/server/auth/auth-config.ts deleted file mode 100644 index c4c3b44..0000000 --- a/src/server/auth/auth-config.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const SESSION_COOKIE = "lb_session"; - -const DEFAULT_SESSION_HOURS = 12; -const DEFAULT_SESSION_MAX_DAYS = 7; - -export interface AuthConfig { - /** Idle timeout; slides forward on activity. */ - sessionTtlMs: number; - /** Hard upper bound for a session, regardless of activity. */ - sessionMaxAgeMs: number; - cookieSecure: boolean; - /** - * Only used to create the very first admin when the users table is still - * empty - after that, every account is created through Config -> Benutzer. - * Unset means: no bootstrap account, the table must already have an admin. - */ - bootstrap: { username: string; password: string } | null; -} - -export class AuthConfigError extends Error {} - -function positiveNumber(raw: string | undefined, fallback: number, name: string): number { - const value = Number(raw ?? fallback); - if (!Number.isFinite(value) || value <= 0) { - throw new AuthConfigError(`${name} must be a positive number.`); - } - return value; -} - -export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { - const hours = positiveNumber(env.AUTH_SESSION_HOURS, DEFAULT_SESSION_HOURS, "AUTH_SESSION_HOURS"); - const maxDays = positiveNumber( - env.AUTH_SESSION_MAX_DAYS, - DEFAULT_SESSION_MAX_DAYS, - "AUTH_SESSION_MAX_DAYS" - ); - - const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase(); - const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? ""; - if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) { - throw new AuthConfigError( - "AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither." - ); - } - if (bootstrapPassword && bootstrapPassword.length < 12) { - throw new AuthConfigError( - "AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long." - ); - } - - return { - sessionTtlMs: hours * 60 * 60 * 1000, - sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000, - cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true", - bootstrap: bootstrapUsername - ? { username: bootstrapUsername, password: bootstrapPassword } - : null, - }; -} diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts deleted file mode 100644 index bafae03..0000000 --- a/src/server/auth/auth.middleware.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { NextFunction, Request, RequestHandler, Response } from "express"; -import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; -import { readCookie } from "./cookie.js"; -import type { SessionStore } from "./sessions.js"; -import type { UserStore } from "../../domain/ports/user.store.js"; -import type { UserRole } from "../../shared/constants/user-roles.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security.js"; - -export interface AuthUser { - id: string; - username: string; - name: string; - role: UserRole; - mustChangePassword: boolean; -} - -declare global { - namespace Express { - interface Request { - authUser?: AuthUser; - } - } -} - -export function sessionIdFromRequest(req: { - headers: { cookie?: string | undefined }; -}): string | undefined { - return readCookie(req.headers.cookie, SESSION_COOKIE); -} - -export async function identityFromRequest( - req: { headers: { cookie?: string | undefined } }, - sessions: SessionStore, - users: Pick, - slideExpiry = true -): Promise { - const session = sessions.get(sessionIdFromRequest(req), slideExpiry); - if (!session) return undefined; - const user = await users.findById(session.userId); - if (!user || !user.active) return undefined; - return { - id: user.id, - username: user.username, - name: user.name, - role: user.role as UserRole, - mustChangePassword: user.mustChangePassword, - }; -} - -export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) { - return { - httpOnly: true, - sameSite: "lax" as const, - secure: authConfig.cookieSecure, - path: "/", - maxAge: maxAgeMs, - }; -} - -/** Sets the session cookie with the same remaining lifetime the server uses. */ -export function setSessionCookie( - res: Response, - authConfig: AuthConfig, - sessions: SessionStore, - sessionId: string -): void { - const session = sessions.get(sessionId, false); - if (!session) return; - res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session))); -} - -const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); - -/** - * CSRF defense for cookie-authenticated writes. - * - * SameSite=Lax alone is not enough here: every other service on the same - * host (other ports of the same IP) counts as "same-site" and may receive the - * cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN. - * Every write therefore needs an application header that HTML forms cannot - * set; a cross-origin fetch would require a CORS preflight, which the API does - * not permit. Sec-Fetch-Site remains an additional browser-side check. - */ -export function requireSameOriginForWrites(): RequestHandler { - return (req: Request, res: Response, next: NextFunction) => { - if (SAFE_METHODS.has(req.method)) { - next(); - return; - } - if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) { - res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." }); - return; - } - const fetchSite = req.headers["sec-fetch-site"]; - if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { - next(); - return; - } - res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." }); - }; -} - -/** Protects /api. Only login, status and logout stay reachable without a session. */ -export function createAuthGuard( - authConfig: AuthConfig, - sessions: SessionStore, - users: Pick, - openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] -): RequestHandler { - return (req: Request, res: Response, next: NextFunction) => { - if (openPaths.includes(req.path)) { - next(); - return; - } - identityFromRequest(req, sessions, users) - .then((identity) => { - if (!identity) { - res.status(401).json({ error: "Nicht angemeldet." }); - return; - } - req.authUser = identity; - const sessionId = sessionIdFromRequest(req); - if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId); - next(); - }) - .catch(next); - }; -} - -/** Blocks everything except logout and the password change while a start password is still in use. */ -export function requirePasswordChanged(): RequestHandler { - const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]); - return (req: Request, res: Response, next: NextFunction) => { - if (!req.authUser?.mustChangePassword || allowed.has(req.path)) { - next(); - return; - } - res.status(403).json({ - error: "Bitte zuerst ein eigenes Passwort setzen.", - mustChangePassword: true, - }); - }; -} - -export function requireRole(role: UserRole): RequestHandler { - return (req: Request, res: Response, next: NextFunction) => { - if (req.authUser?.role !== role) { - res.status(403).json({ error: "Keine Berechtigung für diese Aktion." }); - return; - } - next(); - }; -} diff --git a/src/server/auth/cookie.ts b/src/server/auth/cookie.ts deleted file mode 100644 index 344ff65..0000000 --- a/src/server/auth/cookie.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */ -export function readCookie( - header: string | undefined, - name: string -): string | undefined { - if (!header) return undefined; - for (const part of header.split(";")) { - const separatorIndex = part.indexOf("="); - if (separatorIndex === -1) continue; - const key = part.slice(0, separatorIndex).trim(); - if (key !== name) continue; - // A malformed value must read as "no session", not throw into a 500. - try { - return decodeURIComponent(part.slice(separatorIndex + 1).trim()); - } catch { - return undefined; - } - } - return undefined; -} diff --git a/src/server/auth/login-throttle.ts b/src/server/auth/login-throttle.ts deleted file mode 100644 index d81719b..0000000 --- a/src/server/auth/login-throttle.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Slows down online password guessing per username. - * - * Keyed by username, not by client IP: every request reaches the API through - * the Next.js rewrite, so the socket address is always the web container. - * The trade-off is that someone who knows a username can keep that account - * temporarily throttled; the block lifts by itself after the window, so it is - * a nuisance, not a lockout. - */ -export class LoginThrottle { - private readonly failures = new Map(); - - constructor( - private readonly maxFailures = 5, - private readonly windowMs = 15 * 60 * 1000, - private readonly maxTrackedKeys = 10_000, - private readonly now: () => number = Date.now - ) {} - - /** Milliseconds until another attempt is allowed; 0 = allowed now. */ - retryAfterMs(username: string): number { - const recent = this.recentFailures(this.key(username)); - if (recent.length < this.maxFailures) return 0; - return Math.max(0, recent[0] + this.windowMs - this.now()); - } - - recordFailure(username: string): void { - const key = this.key(username); - const recent = this.recentFailures(key); - recent.push(this.now()); - if (!this.failures.has(key) && this.failures.size >= this.maxTrackedKeys) { - // Bound memory under a flood of random usernames: drop the oldest key. - const oldestKey = this.failures.keys().next().value; - if (oldestKey !== undefined) this.failures.delete(oldestKey); - } - this.failures.set(key, recent); - } - - recordSuccess(username: string): void { - this.failures.delete(this.key(username)); - } - - private recentFailures(key: string): number[] { - const cutoff = this.now() - this.windowMs; - const recent = (this.failures.get(key) ?? []).filter((time) => time > cutoff); - if (recent.length === 0) this.failures.delete(key); - return recent; - } - - private key(username: string): string { - return username.trim().toLowerCase(); - } -} diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts deleted file mode 100644 index 97132af..0000000 --- a/src/server/auth/password.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto"; - -const SCRYPT_N = 16384; -const SCRYPT_R = 8; -const SCRYPT_P = 1; -const KEY_LENGTH = 64; -const SALT_LENGTH = 16; -const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024; -const MIN_SCRYPT_N = 1 << 14; -const MAX_SCRYPT_N = 1 << 16; -const MAX_SCRYPT_R = 16; -const MAX_SCRYPT_P = 8; - -// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only -// resolves to scrypt's 3-argument overload, so the 4-argument form used for -// verification (with explicit N/r/p) doesn't type-check through it. -function scryptAsync( - password: string, - salt: Buffer, - keyLength: number, - options?: ScryptOptions -): Promise { - return new Promise((resolve, reject) => { - const callback = (error: Error | null, derivedKey: Buffer) => { - if (error) reject(error); - else resolve(derivedKey); - }; - if (options) { - scrypt(password, salt, keyLength, options, callback); - } else { - scrypt(password, salt, keyLength, callback); - } - }); -} - -// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the -// hash so they can be tuned later without invalidating existing rows. -export async function hashPassword(password: string): Promise { - const salt = randomBytes(SALT_LENGTH); - const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, - maxmem: MAX_SCRYPT_MEMORY, - }); - return [ - "scrypt", - SCRYPT_N, - SCRYPT_R, - SCRYPT_P, - salt.toString("hex"), - derivedKey.toString("hex"), - ].join("$"); -} - -export async function verifyPassword( - password: string, - storedHash: string -): Promise { - const parts = storedHash.split("$"); - if (parts.length !== 6 || parts[0] !== "scrypt") return false; - const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts; - const n = Number(nRaw); - const r = Number(rRaw); - const p = Number(pRaw); - if ( - !Number.isSafeInteger(n) || - n < MIN_SCRYPT_N || - n > MAX_SCRYPT_N || - (n & (n - 1)) !== 0 || - !Number.isSafeInteger(r) || - r < 1 || - r > MAX_SCRYPT_R || - !Number.isSafeInteger(p) || - p < 1 || - p > MAX_SCRYPT_P || - !/^[0-9a-f]{32}$/i.test(saltHex) || - !/^[0-9a-f]{128}$/i.test(hashHex) - ) { - return false; - } - const salt = Buffer.from(saltHex, "hex"); - const expected = Buffer.from(hashHex, "hex"); - try { - const derivedKey = await scryptAsync(password, salt, expected.length, { - N: n, - r, - p, - maxmem: MAX_SCRYPT_MEMORY, - }); - return timingSafeEqual(derivedKey, expected); - } catch { - return false; - } -} diff --git a/src/server/auth/sessions.ts b/src/server/auth/sessions.ts deleted file mode 100644 index 9ee1fe0..0000000 --- a/src/server/auth/sessions.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { randomUUID } from "node:crypto"; - -interface Session { - id: string; - userId: string; - createdAt: number; - expiresAt: number; -} - -const MAX_SESSIONS_PER_USER = 20; -const SWEEP_INTERVAL_MS = 10 * 60 * 1000; - -/** - * In-memory session store. The API runs as a single container/process, so a - * restart clearing every session is an acceptable trade-off for the - * simplicity of not needing a persisted session table. - * - * Sessions slide forward on activity (`idleTtlMs`) but never outlive - * `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever. - */ -export class SessionStore { - private readonly sessions = new Map(); - - constructor( - private readonly idleTtlMs: number, - private readonly absoluteTtlMs: number, - private readonly now: () => number = Date.now - ) {} - - /** Removes expired sessions; unreferenced ones would otherwise pile up. */ - startSweeping(): void { - setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref(); - } - - sweep(): void { - const now = this.now(); - for (const [id, session] of this.sessions) { - if (this.isExpired(session, now)) this.sessions.delete(id); - } - } - - create(userId: string): Session { - this.enforcePerUserLimit(userId); - const now = this.now(); - const session: Session = { - id: randomUUID(), - userId, - createdAt: now, - expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs), - }; - this.sessions.set(session.id, session); - return session; - } - - /** Returns the session and slides its expiry forward, or undefined if gone/expired. */ - get(sessionId: string | undefined, slideExpiry = true): Session | undefined { - if (!sessionId) return undefined; - const session = this.sessions.get(sessionId); - if (!session) return undefined; - const now = this.now(); - if (this.isExpired(session, now)) { - this.sessions.delete(sessionId); - return undefined; - } - if (slideExpiry) { - session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs); - } - return session; - } - - /** Remaining lifetime, used as cookie max-age so browser and server agree. */ - remainingMs(session: Session): number { - return Math.max(0, session.expiresAt - this.now()); - } - - destroy(sessionId: string | undefined): void { - if (!sessionId) return; - this.sessions.delete(sessionId); - } - - /** Ends every session of a user, optionally except one (e.g. the caller's own). */ - destroyAllForUser(userId: string, exceptSessionId?: string): void { - for (const [id, session] of this.sessions) { - if (session.userId === userId && id !== exceptSessionId) { - this.sessions.delete(id); - } - } - } - - private isExpired(session: Session, now: number): boolean { - return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now; - } - - // Oldest sessions go first, so a legitimate user logging in on a new - // device is never refused - only memory growth is bounded. - private enforcePerUserLimit(userId: string): void { - const own = [...this.sessions.values()] - .filter((session) => session.userId === userId) - .sort((a, b) => a.createdAt - b.createdAt); - while (own.length >= MAX_SESSIONS_PER_USER) { - const oldest = own.shift(); - if (oldest) this.sessions.delete(oldest.id); - } - } -} diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 846a0da..66fdd92 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -16,7 +16,6 @@ import { RoomRepository } from "../../db/repositories/room.repository.js"; import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; -import { UserRepository } from "../../db/repositories/user.repository.js"; export const circuitDeviceRowRepository = new CircuitDeviceRowRepository(db); @@ -40,4 +39,3 @@ export const externalCsvConfigurationRepository = new ExternalCsvConfigurationRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db); -export const userRepository = new UserRepository(db); diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts deleted file mode 100644 index 07b822b..0000000 --- a/src/server/composition/auth.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readAuthConfig } from "../auth/auth-config.js"; -import { LoginThrottle } from "../auth/login-throttle.js"; -import { SessionStore } from "../auth/sessions.js"; -import { UserService } from "../../domain/services/user.service.js"; -import { userRepository } from "./application-repositories.js"; -import { hashPassword, verifyPassword } from "../auth/password.js"; - -export const authConfig = readAuthConfig(); -export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs); -sessionStore.startSweeping(); -export const loginThrottle = new LoginThrottle(); -export const userService = new UserService( - userRepository, - { bootstrap: authConfig.bootstrap }, - { hash: hashPassword, verify: verifyPassword } -); diff --git a/src/server/controllers/auth.controller.ts b/src/server/controllers/auth.controller.ts deleted file mode 100644 index 5eaf699..0000000 --- a/src/server/controllers/auth.controller.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Request, Response } from "express"; -import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js"; -import { - InvalidCredentialsError, - PasswordUnchangedError, - WrongPasswordError, -} from "../../domain/services/user.service.js"; -import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js"; -import { - cookieOptions, - identityFromRequest, - sessionIdFromRequest, - setSessionCookie, -} from "../auth/auth.middleware.js"; -import { SESSION_COOKIE } from "../auth/auth-config.js"; -import { userRepository } from "../composition/application-repositories.js"; - -export async function getAuthStatus(req: Request, res: Response) { - const identity = await identityFromRequest(req, sessionStore, userRepository); - const sessionId = sessionIdFromRequest(req); - if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId); - res.json({ - signedIn: Boolean(identity), - mustChangePassword: identity?.mustChangePassword ?? false, - }); -} - -export async function login(req: Request, res: Response) { - const parsed = loginSchema.safeParse(req.body); - if (!parsed.success) { - res.status(401).json({ error: "Anmeldung fehlgeschlagen." }); - return; - } - const { username, password } = parsed.data; - - const retryAfterMs = loginThrottle.retryAfterMs(username); - if (retryAfterMs > 0) { - res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString()); - res.status(429).json({ - error: "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.", - }); - return; - } - - try { - const user = await userService.authenticate(username, password); - loginThrottle.recordSuccess(username); - // A fresh session id on every login: an id planted before login - // (session fixation) never becomes authenticated. - sessionStore.destroy(sessionIdFromRequest(req)); - const session = sessionStore.create(user.id); - setSessionCookie(res, authConfig, sessionStore, session.id); - res.json({ signedIn: true, mustChangePassword: user.mustChangePassword }); - } catch (error) { - if (error instanceof InvalidCredentialsError) { - loginThrottle.recordFailure(username); - res.status(401).json({ error: error.message }); - return; - } - throw error; - } -} - -export function getMe(req: Request, res: Response) { - const identity = req.authUser; - if (!identity) { - res.status(401).json({ error: "Nicht angemeldet." }); - return; - } - res.json(identity); -} - -export async function changePassword(req: Request, res: Response) { - const identity = req.authUser; - if (!identity) { - res.status(401).json({ error: "Nicht angemeldet." }); - return; - } - const parsed = changePasswordSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." }); - return; - } - try { - await userService.changeOwnPassword( - identity.id, - parsed.data.currentPassword, - parsed.data.newPassword - ); - } catch (error) { - if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) { - res.status(400).json({ error: error.message }); - return; - } - throw error; - } - // Every session of this account ends - including the current one, which is - // replaced by a fresh id so the user stays signed in on this device only. - sessionStore.destroyAllForUser(identity.id); - const session = sessionStore.create(identity.id); - setSessionCookie(res, authConfig, sessionStore, session.id); - res.json({ changed: true }); -} - -export function logout(req: Request, res: Response) { - sessionStore.destroy(sessionIdFromRequest(req)); - res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig, 0), maxAge: undefined }); - res.json({ signedOut: true }); -} diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts deleted file mode 100644 index bdf8d0f..0000000 --- a/src/server/controllers/user.controller.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Request, Response } from "express"; -import type { ZodError } from "zod"; -import { - createUserSchema, - resetPasswordSchema, - updateUserSchema, -} from "../../shared/validation/user.schemas.js"; -import { LastAdminError, UsernameTakenError } from "../../domain/services/user.service.js"; -import { sessionStore, userService } from "../composition/auth.js"; -import { userRepository } from "../composition/application-repositories.js"; - -// The password hash never leaves this layer. -function toDto(user: { - id: string; - username: string; - name: string; - role: string; - active: boolean; - mustChangePassword: boolean; - createdAt: Date; -}) { - return { - id: user.id, - username: user.username, - name: user.name, - role: user.role, - active: user.active, - mustChangePassword: user.mustChangePassword, - createdAt: user.createdAt, - }; -} - -function firstIssue(error: ZodError): string { - return error.issues[0]?.message ?? "Ungültige Eingabe."; -} - -export async function listUsers(_req: Request, res: Response) { - const rows = await userRepository.list(); - res.json(rows.map(toDto)); -} - -export async function createUser(req: Request, res: Response) { - const parsed = createUserSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: firstIssue(parsed.error) }); - return; - } - try { - const created = await userService.createUser(parsed.data); - if (!created) { - res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." }); - return; - } - res.status(201).json(toDto(created)); - } catch (error) { - if (error instanceof UsernameTakenError) { - res.status(409).json({ error: error.message }); - return; - } - throw error; - } -} - -export async function updateUser(req: Request, res: Response) { - const { userId } = req.params; - if (typeof userId !== "string") { - res.status(400).json({ error: "Invalid userId" }); - return; - } - const parsed = updateUserSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: firstIssue(parsed.error) }); - return; - } - // The signed-in admin can't lock themselves out by deactivating or - // demoting their own account through this endpoint. - if (userId === req.authUser?.id) { - res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." }); - return; - } - const existing = await userRepository.findById(userId); - if (!existing) { - res.status(404).json({ error: "Benutzer nicht gefunden." }); - return; - } - try { - const updated = await userService.updateRoleAndActive(userId, parsed.data); - // Deactivation takes effect immediately, not just at the next login. - if (parsed.data.active === false) sessionStore.destroyAllForUser(userId); - res.json(updated ? toDto(updated) : null); - } catch (error) { - if (error instanceof LastAdminError) { - res.status(400).json({ error: error.message }); - return; - } - throw error; - } -} - -export async function resetUserPassword(req: Request, res: Response) { - const { userId } = req.params; - if (typeof userId !== "string") { - res.status(400).json({ error: "Invalid userId" }); - return; - } - const parsed = resetPasswordSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: firstIssue(parsed.error) }); - return; - } - const existing = await userRepository.findById(userId); - if (!existing) { - res.status(404).json({ error: "Benutzer nicht gefunden." }); - return; - } - const updated = await userService.resetPassword(userId, parsed.data.newPassword); - // A reset is usually a response to a suspected compromise: every open - // session of that account ends now. - sessionStore.destroyAllForUser(userId); - res.json(updated ? toDto(updated) : null); -} diff --git a/src/server/index.ts b/src/server/index.ts index 9a65ce0..7cad7e3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,16 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { cableSizingRouter } from "./routes/cable-sizing.routes.js"; -import { authRouter } from "./routes/auth.routes.js"; -import { userRouter } from "./routes/user.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; -import { - createAuthGuard, - requirePasswordChanged, - requireSameOriginForWrites, -} from "./auth/auth.middleware.js"; -import { authConfig, sessionStore, userService } from "./composition/auth.js"; -import { userRepository } from "./composition/application-repositories.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { closeDefaultDatabase } from "../db/client.js"; @@ -23,7 +14,6 @@ const port = Number(process.env.PORT || 3000); const heartbeatIntervalMs = 5 * 60 * 1000; const shutdownTimeoutMs = 15 * 1000; -app.disable("x-powered-by"); app.use(express.json({ limit: "25mb" })); app.use((req, res, next) => { @@ -60,16 +50,6 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); -// Everything below /api requires a signed-in, active user, except -// /auth/login, /auth/status and /auth/logout (see createAuthGuard's -// openPaths). authRouter is mounted AFTER the guard (not before) so that -// /auth/me and /auth/change-password still get req.authUser populated by it. -app.use("/api", requireSameOriginForWrites()); -app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository)); -app.use("/api", requirePasswordChanged()); - -app.use("/api/auth", authRouter); -app.use("/api/users", userRouter); app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/projects", projectRouter); app.use("/api/global-devices", globalDeviceRouter); @@ -97,48 +77,40 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -userService - .ensureBootstrapAdmin() - .catch((error) => { - logger.error("bootstrap admin setup failed", toErrorMeta(error)); +const server = app.listen(port, () => { + logger.info("server started", { port }); +}); + +let shuttingDown = false; + +function shutdown(signal: NodeJS.Signals): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down", { signal }); + + // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der + // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. + const forceExit = setTimeout(() => { + logger.error("shutdown timed out, forcing exit"); process.exit(1); - }) - .then(() => { - const server = app.listen(port, () => { - logger.info("server started", { port }); - }); + }, shutdownTimeoutMs); + forceExit.unref(); - let shuttingDown = false; - - function shutdown(signal: NodeJS.Signals): void { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down", { signal }); - - // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der - // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. - const forceExit = setTimeout(() => { - logger.error("shutdown timed out, forcing exit"); - process.exit(1); - }, shutdownTimeoutMs); - forceExit.unref(); - - server.close((error) => { - if (error) logger.error("http server close failed", toErrorMeta(error)); - try { - closeDefaultDatabase(); - } catch (closeError) { - logger.error("database close failed", toErrorMeta(closeError)); - } - logger.info("shutdown complete"); - process.exit(error ? 1 : 0); - }); - - // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des - // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. - server.closeIdleConnections(); + server.close((error) => { + if (error) logger.error("http server close failed", toErrorMeta(error)); + try { + closeDefaultDatabase(); + } catch (closeError) { + logger.error("database close failed", toErrorMeta(closeError)); } - - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); + logger.info("shutdown complete"); + process.exit(error ? 1 : 0); }); + + // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des + // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. + server.closeIdleConnections(); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/src/server/routes/auth.routes.ts b/src/server/routes/auth.routes.ts deleted file mode 100644 index ee1cdd1..0000000 --- a/src/server/routes/auth.routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from "express"; -import { - changePassword, - getAuthStatus, - getMe, - login, - logout, -} from "../controllers/auth.controller.js"; - -export const authRouter = Router(); - -authRouter.get("/status", getAuthStatus); -authRouter.post("/login", login); -authRouter.get("/me", getMe); -authRouter.post("/change-password", changePassword); -authRouter.post("/logout", logout); diff --git a/src/server/routes/user.routes.ts b/src/server/routes/user.routes.ts deleted file mode 100644 index a34699c..0000000 --- a/src/server/routes/user.routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from "express"; -import { requireRole } from "../auth/auth.middleware.js"; -import { - createUser, - listUsers, - resetUserPassword, - updateUser, -} from "../controllers/user.controller.js"; - -export const userRouter = Router(); - -userRouter.use(requireRole("admin")); -userRouter.get("/", listUsers); -userRouter.post("/", createUser); -userRouter.patch("/:userId", updateUser); -userRouter.post("/:userId/reset-password", resetUserPassword); diff --git a/src/shared/constants/request-security.ts b/src/shared/constants/request-security.ts deleted file mode 100644 index f89ce10..0000000 --- a/src/shared/constants/request-security.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request"; -export const WRITE_REQUEST_HEADER_VALUE = "1"; diff --git a/src/shared/constants/user-roles.ts b/src/shared/constants/user-roles.ts deleted file mode 100644 index f123d5f..0000000 --- a/src/shared/constants/user-roles.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const USER_ROLES = ["admin", "user"] as const; -export type UserRole = (typeof USER_ROLES)[number]; - -export const USER_ROLE_LABEL: Record = { - admin: "Administrator", - user: "Benutzer", -}; diff --git a/src/shared/validation/auth.schemas.ts b/src/shared/validation/auth.schemas.ts deleted file mode 100644 index 7d95eec..0000000 --- a/src/shared/validation/auth.schemas.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; - -// Upper bounds keep a huge request body from being fed into scrypt. -export const MAX_USERNAME_LENGTH = 100; -export const MAX_PASSWORD_LENGTH = 200; -export const MIN_PASSWORD_LENGTH = 8; - -export const loginSchema = z - .object({ - username: z.string().trim().min(1).max(MAX_USERNAME_LENGTH), - password: z.string().min(1).max(MAX_PASSWORD_LENGTH), - }) - .strict(); - -export const changePasswordSchema = z - .object({ - currentPassword: z.string().min(1).max(MAX_PASSWORD_LENGTH), - newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), - }) - .strict(); diff --git a/src/shared/validation/user.schemas.ts b/src/shared/validation/user.schemas.ts deleted file mode 100644 index cec4c5f..0000000 --- a/src/shared/validation/user.schemas.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import { USER_ROLES } from "../constants/user-roles.js"; -import { - MAX_PASSWORD_LENGTH, - MAX_USERNAME_LENGTH, - MIN_PASSWORD_LENGTH, -} from "./auth.schemas.js"; - -// ASCII only: rules out look-alike Unicode names ("admіn" with a Cyrillic i) -// and invisible whitespace. E-mail addresses fit this pattern. -const USERNAME_PATTERN = /^[a-z0-9._@-]+$/; - -export const createUserSchema = z - .object({ - username: z - .string() - .trim() - .toLowerCase() - .min(1) - .max(MAX_USERNAME_LENGTH) - .regex(USERNAME_PATTERN, { - message: "Anmeldename: nur a-z, 0-9 sowie . _ @ - erlaubt.", - }), - name: z.string().trim().min(1).max(200), - role: z.enum(USER_ROLES), - initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), - }) - .strict(); - -export const updateUserSchema = z - .object({ - role: z.enum(USER_ROLES).optional(), - active: z.boolean().optional(), - }) - .strict() - .refine((value) => value.role !== undefined || value.active !== undefined, { - message: "Nothing to update.", - }); - -export const resetPasswordSchema = z - .object({ - newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), - }) - .strict(); - -export type CreateUserInput = z.infer; -export type UpdateUserInput = z.infer; -export type ResetPasswordInput = z.infer; diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts deleted file mode 100644 index e5c431d..0000000 --- a/tests/authentication.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { Request, Response } from "express"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { createDatabaseContext } from "../src/db/database-context.js"; -import { UserRepository } from "../src/db/repositories/user.repository.js"; -import { - InvalidCredentialsError, - LastAdminError, - PasswordUnchangedError, - UserService, - UsernameTakenError, - WrongPasswordError, -} from "../src/domain/services/user.service.js"; -import { hashPassword, verifyPassword } from "../src/server/auth/password.js"; -import { SessionStore } from "../src/server/auth/sessions.js"; -import { LoginThrottle } from "../src/server/auth/login-throttle.js"; -import { readCookie } from "../src/server/auth/cookie.js"; -import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js"; -import type { AuthConfig } from "../src/server/auth/auth-config.js"; -import { createUserSchema } from "../src/shared/validation/user.schemas.js"; -import { loginSchema } from "../src/shared/validation/auth.schemas.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../src/shared/constants/request-security.js"; - -const HOUR = 60 * 60 * 1000; - -function createUserService() { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - const repository = new UserRepository(context.db); - const config: AuthConfig = { - sessionTtlMs: 12 * HOUR, - sessionMaxAgeMs: 7 * 24 * HOUR, - cookieSecure: false, - bootstrap: { username: "admin", password: "bootstrap-password-123" }, - }; - return { - repository, - service: new UserService( - repository, - { bootstrap: config.bootstrap }, - { hash: hashPassword, verify: verifyPassword } - ), - }; -} - -describe("password hashing", () => { - it("verifies the right password and rejects a wrong one", async () => { - const hash = await hashPassword("richtiges-passwort"); - assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/); - assert.equal(await verifyPassword("richtiges-passwort", hash), true); - assert.equal(await verifyPassword("falsches-passwort", hash), false); - }); - - it("uses a fresh salt per hash", async () => { - assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich")); - }); - - it("rejects malformed stored hashes instead of throwing", async () => { - assert.equal(await verifyPassword("x", "not-a-hash"), false); - assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false); - assert.equal( - await verifyPassword( - "x", - `scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}` - ), - false - ); - assert.equal( - await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`), - false - ); - }); -}); - -describe("session store", () => { - it("expires idle sessions and slides on activity", () => { - let now = 0; - const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now); - const session = store.create("user-1"); - now = 50 * 60 * 1000; - assert.ok(store.get(session.id)); - now += 50 * 60 * 1000; - assert.ok(store.get(session.id), "activity must have extended the idle window"); - now += 2 * HOUR; - assert.equal(store.get(session.id), undefined); - }); - - it("never outlives the absolute lifetime, even when constantly used", () => { - let now = 0; - const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now); - const session = store.create("user-1"); - for (let step = 0; step < 5; step += 1) { - now += 30 * 60 * 1000; - store.get(session.id); - } - now = 3 * HOUR; - assert.equal(store.get(session.id), undefined); - }); - - it("ends all sessions of a user except an optional one", () => { - const store = new SessionStore(HOUR, 24 * HOUR); - const first = store.create("user-1"); - const second = store.create("user-1"); - const other = store.create("user-2"); - store.destroyAllForUser("user-1", second.id); - assert.equal(store.get(first.id), undefined); - assert.ok(store.get(second.id)); - assert.ok(store.get(other.id)); - }); - - it("bounds the number of sessions per user by dropping the oldest", () => { - let now = 0; - const store = new SessionStore(HOUR, 24 * HOUR, () => now); - const oldest = store.create("user-1"); - for (let index = 0; index < 20; index += 1) { - now += 1; - store.create("user-1"); - } - assert.equal(store.get(oldest.id), undefined); - }); -}); - -describe("login throttle", () => { - it("blocks a username after repeated failures and releases it after the window", () => { - let now = 0; - const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now); - for (let attempt = 0; attempt < 3; attempt += 1) { - assert.equal(throttle.retryAfterMs("Admin"), 0); - throttle.recordFailure("admin "); - } - assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter"); - assert.equal(throttle.retryAfterMs("someone-else"), 0); - now = 15 * 60 * 1000 + 1; - assert.equal(throttle.retryAfterMs("admin"), 0); - }); - - it("resets the counter after a successful login", () => { - const throttle = new LoginThrottle(2); - throttle.recordFailure("admin"); - throttle.recordSuccess("admin"); - throttle.recordFailure("admin"); - assert.equal(throttle.retryAfterMs("admin"), 0); - }); -}); - -describe("cookie parsing", () => { - it("reads the session cookie and ignores malformed encodings", () => { - assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc"); - assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined); - assert.equal(readCookie(undefined, "lb_session"), undefined); - }); -}); - -describe("same-origin guard for writes", () => { - function run(method: string, fetchSite?: string, includeApplicationHeader = true) { - let status: number | undefined; - let passed = false; - const req = { - method, - headers: { - ...(fetchSite ? { "sec-fetch-site": fetchSite } : {}), - ...(includeApplicationHeader - ? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE } - : {}), - }, - get(name: string) { - return this.headers[name.toLowerCase() as keyof typeof this.headers]; - }, - } as unknown as Request; - const res = { - status(code: number) { - status = code; - return this; - }, - json() { - return this; - }, - } as unknown as Response; - requireSameOriginForWrites()(req, res, () => { - passed = true; - }); - return { passed, status }; - } - - it("lets reads and same-origin writes through", () => { - assert.equal(run("GET", "cross-site", false).passed, true); - assert.equal(run("POST", "same-origin").passed, true); - assert.equal(run("POST").passed, true, "scripts can send the explicit application header"); - }); - - it("rejects writes without the application header or from another origin", () => { - assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 }); - assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 }); - assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 }); - assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 }); - }); -}); - -describe("input validation", () => { - it("rejects look-alike and whitespace usernames and normalizes case", () => { - const base = { name: "Test", role: "user", initialPassword: "start-passwort" }; - assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false); - assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false); - const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " }); - assert.equal(parsed.username, "max.muster@firma.de"); - }); - - it("caps login input length so huge bodies never reach scrypt", () => { - assert.equal( - loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success, - false - ); - }); -}); - -describe("user service", () => { - it("creates the bootstrap admin only while the table is empty", async () => { - const { repository, service } = createUserService(); - await service.ensureBootstrapAdmin(); - await service.ensureBootstrapAdmin(); - const users = await repository.list(); - assert.equal(users.length, 1); - assert.equal(users[0].role, "admin"); - assert.equal(users[0].mustChangePassword, true); - }); - - it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => { - const { service } = createUserService(); - const user = await service.createUser({ - username: "max", - name: "Max", - role: "user", - initialPassword: "start-passwort", - }); - assert.ok(user); - await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError); - await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError); - assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id); - await service.updateRoleAndActive(user.id, { active: false }); - await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError); - }); - - it("refuses duplicate usernames", async () => { - const { service } = createUserService(); - const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" }; - await service.createUser(input); - await assert.rejects(service.createUser(input), UsernameTakenError); - }); - - it("never removes the last active admin", async () => { - const { repository, service } = createUserService(); - await service.ensureBootstrapAdmin(); - const [admin] = await repository.list(); - await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError); - await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError); - - const second = await service.createUser({ - username: "zweiter", - name: "Zweiter", - role: "admin", - initialPassword: "start-passwort", - }); - assert.ok(second); - await service.updateRoleAndActive(admin.id, { role: "user" }); - await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError); - }); - - it("preserves one active admin across concurrent updates", async () => { - const { repository, service } = createUserService(); - await service.ensureBootstrapAdmin(); - const second = await service.createUser({ - username: "zweiter-admin", - name: "Zweiter Admin", - role: "admin", - initialPassword: "start-passwort", - }); - assert.ok(second); - const [first] = (await repository.list()).filter((user) => user.username === "admin"); - - const results = await Promise.allSettled([ - service.updateRoleAndActive(first.id, { role: "user" }), - service.updateRoleAndActive(second.id, { role: "user" }), - ]); - assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); - assert.equal(results.filter((result) => result.status === "rejected").length, 1); - const activeAdmins = (await repository.list()).filter( - (user) => user.role === "admin" && user.active - ); - assert.equal(activeAdmins.length, 1); - }); - - it("requires the current password and a different new one", async () => { - const { service } = createUserService(); - const user = await service.createUser({ - username: "max", - name: "Max", - role: "user", - initialPassword: "start-passwort", - }); - assert.ok(user); - await assert.rejects( - service.changeOwnPassword(user.id, "falsch", "neues-passwort"), - WrongPasswordError - ); - await assert.rejects( - service.changeOwnPassword(user.id, "start-passwort", "start-passwort"), - PasswordUnchangedError - ); - const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort"); - assert.equal(changed?.mustChangePassword, false); - - const reset = await service.resetPassword(user.id, "admin-vergeben"); - assert.equal(reset?.mustChangePassword, true); - }); -}); diff --git a/tests/config-access.test.ts b/tests/config-access.test.ts deleted file mode 100644 index 3fdc83d..0000000 --- a/tests/config-access.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - getConfigEntry, - getConfigRedirect, - getConfigTabs, -} from "../src/frontend/utils/config-access.js"; - -describe("config access", () => { - it("offers account settings to every signed-in user", () => { - assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" }); - assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]); - assert.equal(getConfigRedirect("/config", false), "/config/konto"); - assert.equal(getConfigRedirect("/config/konto", false), null); - }); - - it("keeps user administration admin-only", () => { - assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto"); - assert.deepEqual(getConfigTabs(true), [ - { href: "/config/benutzer", label: "Benutzer" }, - { href: "/config/konto", label: "Konto" }, - ]); - assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" }); - assert.equal(getConfigRedirect("/config/benutzer", true), null); - }); -}); diff --git a/tests/write-request-headers.test.ts b/tests/write-request-headers.test.ts deleted file mode 100644 index c78a277..0000000 --- a/tests/write-request-headers.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { authApi } from "../src/frontend/utils/auth-api.js"; -import { createProject } from "../src/frontend/utils/api.js"; -import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js"; -import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../src/shared/constants/request-security.js"; - -describe("write request clients", () => { - it("send the application header from every frontend API client", async () => { - const originalFetch = globalThis.fetch; - const requests: RequestInit[] = []; - globalThis.fetch = async (_input, init) => { - requests.push(init ?? {}); - return new Response(JSON.stringify({}), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - - try { - await authApi.login("admin", "start-passwort"); - await createProject("Testprojekt"); - await calculateCableSizing({} as CableSizingInput); - } finally { - globalThis.fetch = originalFetch; - } - - assert.equal(requests.length, 3); - for (const request of requests) { - assert.equal( - new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME), - WRITE_REQUEST_HEADER_VALUE - ); - } - }); -});