diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46fa42a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# 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 a6e1ff5..b237969 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ 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 93e5108..b01e6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,16 @@ 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. @@ -732,6 +742,11 @@ 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 b01ae85..f64ae04 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ 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 -und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau +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 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 @@ -60,6 +62,8 @@ Voraussetzungen: ```powershell git clone Set-Location leistungsbilanz-ts +$env:AUTH_BOOTSTRAP_USERNAME = "admin" +$env:AUTH_BOOTSTRAP_PASSWORT = "" docker compose up --build --detach ``` @@ -68,9 +72,15 @@ Danach: - Frontend: - API-Healthcheck: (über den Next.js-Rewrite) -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. +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. Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start automatisch aus. Die SQLite-Datei liegt auf dem Host unter @@ -116,6 +126,8 @@ 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 ``` @@ -162,6 +174,7 @@ 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 b9c64b4..f49e9e6 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -35,8 +35,12 @@ services: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf - # nicht aus dem LAN erreichbar sein. + 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. 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 3438f90..1de9104 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,8 +45,16 @@ services: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung - # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. + # 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. expose: - "3000" # Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit diff --git a/docs/README.md b/docs/README.md index 01b5588..3569b42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ 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 new file mode 100644 index 0000000..32039cf --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,99 @@ +# 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 71b7feb..20c7ccd 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -33,6 +33,15 @@ 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 @@ -57,6 +66,11 @@ 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 0b5baba..89c99a3 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 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. +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. ## Topologie @@ -29,9 +29,10 @@ deshalb nicht öffentlich erreichbar gemacht werden. | 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 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 +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 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. @@ -72,6 +73,26 @@ 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 @@ -137,7 +158,8 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie. Vor einer produktiven Installation werden mindestens benötigt: - TLS-Termination und Reverse Proxy -- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell +- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei + festen Rollen später nicht ausreichen - 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 e0610b9..a6cb01e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,6 +11,22 @@ 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 new file mode 100644 index 0000000..134890d --- /dev/null +++ b/src/app/config/benutzer/page.tsx @@ -0,0 +1,325 @@ +"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 new file mode 100644 index 0000000..2b58ee7 --- /dev/null +++ b/src/app/config/konto/page.tsx @@ -0,0 +1,103 @@ +"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 new file mode 100644 index 0000000..72d9b6d --- /dev/null +++ b/src/app/config/layout.tsx @@ -0,0 +1,52 @@ +"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 new file mode 100644 index 0000000..3c9d8fd --- /dev/null +++ b/src/app/config/page.tsx @@ -0,0 +1,3 @@ +export default function ConfigPage() { + return null; +} diff --git a/src/app/globals.css b/src/app/globals.css index 88df3e9..8b55c75 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -323,23 +323,24 @@ a { } .sidebar-footer { - padding: 12px; + padding-top: 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; - gap: 10px; - padding: 10px 14px; + justify-content: center; + flex: 0 0 auto; + width: 36px; + height: 36px; + padding: 0; 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; } @@ -451,13 +452,25 @@ a { background: var(--color-bg); } -.filter-panel-header { +.filter-panel-row { display: flex; align-items: center; gap: 10px; } -.filter-panel-header .filter-panel-toggle { +/* 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; width: 24px; height: 24px; padding: 0; @@ -467,7 +480,7 @@ a { color: var(--color-ink-soft); } -.filter-panel-header .filter-panel-toggle:hover { +.filter-panel-row .filter-panel-toggle:hover { background: var(--color-surface); } @@ -502,10 +515,11 @@ 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, @@ -2730,3 +2744,128 @@ 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 cd2407b..c830c99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ 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", @@ -36,7 +38,11 @@ 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 new file mode 100644 index 0000000..6c07010 --- /dev/null +++ b/src/db/migrations/0013_users.sql @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..bd091fb --- /dev/null +++ b/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,3072 @@ +{ + "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 548734d..2a2e076 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "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 new file mode 100644 index 0000000..1fd55c7 --- /dev/null +++ b/src/db/repositories/user.repository.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..7105fb4 --- /dev/null +++ b/src/db/schema/users.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..9b80023 --- /dev/null +++ b/src/domain/ports/user.store.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..7ea2f0b --- /dev/null +++ b/src/domain/services/user.service.ts @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..02ab833 --- /dev/null +++ b/src/frontend/components/AuthProvider.tsx @@ -0,0 +1,61 @@ +"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 new file mode 100644 index 0000000..23a2aeb --- /dev/null +++ b/src/frontend/components/LoginGate.tsx @@ -0,0 +1,179 @@ +"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 395cd7c..e99b493 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -6,6 +6,8 @@ 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 = [ @@ -30,6 +32,8 @@ 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] ?? ""; @@ -179,8 +183,34 @@ 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 355e0e2..2073a5e 100644 --- a/src/frontend/components/ThemeToggle.tsx +++ b/src/frontend/components/ThemeToggle.tsx @@ -48,16 +48,17 @@ 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 28530d2..73582b5 100644 --- a/src/frontend/components/cable-sizing-api.ts +++ b/src/frontend/components/cable-sizing-api.ts @@ -6,6 +6,10 @@ 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; @@ -17,7 +21,10 @@ export async function calculateCableSizing( ): Promise { const response = await fetch("/api/cable-sizing/calculate", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, + }, body: JSON.stringify(input), cache: "no-store", }); diff --git a/src/frontend/components/filter-panel.tsx b/src/frontend/components/filter-panel.tsx index afa1fde..9c9923b 100644 --- a/src/frontend/components/filter-panel.tsx +++ b/src/frontend/components/filter-panel.tsx @@ -55,9 +55,15 @@ 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 (
-
+
- Filter - {isCollapsed && isFilterActive ? ( - Filter aktiv - ) : null} + {isCollapsed ? ( + <> + Filter + {isFilterActive ? Filter aktiv : null} + + ) : ( +
+ {children} + {isFilterActive ? ( + + ) : null} +
+ )} {visibleCount} von {totalCount}
- {isCollapsed ? null : ( -
- {children} - {isFilterActive ? ( - - ) : null} -
- )}
); } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 8f5a878..5256195 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,3 +1,7 @@ +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../../shared/constants/request-security"; import type { BuildingDto, CircuitListDto, @@ -122,6 +126,7 @@ 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 new file mode 100644 index 0000000..a6f12b3 --- /dev/null +++ b/src/frontend/utils/auth-api.ts @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..86dd2a2 --- /dev/null +++ b/src/frontend/utils/config-access.ts @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..c4c3b44 --- /dev/null +++ b/src/server/auth/auth-config.ts @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..bafae03 --- /dev/null +++ b/src/server/auth/auth.middleware.ts @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..344ff65 --- /dev/null +++ b/src/server/auth/cookie.ts @@ -0,0 +1,20 @@ +/** 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 new file mode 100644 index 0000000..d81719b --- /dev/null +++ b/src/server/auth/login-throttle.ts @@ -0,0 +1,53 @@ +/** + * 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 new file mode 100644 index 0000000..97132af --- /dev/null +++ b/src/server/auth/password.ts @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..9ee1fe0 --- /dev/null +++ b/src/server/auth/sessions.ts @@ -0,0 +1,105 @@ +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 66fdd92..846a0da 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -16,6 +16,7 @@ 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); @@ -39,3 +40,4 @@ 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 new file mode 100644 index 0000000..07b822b --- /dev/null +++ b/src/server/composition/auth.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..5eaf699 --- /dev/null +++ b/src/server/controllers/auth.controller.ts @@ -0,0 +1,109 @@ +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 new file mode 100644 index 0000000..bdf8d0f --- /dev/null +++ b/src/server/controllers/user.controller.ts @@ -0,0 +1,121 @@ +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 7cad7e3..9a65ce0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,16 @@ 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"; @@ -14,6 +23,7 @@ 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) => { @@ -50,6 +60,16 @@ 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); @@ -77,40 +97,48 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -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"); +userService + .ensureBootstrapAdmin() + .catch((error) => { + logger.error("bootstrap admin setup failed", toErrorMeta(error)); process.exit(1); - }, shutdownTimeoutMs); - forceExit.unref(); + }) + .then(() => { + const server = app.listen(port, () => { + logger.info("server started", { port }); + }); - server.close((error) => { - if (error) logger.error("http server close failed", toErrorMeta(error)); - try { - closeDefaultDatabase(); - } catch (closeError) { - logger.error("database close failed", toErrorMeta(closeError)); + 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(); } - logger.info("shutdown complete"); - process.exit(error ? 1 : 0); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); }); - - // 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 new file mode 100644 index 0000000..ee1cdd1 --- /dev/null +++ b/src/server/routes/auth.routes.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..a34699c --- /dev/null +++ b/src/server/routes/user.routes.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..f89ce10 --- /dev/null +++ b/src/shared/constants/request-security.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..f123d5f --- /dev/null +++ b/src/shared/constants/user-roles.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..7d95eec --- /dev/null +++ b/src/shared/validation/auth.schemas.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..cec4c5f --- /dev/null +++ b/src/shared/validation/user.schemas.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..e5c431d --- /dev/null +++ b/tests/authentication.test.ts @@ -0,0 +1,320 @@ +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 new file mode 100644 index 0000000..3fdc83d --- /dev/null +++ b/tests/config-access.test.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..c78a277 --- /dev/null +++ b/tests/write-request-headers.test.ts @@ -0,0 +1,40 @@ +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 + ); + } + }); +});