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 4300b38..b01e6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ It must support circuits, device rows, project devices, drag-and-drop restructur `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`). See `docs/authentication.md` for the full picture. + (`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. @@ -483,6 +486,17 @@ downstream board's demand after its own board simultaneity factor to the chosen feeding circuit as a read-only virtual row. Recursive chains and several boards on one circuit are supported. A board-only link affects hierarchy but adds no load until a feeding circuit is selected. +The distribution-board dialog can create one empty feeder circuit and link it +in the same `distribution-board.create-feeder-circuit` project command. Its +history inverse restores the complete previous board update before removing +only the unchanged generated circuit; Redo reuses its UUID and BMK. No +CircuitDeviceRow is created. The three-phase group is suggested, the optional +circuit display name stays empty by default, and protection remains open for +planner sizing. A circuit with exactly one downstream board and no device rows +is rendered as one compact row using the live board number/name; several +downstream boards or additional devices retain summary and child rows. Editing +the name or remark of an empty reserve circuit changes the circuit itself and +does not implicitly create a device row. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. 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 2c362bc..f49e9e6 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -38,6 +38,8 @@ services: 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" diff --git a/compose.yaml b/compose.yaml index 2a2eecd..1de9104 100644 --- a/compose.yaml +++ b/compose.yaml @@ -47,7 +47,7 @@ services: LOG_LEVEL: "${LOG_LEVEL:-info}" # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte - # .env - siehe docs/AUTH.md. + # .env - siehe docs/authentication.md. AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" diff --git a/docs/authentication.md b/docs/authentication.md index 15729aa..32039cf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -7,10 +7,9 @@ Zugang wird von einem Administrator über **Config → Benutzer** angelegt. Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): -- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich - (Benutzerverwaltung, eigenes Konto) +- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer** - **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, - Grobbilanz, …), kein Zugriff auf Config + 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. @@ -44,6 +43,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. - **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 @@ -52,12 +53,12 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. | 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`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten | -| CSRF über reines HTTP | Browser senden `Sec-Fetch-Site` nur an HTTPS-Adressen. Beim Zugriff per `http://…` im LAN greift deshalb nur SameSite=Lax (schützt gegen fremde Domains, nicht gegen andere Ports desselben Hosts). | +| 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; eigener Zugang ist in der Liste gesperrt | +| 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 | @@ -77,6 +78,18 @@ 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 diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index a8173f5..de724aa 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. submitted info fields, are restored together by persistent Undo/Redo - `fedFrom` remains free text for external feeds such as HAK, metering, transformer or generator and for supplementary notes +- The project-command endpoint additionally accepts + `distribution-board.create-feeder-circuit` with an empty complete circuit + snapshot and an embedded version-2 `distribution-board.update`. Both writes + are one transaction, revision and Undo step. The generated circuit has no + device row; Redo keeps its UUID and BMK. The history-only inverse + `distribution-board.remove-feeder-circuit` restores the previous board values + and removes only the unchanged circuit. - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - duplicates the complete distribution-board subtree with new owning UUIDs @@ -346,7 +353,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. footer components. Circuit and component protection devices use their separate one-to-one DTOs. Calculated row, circuit, section and board totals remain included. Circuit totals contain the demand of linked downstream - boards. `downstreamBoardLoads` exposes those contributions as read-only rows; + boards. `downstreamBoardLoads` exposes those contributions as read-only + values; the grid combines exactly one downstream board without device rows + into the circuit row and keeps child rows for mixed or multiple loads. `feedingReference` resolves the current board/circuit labels by id. Response sketch: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 920002d..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 @@ -477,6 +491,21 @@ Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden vor der Datenbankänderung fachlich gesperrt. +Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein +neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige +Gruppe und das nächste BMK vor. Der Befehl +`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und +wendet die vollständige Verteilungsänderung in derselben Transaktion und +Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst +offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo +stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach +nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut. +Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das +Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten +Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen +Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren +Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine +Gerätezeile. Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten 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/docs/print-view.md b/docs/print-view.md index a843030..ecc00e2 100644 --- a/docs/print-view.md +++ b/docs/print-view.md @@ -38,8 +38,9 @@ automatisch bei späteren Änderungen in einem anderen Tab. 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Pgl; darunter Kopfkomponenten, Gruppen, Stromkreise - (mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und - schreibgeschützten Unterverteilungszeilen), + (reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit + Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten + Unterverteilungszeilen), Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim Drucken auf Folgeseiten. diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx index 1356d78..72d9b6d 100644 --- a/src/app/config/layout.tsx +++ b/src/app/config/layout.tsx @@ -4,26 +4,27 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "../../frontend/components/AuthProvider"; - -const CONFIG_TABS = [ - { href: "/config/benutzer", label: "Benutzer" }, - { href: "/config/konto", label: "Konto" }, -] as const; +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(() => { - // LoginGate already guarantees a signed-in user with a set password by - // the time this layout renders; only the role still needs checking. - if (status === "signed-in" && !isAdmin) { - router.replace("/"); - } - }, [status, isAdmin, router]); + if (status !== "signed-in") return; + if (redirectTarget) router.replace(redirectTarget); + }, [status, redirectTarget, router]); - if (status !== "signed-in" || !isAdmin) { + if ( + status !== "signed-in" || + redirectTarget !== null + ) { return null; } @@ -34,7 +35,7 @@ export default function ConfigLayout({ children }: { children: React.ReactNode } - {isAdmin && ( - - )} +
{user && !isCollapsed && (
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/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index bac08ce..f75b64e 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const currentDisplay = mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" ? typedChar ?? "" - : String(visibleCell.value ?? ""); + : String(visibleCell.editValue ?? visibleCell.value ?? ""); focusTokenRef.current += 1; setEditingCell({ ...cell, @@ -4077,7 +4077,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str key={row.rowKey} className={`${ row.rowType === "circuitSummary" - ? "summary-row" + ? row.downstreamBoardLoad + ? "feeder-circuit-row" + : "summary-row" : row.rowType === "deviceRow" ? "device-row" : row.rowType === "downstreamBoardLoad" @@ -4708,11 +4710,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str tabIndex={-1} /> 0 + } icon={Trash2} label="Stromkreis löschen" onClick={() => void handleDeleteCircuit(row.circuit!.id)} tabIndex={-1} + title={ + (row.circuit.downstreamBoardLoads?.length ?? 0) > 0 + ? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern" + : undefined + } variant="danger" /> diff --git a/src/frontend/print/print-document.tsx b/src/frontend/print/print-document.tsx index 5a0152b..a13d7e1 100644 --- a/src/frontend/print/print-document.tsx +++ b/src/frontend/print/print-document.tsx @@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) { ); } + if (rows.length === 0 && downstreamBoards.length === 1) { + const boardLabel = + downstreamBoards[0].boardNumber ?? downstreamBoards[0].name; + return ( + + {circuit.equipmentIdentifier} + + {boardLabel} + {circuit.displayName ? ( + · {circuit.displayName} + ) : null} + + – + + {formatKilowatt(circuit.circuitTotalPower)} + {circuitCells} + + ); + } if (rows.length === 1 && downstreamBoards.length === 0) { const row = rows[0]; const title = circuit.displayName ?? row.displayName; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 8473bfa..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", @@ -366,20 +371,22 @@ export function createDistributionBoard( ); } +export type UpdateDistributionBoardInput = { + floorId: string | null; + supplyType: NonNullable; + simultaneityFactor: number; +} & Partial & + Partial< + Pick< + DistributionBoardDto, + "feedingDistributionBoardId" | "feedingCircuitId" + > + >; + export function updateDistributionBoard( projectId: string, distributionBoardId: string, - input: { - floorId: string | null; - supplyType: NonNullable; - simultaneityFactor: number; - } & Partial & - Partial< - Pick< - DistributionBoardDto, - "feedingDistributionBoardId" | "feedingCircuitId" - > - >, + input: UpdateDistributionBoardInput, expectedRevision: number ) { return request( @@ -391,6 +398,41 @@ export function updateDistributionBoard( ); } +export function createFeederCircuitAndUpdateDistributionBoard( + projectId: string, + distributionBoardId: string, + input: UpdateDistributionBoardInput & { + feedingDistributionBoardId: string; + feedingCircuitId: string; + }, + circuit: CircuitSnapshot, + expectedRevision: number +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "distribution-board.create-feeder-circuit", + payload: { + circuit, + boardUpdate: { + schemaVersion: 2, + type: "distribution-board.update", + payload: { + distributionBoardId, + changes: Object.entries(input).map(([field, value]) => ({ + field, + value, + })), + }, + }, + }, + }, + "Einspeiseabgang anlegen und Verteilung verknüpfen" + ); +} + export const documentLogoUrl = "/api/document-branding/logo"; export function getDocumentBranding() { diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts index f7057f9..a6f12b3 100644 --- a/src/frontend/utils/auth-api.ts +++ b/src/frontend/utils/auth-api.ts @@ -1,3 +1,8 @@ +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. @@ -39,7 +44,11 @@ export class AuthApiError extends Error { async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, + headers: { + "Content-Type": "application/json", + [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, + ...init?.headers, + }, cache: "no-store", }); if (!response.ok) { diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index faa4a11..fafeca4 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -617,7 +617,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind { if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; if (rowType === "downstreamBoardLoad") return "readonly"; if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly"; - if (rowType === "reserveCircuit" || rowType === "circuitCompact") { + if (rowType === "reserveCircuit") { + if (circuitFieldKeys.has(key)) return "circuitField"; + if (deviceFieldKeys.has(key)) return "deviceField"; + } + if (rowType === "circuitCompact") { if (deviceFieldKeys.has(key)) return "deviceField"; if (circuitFieldKeys.has(key)) return "circuitField"; } diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts index a9b11de..30e5ecf 100644 --- a/src/frontend/utils/circuit-grid-projection.ts +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -37,6 +37,8 @@ export interface VisibleGridCell { editable: boolean; kind: CellKind; value: GridValue; + /** Separate persisted value when the displayed value is derived. */ + editValue?: GridValue; } export interface VisibleGridRow { @@ -144,6 +146,7 @@ function makeVisibleGridRow( const kind = getCellKind(rowType, column.key); const editable = kind === "circuitField" || kind === "deviceField"; let value: GridValue; + let editValue: GridValue | undefined; if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { value = column.key === "displayName" @@ -155,6 +158,35 @@ function makeVisibleGridRow( : column.key === "remark" ? "Automatisch aus der verknüpften Verteilung" : undefined; + } else if ( + rowType === "circuitSummary" && + circuit && + downstreamBoardLoad && + circuit.deviceRows.length === 0 + ) { + if (column.key === "displayName") { + const boardLabel = + downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name; + value = circuit.displayName + ? `${boardLabel} · ${circuit.displayName}` + : boardLabel; + editValue = circuit.displayName ?? ""; + } else if (column.key === "cosPhi") { + value = circuit.circuitCosPhi; + } else if ( + kind === "computed" && + circuitOnlyColumns.has(column.key) + ) { + value = getCircuitValue(circuit, column.key); + } else if (kind === "circuitField") { + value = getCircuitValue(circuit, column.key); + } else if (column.key === "rowTotalPower") { + value = circuit.circuitTotalPower; + } else if (column.key === "circuitTotalPower") { + value = circuit.circuitTotalPower; + } else if (column.key === "voltage") { + value = circuit.voltage; + } } else if (rowType === "placeholder") { value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; } else if (kind === "deviceField" && device) { @@ -178,7 +210,7 @@ function makeVisibleGridRow( value = circuit.voltage; } - return { cellKey: column.key, editable, kind, value }; + return { cellKey: column.key, editable, kind, value, editValue }; }); return { @@ -247,6 +279,19 @@ export function buildVisibleGridRowsWithStructure( const downstreamLoads = circuit.downstreamBoardLoads ?? []; if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); + } else if ( + circuit.deviceRows.length === 0 && + downstreamLoads.length === 1 + ) { + rows.push( + makeVisibleGridRow( + "circuitSummary", + section.id, + circuit, + undefined, + downstreamLoads[0] + ) + ); } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); } else { 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/frontend/utils/distribution-board-feeder-circuit.ts b/src/frontend/utils/distribution-board-feeder-circuit.ts new file mode 100644 index 0000000..c2c7f43 --- /dev/null +++ b/src/frontend/utils/distribution-board-feeder-circuit.ts @@ -0,0 +1,68 @@ +import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js"; +import type { + CircuitTreeResponseDto, + CircuitTreeSectionDto, +} from "../types"; +import { buildCircuitInsertSnapshot } from "./circuit-structure-command"; + +export function getDefaultFeederCircuitSection( + tree: CircuitTreeResponseDto | undefined +) { + if (!tree) return undefined; + return ( + tree.sections.find((section) => section.category === "three_phase") ?? + tree.sections.find((section) => section.category !== undefined) + ); +} + +export function getNextFeederCircuitIdentifier( + section: CircuitTreeSectionDto +) { + let highestSuffix = 0; + for (const circuit of section.circuits) { + if (!circuit.equipmentIdentifier.startsWith(section.prefix)) continue; + const suffix = circuit.equipmentIdentifier.slice(section.prefix.length); + if (!/^\d+$/.test(suffix)) continue; + highestSuffix = Math.max(highestSuffix, Number(suffix)); + } + return `${section.prefix}${highestSuffix + 1}`; +} + +export function buildFeederCircuitSnapshot(input: { + id: string; + tree: CircuitTreeResponseDto; + sectionId: string; + displayName: string; +}): CircuitSnapshot { + const section = input.tree.sections.find( + (candidate) => candidate.id === input.sectionId + ); + if (!section || !section.category) { + throw new Error("Die gewählte Stromkreisgruppe ist nicht verfügbar."); + } + const snapshot = buildCircuitInsertSnapshot({ + id: input.id, + circuitListId: input.tree.circuitListId, + category: section.category, + deviceRows: [], + values: { + sectionId: section.id, + equipmentIdentifier: getNextFeederCircuitIdentifier(section), + displayName: input.displayName.trim() || undefined, + sortOrder: + section.circuits.length === 0 + ? 10 + : Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + + 10, + voltage: + section.category === "three_phase" + ? input.tree.threePhaseVoltageV + : input.tree.singlePhaseVoltageV, + isReserve: true, + }, + }); + // A distribution-board feeder is not a generic final circuit. Its protection + // must be selected from the calculated load and cable, so no 16 A group + // default is persisted here. + return { ...snapshot, protectionDevice: null }; +} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 146cda8..eb195d0 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -32,6 +32,10 @@ const commandTypeLabels: Record = { "project.update-settings": "Projekteinstellungen bearbeitet", "distribution-board.insert": "Verteilung angelegt", "distribution-board.update": "Verteilung bearbeitet", + "distribution-board.create-feeder-circuit": + "Einspeiseabgang angelegt und verknüpft", + "distribution-board.remove-feeder-circuit": + "Einspeiseabgang und Verknüpfung entfernt", "distribution-board.delete": "Verteilung entfernt", "distribution-board.insert-subtree": "Verteilung vollständig eingefügt", "distribution-board.delete-subtree": "Verteilung vollständig entfernt", diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts index ce65bfb..bafae03 100644 --- a/src/server/auth/auth.middleware.ts +++ b/src/server/auth/auth.middleware.ts @@ -2,8 +2,12 @@ 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 { UserRepository } from "../../db/repositories/user.repository.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; @@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: { export async function identityFromRequest( req: { headers: { cookie?: string | undefined } }, sessions: SessionStore, - users: UserRepository, + users: Pick, slideExpiry = true ): Promise { const session = sessions.get(sessionIdFromRequest(req), slideExpiry); @@ -74,10 +78,11 @@ 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 would get the - * cookie attached. Browsers send Sec-Fetch-Site on every request, so a write - * is only accepted from the app's own origin. Clients without the header - * (curl, scripts) carry no ambient browser cookie and are not a CSRF vector. + * 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) => { @@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler { 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(); @@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler { export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, - users: UserRepository, + users: Pick, openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] ): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts index 96814e1..97132af 100644 --- a/src/server/auth/password.ts +++ b/src/server/auth/password.ts @@ -4,6 +4,12 @@ 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 @@ -30,8 +36,13 @@ function scryptAsync( // 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(16); - const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); + 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, @@ -52,12 +63,33 @@ export async function verifyPassword( const n = Number(nRaw); const r = Number(rRaw); const p = Number(pRaw); - if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) { + 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"); - const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); - if (derivedKey.length !== expected.length) return false; - return timingSafeEqual(derivedKey, expected); + 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/composition/auth.ts b/src/server/composition/auth.ts index 1b5ce09..07b822b 100644 --- a/src/server/composition/auth.ts +++ b/src/server/composition/auth.ts @@ -3,9 +3,14 @@ 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, authConfig); +export const userService = new UserService( + userRepository, + { bootstrap: authConfig.bootstrap }, + { hash: hashPassword, verify: verifyPassword } +); 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/tests/authentication.test.ts b/tests/authentication.test.ts index e634716..e5c431d 100644 --- a/tests/authentication.test.ts +++ b/tests/authentication.test.ts @@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j 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; @@ -34,7 +38,14 @@ function createUserService() { cookieSecure: false, bootstrap: { username: "admin", password: "bootstrap-password-123" }, }; - return { repository, service: new UserService(repository, config) }; + return { + repository, + service: new UserService( + repository, + { bootstrap: config.bootstrap }, + { hash: hashPassword, verify: verifyPassword } + ), + }; } describe("password hashing", () => { @@ -52,6 +63,17 @@ describe("password hashing", () => { 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 + ); }); }); @@ -135,12 +157,20 @@ describe("cookie parsing", () => { }); describe("same-origin guard for writes", () => { - function run(method: string, fetchSite?: string) { + function run(method: string, fetchSite?: string, includeApplicationHeader = true) { let status: number | undefined; let passed = false; const req = { method, - headers: fetchSite ? { "sec-fetch-site": fetchSite } : {}, + 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) { @@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => { } it("lets reads and same-origin writes through", () => { - assert.equal(run("GET", "cross-site").passed, true); + assert.equal(run("GET", "cross-site", false).passed, true); assert.equal(run("POST", "same-origin").passed, true); - assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie"); + assert.equal(run("POST").passed, true, "scripts can send the explicit application header"); }); - it("rejects writes from other sites and other ports of the same host", () => { + 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 }); @@ -239,6 +270,30 @@ describe("user service", () => { 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({ diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts index 641a770..475d781 100644 --- a/tests/circuit-grid-model.test.ts +++ b/tests/circuit-grid-model.test.ts @@ -133,7 +133,9 @@ describe("circuit grid model", () => { assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); - assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField"); + assert.equal(getCellKind("reserveCircuit", "displayName"), "circuitField"); + assert.equal(getCellKind("reserveCircuit", "remark"), "circuitField"); + assert.equal(getCellKind("reserveCircuit", "powerPerUnit"), "deviceField"); assert.equal(getCellKind("placeholder", "displayName"), "deviceField"); assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index 1a16913..6551aee 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -179,9 +179,10 @@ describe("circuit grid projection", () => { ); }); - it("renders linked downstream boards as separate read-only rows", () => { + it("renders one linked downstream board as one compact editable circuit row", () => { const feeder = { ...reserveCircuit, + displayName: undefined, isReserve: false, circuitTotalPower: 4, circuitApparentPower: 5, @@ -202,19 +203,65 @@ describe("circuit grid projection", () => { assert.deepEqual(rows.map((row) => row.rowType), [ "section", "circuitSummary", - "downstreamBoardLoad", "placeholder", ]); - const loadRow = rows[2]; + const loadRow = rows[1]; assert.equal( loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, - "Unterverteilung UV-02" + "UV-02" + ); + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue, + "" ); assert.equal( loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, 4 ); - assert.equal(loadRow.cells.every((cell) => !cell.editable), true); + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "displayName")?.kind, + "circuitField" + ); + }); + + it("keeps separate read-only rows for several linked downstream boards", () => { + const feeder = { + ...reserveCircuit, + isReserve: false, + circuitTotalPower: 6, + circuitApparentPower: 7, + circuitCosPhi: 6 / 7, + downstreamBoardLoads: [ + { + distributionBoardId: "board-a", + name: "Unterverteilung A", + boardNumber: "UV-A", + activePowerKw: 4, + apparentPowerKva: 5, + cosPhi: 0.8, + }, + { + distributionBoardId: "board-b", + name: "Unterverteilung B", + boardNumber: "UV-B", + activePowerKw: 2, + apparentPowerKva: 2, + cosPhi: 1, + }, + ], + }; + const rows = buildVisibleGridRowsWithStructure( + [{ ...sections[0], circuits: [feeder] }], + { headerComponents: [], footerComponents: [] } + ); + assert.deepEqual(rows.map((row) => row.rowType), [ + "section", + "circuitSummary", + "downstreamBoardLoad", + "downstreamBoardLoad", + "placeholder", + ]); + assert.equal(rows[2].cells.every((cell) => !cell.editable), true); }); it("shows circuit protection once on each visible circuit row", () => { 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/distribution-board-feeder-circuit-project-command.repository.test.ts b/tests/distribution-board-feeder-circuit-project-command.repository.test.ts new file mode 100644 index 0000000..d4d2c26 --- /dev/null +++ b/tests/distribution-board-feeder-circuit-project-command.repository.test.ts @@ -0,0 +1,240 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + assertDistributionBoardFeederCircuitProjectCommand, + createDistributionBoardCreateFeederCircuitProjectCommand, +} from "../src/domain/models/distribution-board-feeder-circuit-project-command.model.js"; +import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js"; +import { + createDistributionBoardInsertProjectCommand, + createDistributionBoardStructureSnapshot, +} from "../src/domain/models/distribution-board-structure-project-command.model.js"; +import { buildCircuitInsertSnapshot } from "../src/frontend/utils/circuit-structure-command.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values({ id: "project-1", name: "Test project" }) + .run(); + return context; +} + +function setupBoards( + context: DatabaseContext, + repository: DistributionBoardStructureProjectCommandRepository +) { + const source = createDistributionBoardStructureSnapshot( + "project-1", + "NSHV" + ); + const downstream = createDistributionBoardStructureSnapshot( + "project-1", + "UV EG" + ); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createDistributionBoardInsertProjectCommand(source), + }); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createDistributionBoardInsertProjectCommand(downstream), + }); + const section = source.sections.find( + (candidate) => candidate.key === "three_phase" + )!; + const circuit = { + ...buildCircuitInsertSnapshot({ + id: "feeder-circuit", + circuitListId: source.circuitList.id, + category: "three_phase", + deviceRows: [], + values: { + sectionId: section.id, + equipmentIdentifier: `${section.prefix}1`, + displayName: undefined, + sortOrder: 10, + voltage: 400, + isReserve: true, + }, + }), + protectionDevice: null, + }; + const boardUpdate = createDistributionBoardUpdateProjectCommand( + downstream.distributionBoard.id, + { + feedingDistributionBoardId: source.distributionBoard.id, + feedingCircuitId: circuit.id, + } + ); + const command = + createDistributionBoardCreateFeederCircuitProjectCommand( + circuit, + boardUpdate + ); + return { source, downstream, circuit, command }; +} + +describe("distribution-board feeder-circuit project command", () => { + it("creates and links one feeder atomically and supports undo and redo", () => { + const context = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const { source, downstream, circuit, command } = setupBoards( + context, + repository + ); + + repository.executeFeederCircuit({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command, + }); + assert.ok( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, circuit.id)) + .get() + ); + assert.deepEqual( + context.db + .select({ + boardId: distributionBoards.feedingDistributionBoardId, + circuitId: distributionBoards.feedingCircuitId, + }) + .from(distributionBoards) + .where(eq(distributionBoards.id, downstream.distributionBoard.id)) + .get(), + { + boardId: source.distributionBoard.id, + circuitId: circuit.id, + } + ); + + const undo = history.getNextCommand("project-1", "undo"); + assert.ok(undo); + assertDistributionBoardFeederCircuitProjectCommand(undo.command); + repository.executeFeederCircuit({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: undo.changeSetId, + command: undo.command, + }); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, circuit.id)) + .get(), + undefined + ); + assert.deepEqual( + context.db + .select({ + boardId: distributionBoards.feedingDistributionBoardId, + circuitId: distributionBoards.feedingCircuitId, + }) + .from(distributionBoards) + .where(eq(distributionBoards.id, downstream.distributionBoard.id)) + .get(), + { boardId: null, circuitId: null } + ); + + const redo = history.getNextCommand("project-1", "redo"); + assert.ok(redo); + assertDistributionBoardFeederCircuitProjectCommand(redo.command); + repository.executeFeederCircuit({ + projectId: "project-1", + expectedRevision: 4, + source: "redo", + historyTargetChangeSetId: redo.changeSetId, + command: redo.command, + }); + assert.equal( + context.db + .select({ equipmentIdentifier: circuits.equipmentIdentifier }) + .from(circuits) + .where(eq(circuits.id, circuit.id)) + .get()?.equipmentIdentifier, + circuit.equipmentIdentifier + ); + } finally { + context.close(); + } + }); + + it("rolls back unlinking when the generated circuit changed before undo", () => { + const context = createTestDatabase(); + try { + const repository = + new DistributionBoardStructureProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const { downstream, circuit, command } = setupBoards(context, repository); + repository.executeFeederCircuit({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command, + }); + context.db + .update(circuits) + .set({ displayName: "Außerhalb der Historie geändert" }) + .where(eq(circuits.id, circuit.id)) + .run(); + + const undo = history.getNextCommand("project-1", "undo"); + assert.ok(undo); + assertDistributionBoardFeederCircuitProjectCommand(undo.command); + assert.throws( + () => + repository.executeFeederCircuit({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: undo.changeSetId, + command: undo.command, + }), + /changed before history removal/ + ); + const board = context.db + .select() + .from(distributionBoards) + .where(eq(distributionBoards.id, downstream.distributionBoard.id)) + .get(); + assert.equal(board?.feedingCircuitId, circuit.id); + assert.ok( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, circuit.id)) + .get() + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/distribution-board-feeder-circuit.test.ts b/tests/distribution-board-feeder-circuit.test.ts new file mode 100644 index 0000000..fb143da --- /dev/null +++ b/tests/distribution-board-feeder-circuit.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildFeederCircuitSnapshot, + getDefaultFeederCircuitSection, + getNextFeederCircuitIdentifier, +} from "../src/frontend/utils/distribution-board-feeder-circuit.js"; +import type { CircuitTreeResponseDto } from "../src/frontend/types.js"; +import { createFeederCircuitAndUpdateDistributionBoard } from "../src/frontend/utils/api.js"; + +function tree(): CircuitTreeResponseDto { + return { + circuitListId: "list-main", + distributionBoardId: "board-main", + distributionBoardName: "NSHV", + currentRevision: 2, + singlePhaseVoltageV: 230, + threePhaseVoltageV: 400, + distributionBoardSimultaneityFactor: 1, + distributionBoardTotalPower: 0, + distributionBoardTotalPowerWithSimultaneityFactor: 0, + headerComponents: [], + footerComponents: [], + sections: [ + { + id: "single", + key: "single_phase", + category: "single_phase", + groupNumber: 1, + displayName: "1-phasig 1", + prefix: "-2F1.", + sortOrder: 10, + sectionTotalPower: 0, + components: [], + circuits: [], + }, + { + id: "three", + key: "three_phase", + category: "three_phase", + groupNumber: 1, + displayName: "3-phasig 1", + prefix: "-3F1.", + sortOrder: 20, + sectionTotalPower: 0, + components: [], + circuits: [ + { + id: "existing-2", + circuitListId: "list-main", + sectionId: "three", + equipmentIdentifier: "-3F1.2", + sortOrder: 10, + isReserve: true, + circuitTotalPower: 0, + circuitApparentPower: 0, + circuitCosPhi: 1, + deviceRows: [], + downstreamBoardLoads: [], + }, + { + id: "existing-5", + circuitListId: "list-main", + sectionId: "three", + equipmentIdentifier: "-3F1.5", + sortOrder: 30, + isReserve: true, + circuitTotalPower: 0, + circuitApparentPower: 0, + circuitCosPhi: 1, + deviceRows: [], + downstreamBoardLoads: [], + }, + ], + }, + ], + }; +} + +describe("distribution-board feeder circuit planning", () => { + it("prefers a three-phase section and does not fill BMK gaps", () => { + const input = tree(); + const section = getDefaultFeederCircuitSection(input)!; + assert.equal(section.id, "three"); + assert.equal(getNextFeederCircuitIdentifier(section), "-3F1.6"); + }); + + it("builds an empty circuit with stable id, optional name and defaults", () => { + const input = tree(); + const snapshot = buildFeederCircuitSnapshot({ + id: "new-feeder", + tree: input, + sectionId: "three", + displayName: " ", + }); + assert.equal(snapshot.id, "new-feeder"); + assert.equal(snapshot.equipmentIdentifier, "-3F1.6"); + assert.equal(snapshot.displayName, null); + assert.equal(snapshot.sortOrder, 40); + assert.equal(snapshot.voltage, 400); + assert.equal(snapshot.isReserve, true); + assert.deepEqual(snapshot.deviceRows, []); + assert.equal(snapshot.protectionDevice, null); + }); + + it("sends circuit creation and board linking as one project command", async () => { + const input = tree(); + const circuit = buildFeederCircuitSnapshot({ + id: "new-feeder", + tree: input, + sectionId: "three", + displayName: "", + }); + const requests: Array<{ url: string; method: string; body: any }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (request, init) => { + requests.push({ + url: String(request), + method: init?.method ?? "GET", + body: JSON.parse(String(init?.body)), + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await createFeederCircuitAndUpdateDistributionBoard( + "project-1", + "board-child", + { + floorId: null, + supplyType: "AV", + simultaneityFactor: 0.8, + feedingDistributionBoardId: "board-main", + feedingCircuitId: circuit.id, + }, + circuit, + 7 + ); + } finally { + globalThis.fetch = originalFetch; + } + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "/api/projects/project-1/commands"); + assert.equal(requests[0].method, "POST"); + assert.equal(requests[0].body.expectedRevision, 7); + assert.equal( + requests[0].body.command.type, + "distribution-board.create-feeder-circuit" + ); + assert.equal(requests[0].body.command.payload.circuit.id, "new-feeder"); + assert.deepEqual( + requests[0].body.command.payload.boardUpdate.payload.changes + .filter((change: { field: string }) => + change.field.startsWith("feeding") + ) + .map((change: { field: string; value: unknown }) => [ + change.field, + change.value, + ]), + [ + ["feedingDistributionBoardId", "board-main"], + ["feedingCircuitId", "new-feeder"], + ] + ); + }); +}); 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 + ); + } + }); +});