Compare commits

...

6 commits

46 changed files with 1632 additions and 185 deletions

7
.env.example Normal file
View file

@ -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

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
node_modules/ node_modules/
dist/ dist/
.next/ .next/
.env
.env.*
!.env.example
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. # Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
/drizzle.config.js /drizzle.config.js
data/*.db data/*.db

View file

@ -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 `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
signed-in user (`src/server/index.ts`); `/api/users/*` additionally signed-in user (`src/server/index.ts`); `/api/users/*` additionally
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` 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, The supported runtime model is Circuit-First. The former Consumer UI, API,
tables and upgrade tooling are removed; do not reintroduce them. 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 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 on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected. 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 floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.

View file

@ -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 Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
Docker-Server-Stack mit gebautem Anwendungscode und ein separater Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment 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 und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import
@ -60,6 +62,8 @@ Voraussetzungen:
```powershell ```powershell
git clone <repository-url> git clone <repository-url>
Set-Location leistungsbilanz-ts Set-Location leistungsbilanz-ts
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
docker compose up --build --detach docker compose up --build --detach
``` ```
@ -68,9 +72,15 @@ Danach:
- Frontend: <http://localhost:3001> - Frontend: <http://localhost:3001>
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite) - API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator,
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für
Zugriff läuft über den Rewrite im Web-Dienst. 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 Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
automatisch aus. Die SQLite-Datei liegt auf dem Host unter automatisch aus. Die SQLite-Datei liegt auf dem Host unter
@ -116,6 +126,8 @@ Voraussetzungen:
npm ci npm ci
npm run db:migrate npm run db:migrate
npm run db:verify:circuit-schema npm run db:verify:circuit-schema
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
npm run dev:api npm run dev:api
``` ```
@ -162,6 +174,7 @@ vor der Baseline werden nicht unterstützt.
- [Gebäudestruktur und Raumflächen](docs/building-structure.md) - [Gebäudestruktur und Raumflächen](docs/building-structure.md)
- [Grobe Leistungsbilanz](docs/estimate-balance.md) - [Grobe Leistungsbilanz](docs/estimate-balance.md)
- [Leitungsberechnung](docs/cable-sizing-module.md) - [Leitungsberechnung](docs/cable-sizing-module.md)
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md)

View file

@ -38,6 +38,8 @@ services:
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}"
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
# Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein.
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"

View file

@ -47,7 +47,7 @@ services:
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte # 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_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"

View file

@ -7,10 +7,9 @@ Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich - **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer**
(Benutzerverwaltung, eigenes Konto)
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, - **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 Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. 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` - **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
**Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für
Administratoren.
## Schutzmechanismen ## 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 (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 | | 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 | | 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 | 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 | 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 ü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 | | 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) | | 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 | | 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()` | | 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 | | 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 | | 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 gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
in `compose.yaml` selbst. in `compose.yaml` selbst.
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
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) ## Bewusst nicht umgesetzt (Stand jetzt)
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen - Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen

View file

@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
submitted info fields, are restored together by persistent Undo/Redo submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` remains free text for external feeds such as HAK, metering, - `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes 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` - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs - 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 footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included. Circuit totals contain the demand of linked downstream 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. `feedingReference` resolves the current board/circuit labels by id.
Response sketch: Response sketch:

View file

@ -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 weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
Datenbank liegt über einen Host-Mount außerhalb des Containers. 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 Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls 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, - `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe - `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/server/index.ts` und `src/server/routes/` – API-Komposition
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln - `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten - `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 Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt. 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 Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten

View file

@ -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 ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner. Entwicklungsrechner.
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
deshalb nicht öffentlich erreichbar gemacht werden. übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
## Topologie ## 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` | | 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` | | 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 Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie (`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem 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 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. 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`. JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`. `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=<eigenes Startpasswort mit mindestens 12 Zeichen>
```
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 Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im (`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: Vor einer produktiven Installation werden mindestens benötigt:
- TLS-Termination und Reverse Proxy - 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 - definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb - persistenter, gesicherter Datenbankbetrieb
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout - kontrollierter einmaliger Migrationsschritt vor dem API-Rollout

View file

@ -38,8 +38,9 @@ automatisch bei späteren Änderungen in einem anderen Tab.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und (reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit
schreibgeschützten Unterverteilungszeilen), Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten
Unterverteilungszeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten. Drucken auf Folgeseiten.

View file

@ -4,26 +4,27 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuth } from "../../frontend/components/AuthProvider"; import { useAuth } from "../../frontend/components/AuthProvider";
import {
const CONFIG_TABS = [ getConfigRedirect,
{ href: "/config/benutzer", label: "Benutzer" }, getConfigTabs,
{ href: "/config/konto", label: "Konto" }, } from "../../frontend/utils/config-access";
] as const;
export default function ConfigLayout({ children }: { children: React.ReactNode }) { export default function ConfigLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { status, isAdmin } = useAuth(); const { status, isAdmin } = useAuth();
const tabs = getConfigTabs(isAdmin);
const redirectTarget = getConfigRedirect(pathname, isAdmin);
useEffect(() => { useEffect(() => {
// LoginGate already guarantees a signed-in user with a set password by if (status !== "signed-in") return;
// the time this layout renders; only the role still needs checking. if (redirectTarget) router.replace(redirectTarget);
if (status === "signed-in" && !isAdmin) { }, [status, redirectTarget, router]);
router.replace("/");
}
}, [status, isAdmin, router]);
if (status !== "signed-in" || !isAdmin) { if (
status !== "signed-in" ||
redirectTarget !== null
) {
return null; return null;
} }
@ -34,7 +35,7 @@ export default function ConfigLayout({ children }: { children: React.ReactNode }
</div> </div>
<nav className="config-tabs"> <nav className="config-tabs">
{CONFIG_TABS.map((tab) => ( {tabs.map((tab) => (
<Link <Link
key={tab.href} key={tab.href}
href={tab.href} href={tab.href}

View file

@ -1,5 +1,3 @@
import { redirect } from "next/navigation";
export default function ConfigPage() { export default function ConfigPage() {
redirect("/config/benutzer"); return null;
} }

View file

@ -1551,6 +1551,10 @@ a.kpi:hover {
background: var(--panel-bg-subtle); background: var(--panel-bg-subtle);
} }
.tree-grid .feeder-circuit-row td:nth-child(2) {
font-weight: 600;
}
.tree-grid .device-row td:first-child { .tree-grid .device-row td:first-child {
color: var(--text-faint); color: var(--text-faint);
} }

View file

@ -47,6 +47,7 @@ import {
import { import {
copyGlobalDeviceToProject, copyGlobalDeviceToProject,
copyDistributionBoard, copyDistributionBoard,
createFeederCircuitAndUpdateDistributionBoard,
copyProjectDeviceToGlobal, copyProjectDeviceToGlobal,
createDistributionBoard, createDistributionBoard,
createProjectDevice, createProjectDevice,
@ -71,6 +72,12 @@ import {
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
} from "../../../frontend/utils/api"; } from "../../../frontend/utils/api";
import { createClientId } from "../../../frontend/utils/client-id";
import {
buildFeederCircuitSnapshot,
getDefaultFeederCircuitSection,
getNextFeederCircuitIdentifier,
} from "../../../frontend/utils/distribution-board-feeder-circuit";
import type { import type {
BuildingDto, BuildingDto,
CircuitListDto, CircuitListDto,
@ -146,6 +153,7 @@ const boardFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:distribution-boards"; "leistungsbilanz:overview-filter-collapsed:distribution-boards";
const projectDeviceFilterCollapsedStorageKey = const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices"; "leistungsbilanz:overview-filter-collapsed:project-devices";
const createFeederCircuitSelectValue = "__create_feeder_circuit__";
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>(); const params = useParams<{ projectId: string }>();
@ -175,6 +183,12 @@ export default function ProjectDetailPage() {
const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState(""); const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState(""); const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [isCreatingFeederCircuit, setIsCreatingFeederCircuit] =
useState(false);
const [editingFeederCircuitSectionId, setEditingFeederCircuitSectionId] =
useState("");
const [editingFeederCircuitDisplayName, setEditingFeederCircuitDisplayName] =
useState("");
const [editingBoardInfo, setEditingBoardInfo] = const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>( useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({ toDistributionBoardInfoDraft({
@ -350,6 +364,21 @@ export default function ProjectDetailPage() {
const selectedFeedingCircuit = selectedFeedingCircuits.find( const selectedFeedingCircuit = selectedFeedingCircuits.find(
({ circuit }) => circuit.id === editingFeedingCircuitId ({ circuit }) => circuit.id === editingFeedingCircuitId
); );
const feederCircuitSections =
selectedFeedingTree?.sections.filter(
(section) => section.category !== undefined
) ?? [];
const selectedNewFeederCircuitSection = feederCircuitSections.find(
(section) => section.id === editingFeederCircuitSectionId
);
const newFeederCircuitIdentifier = selectedNewFeederCircuitSection
? getNextFeederCircuitIdentifier(selectedNewFeederCircuitSection)
: null;
const selectedFeedingCircuitCategory =
selectedFeedingCircuit?.section.category ??
(isCreatingFeederCircuit
? selectedNewFeederCircuitSection?.category
: undefined);
const selectedFeedingBoard = boards.find( const selectedFeedingBoard = boards.find(
(board) => board.id === editingFeedingBoardId (board) => board.id === editingFeedingBoardId
); );
@ -556,6 +585,9 @@ export default function ProjectDetailPage() {
setEditingBoardInfo(toDistributionBoardInfoDraft(board)); setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? ""); setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? ""); setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId("");
setEditingFeederCircuitDisplayName("");
} }
function openBoardCreator() { function openBoardCreator() {
@ -584,28 +616,56 @@ export default function ProjectDetailPage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const result = await updateDistributionBoard( const boardInput = {
projectId,
editingBoard.id,
{
floorId: editingBoardFloorId || null, floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType, supplyType: editingBoardSupplyType,
simultaneityFactor, simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null, feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null, feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo), ...fromDistributionBoardInfoDraft(editingBoardInfo),
};
let result;
if (isCreatingFeederCircuit) {
if (!editingFeedingBoardId || !selectedFeedingTree) {
throw new Error("Bitte eine speisende Verteilung wählen.");
}
if (!selectedNewFeederCircuitSection) {
throw new Error(
"Bitte eine Stromkreisgruppe für den neuen Abgang wählen."
);
}
const circuit = buildFeederCircuitSnapshot({
id: createClientId(),
tree: selectedFeedingTree,
sectionId: selectedNewFeederCircuitSection.id,
displayName: editingFeederCircuitDisplayName,
});
result = await createFeederCircuitAndUpdateDistributionBoard(
projectId,
editingBoard.id,
{
...boardInput,
feedingDistributionBoardId: editingFeedingBoardId,
feedingCircuitId: circuit.id,
}, },
circuit,
project.currentRevision project.currentRevision
); );
setBoards((current) => } else {
current.map((board) => result = await updateDistributionBoard(
board.id === result.distributionBoard.id projectId,
? result.distributionBoard editingBoard.id,
: board boardInput,
) project.currentRevision
); );
}
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); const [updatedBoards, updatedTrees] = await Promise.all([
listDistributionBoards(projectId),
getProjectCircuitTrees(projectId),
]);
setBoards(updatedBoards);
setCircuitTrees(updatedTrees.trees);
setEditingBoard(null); setEditingBoard(null);
} catch (err) { } catch (err) {
setError( setError(
@ -1567,8 +1627,16 @@ export default function ProjectDetailPage() {
className="form-select" className="form-select"
id="feeding-distribution-board" id="feeding-distribution-board"
onChange={(event) => { onChange={(event) => {
setEditingFeedingBoardId(event.target.value); const boardId = event.target.value;
setEditingFeedingBoardId(boardId);
setEditingFeedingCircuitId(""); setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId(
getDefaultFeederCircuitSection(
circuitTreeByBoardId.get(boardId)
)?.id ?? ""
);
setEditingFeederCircuitDisplayName("");
}} }}
value={editingFeedingBoardId} value={editingFeedingBoardId}
> >
@ -1588,10 +1656,31 @@ export default function ProjectDetailPage() {
className="form-select" className="form-select"
disabled={!editingFeedingBoardId} disabled={!editingFeedingBoardId}
id="feeding-circuit" id="feeding-circuit"
onChange={(event) => setEditingFeedingCircuitId(event.target.value)} onChange={(event) => {
value={editingFeedingCircuitId} if (event.target.value === createFeederCircuitSelectValue) {
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(true);
setEditingFeederCircuitSectionId(
(current) =>
current ||
getDefaultFeederCircuitSection(selectedFeedingTree)?.id ||
""
);
return;
}
setIsCreatingFeederCircuit(false);
setEditingFeedingCircuitId(event.target.value);
}}
value={
isCreatingFeederCircuit
? createFeederCircuitSelectValue
: editingFeedingCircuitId
}
> >
<option value="">Nur Verteilung bekannt</option> <option value="">Nur Verteilung bekannt</option>
<option value={createFeederCircuitSelectValue}>
+ Neuen Einspeiseabgang anlegen …
</option>
{selectedFeedingCircuits.map(({ circuit }) => ( {selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}> <option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier} {circuit.equipmentIdentifier}
@ -1600,7 +1689,62 @@ export default function ProjectDetailPage() {
))} ))}
</select> </select>
</div> </div>
{editingFeedingBoardId && !editingFeedingCircuitId ? ( {isCreatingFeederCircuit ? (
<div className="col-12">
<div className="border rounded p-3">
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-section">
Stromkreisgruppe
</label>
<select
className="form-select"
id="new-feeder-circuit-section"
onChange={(event) =>
setEditingFeederCircuitSectionId(event.target.value)
}
required
value={editingFeederCircuitSectionId}
>
{feederCircuitSections.map((section) => (
<option key={section.id} value={section.id}>
{section.displayName}
</option>
))}
</select>
<div className="form-text">
Vorgesehenes BMK: {newFeederCircuitIdentifier ?? "–"}
</div>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-name">
Abgangsbezeichnung (optional)
</label>
<input
className="form-control"
id="new-feeder-circuit-name"
onChange={(event) =>
setEditingFeederCircuitDisplayName(event.target.value)
}
placeholder="z. B. Steigetrasse Ost"
value={editingFeederCircuitDisplayName}
/>
<div className="form-text">
Ohne Zusatzbezeichnung wird der Name der gespeisten
Verteilung angezeigt.
</div>
</div>
</div>
<div className="form-text mt-2">
Der Abgang wird erst beim Speichern angelegt und gemeinsam
mit dieser Verteilung verknüpft. Es entsteht keine Gerätezeile.
</div>
</div>
</div>
) : null}
{editingFeedingBoardId &&
!editingFeedingCircuitId &&
!isCreatingFeederCircuit ? (
<div className="col-12"> <div className="col-12">
<div className="alert alert-warning mb-0 py-2"> <div className="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die Ohne speisenden Abgang wird die Hierarchie angezeigt, die
@ -1618,8 +1762,8 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
) : null} ) : null}
{selectedFeedingCircuit && {selectedFeedingCircuitCategory !== undefined &&
selectedFeedingCircuit.section.category !== "three_phase" ? ( selectedFeedingCircuitCategory !== "three_phase" ? (
<div className="col-12"> <div className="col-12">
<div className="alert alert-warning mb-0 py-2"> <div className="alert alert-warning mb-0 py-2">
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese

View file

@ -40,7 +40,7 @@ export class CircuitStructureProjectCommandRepository
this.database, this.database,
input, input,
(tx) => (tx) =>
this.applyCommand( this.applyInTransaction(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
@ -49,7 +49,11 @@ export class CircuitStructureProjectCommandRepository
); );
} }
private applyCommand( /**
* Applies the circuit write inside an already-owned project transaction.
* Composite project commands use this without creating a second revision.
*/
applyInTransaction(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
source: ExecuteCircuitStructureCommandInput["source"], source: ExecuteCircuitStructureCommandInput["source"],

View file

@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm"; import { and, asc, eq, inArray } from "drizzle-orm";
import { isDeepStrictEqual } from "node:util";
import { import {
assertDistributionBoardDeleteProjectCommand, assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand, assertDistributionBoardInsertProjectCommand,
@ -17,6 +18,20 @@ import {
type DistributionBoardUpdateProjectCommand, type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues, type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js"; } from "../../domain/models/distribution-board-project-command.model.js";
import {
assertDistributionBoardFeederCircuitProjectCommand,
createDistributionBoardCreateFeederCircuitProjectCommand,
createDistributionBoardRemoveFeederCircuitProjectCommand,
distributionBoardCreateFeederCircuitCommandType,
distributionBoardRemoveFeederCircuitCommandType,
type DistributionBoardFeederCircuitProjectCommand,
} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
import {
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type { import type {
DistributionBoardStructureProjectCommandStore, DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput, ExecuteDistributionBoardStructureCommandInput,
@ -31,6 +46,7 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore implements DistributionBoardStructureProjectCommandStore
@ -62,6 +78,30 @@ export class DistributionBoardStructureProjectCommandRepository
); );
} }
executeFeederCircuit(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
) {
assertDistributionBoardFeederCircuitProjectCommand(input.command);
if (
input.source === "user" &&
input.command.type === distributionBoardRemoveFeederCircuitCommandType
) {
throw new Error(
"Generated feeder circuits may only be removed through project history."
);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyFeederCircuitCommand(tx, input)
);
}
private applyCommand( private applyCommand(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
@ -367,6 +407,64 @@ export class DistributionBoardStructureProjectCommandRepository
return inverse; return inverse;
} }
private applyFeederCircuitCommand(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
): DistributionBoardFeederCircuitProjectCommand {
const circuitRepository =
new CircuitStructureProjectCommandRepository(database);
const { circuit, boardUpdate } = input.command.payload;
if (
input.command.type === distributionBoardCreateFeederCircuitCommandType
) {
const circuitInverse = circuitRepository.applyInTransaction(
database,
input.projectId,
input.source,
createCircuitInsertProjectCommand(circuit)
);
if (circuitInverse.type !== circuitDeleteCommandType) {
throw new Error("Created feeder circuit returned an invalid inverse.");
}
const boardInverse = this.update(database, {
...input,
command: boardUpdate,
});
return createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit,
boardInverse
);
}
const boardInverse = this.update(database, {
...input,
command: boardUpdate,
});
const circuitInverse = circuitRepository.applyInTransaction(
database,
input.projectId,
input.source,
createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId)
);
if (
circuitInverse.type !== circuitInsertCommandType ||
!isDeepStrictEqual(circuitInverse.payload.circuit, circuit)
) {
throw new Error(
"Generated feeder circuit changed before history removal."
);
}
return createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
boardInverse
);
}
private assertFeedingReference( private assertFeedingReference(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,

View file

@ -1,17 +1,24 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { users } from "../schema/users.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"; import type { UserRole } from "../../shared/constants/user-roles.js";
export interface CreateUserRow { function isUniqueConstraintError(error: unknown): boolean {
username: string; return (
name: string; error instanceof Error &&
role: UserRole; "code" in error &&
passwordHash: string; (error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
);
} }
export class UserRepository { export class UserRepository implements UserStore {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
async list() { async list() {
@ -41,9 +48,10 @@ export class UserRepository {
return row ?? null; return row ?? null;
} }
async create(input: CreateUserRow) { async create(input: CreateUserAccount) {
const db = this.database; const db = this.database;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
try {
await db.insert(users).values({ await db.insert(users).values({
id, id,
username: input.username.trim().toLowerCase(), username: input.username.trim().toLowerCase(),
@ -53,6 +61,10 @@ export class UserRepository {
active: true, active: true,
mustChangePassword: true, mustChangePassword: true,
}); });
} catch (error) {
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
throw error;
}
return this.findById(id); return this.findById(id);
} }
@ -60,16 +72,34 @@ export class UserRepository {
userId: string, userId: string,
patch: { role?: UserRole; active?: boolean } patch: { role?: UserRole; active?: boolean }
) { ) {
const db = this.database; return this.database.transaction((tx) => {
await db const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all();
.update(users) 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({ .set({
...(patch.role !== undefined ? { role: patch.role } : {}), ...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.active !== undefined ? { active: patch.active } : {}), ...(patch.active !== undefined ? { active: patch.active } : {}),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId))
return this.findById(userId); .run();
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
});
} }
async setPassword( async setPassword(

View file

@ -1,5 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { UserRole } from "../../shared/constants/user-roles.js";
export const users = sqliteTable( export const users = sqliteTable(
"users", "users",
@ -8,7 +9,7 @@ export const users = sqliteTable(
username: text("username").notNull(), username: text("username").notNull(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
role: text("role").notNull().default("user"), role: text("role").$type<UserRole>().notNull().default("user"),
active: integer("active", { mode: "boolean" }).notNull().default(true), active: integer("active", { mode: "boolean" }).notNull().default(true),
mustChangePassword: integer("must_change_password", { mode: "boolean" }) mustChangePassword: integer("must_change_password", { mode: "boolean" })
.notNull() .notNull()

View file

@ -0,0 +1,132 @@
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
circuitStructureCommandSchemaVersion,
type CircuitSnapshot,
} from "./circuit-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateProjectCommand,
} from "./distribution-board-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardCreateFeederCircuitCommandType =
"distribution-board.create-feeder-circuit" as const;
export const distributionBoardRemoveFeederCircuitCommandType =
"distribution-board.remove-feeder-circuit" as const;
export const distributionBoardFeederCircuitCommandSchemaVersion = 1 as const;
export interface DistributionBoardFeederCircuitCommandPayload {
circuit: CircuitSnapshot;
boardUpdate: DistributionBoardUpdateProjectCommand;
}
export interface DistributionBoardCreateFeederCircuitProjectCommand
extends SerializedProjectCommand<DistributionBoardFeederCircuitCommandPayload> {
schemaVersion: typeof distributionBoardFeederCircuitCommandSchemaVersion;
type: typeof distributionBoardCreateFeederCircuitCommandType;
}
export interface DistributionBoardRemoveFeederCircuitProjectCommand
extends SerializedProjectCommand<DistributionBoardFeederCircuitCommandPayload> {
schemaVersion: typeof distributionBoardFeederCircuitCommandSchemaVersion;
type: typeof distributionBoardRemoveFeederCircuitCommandType;
}
export type DistributionBoardFeederCircuitProjectCommand =
| DistributionBoardCreateFeederCircuitProjectCommand
| DistributionBoardRemoveFeederCircuitProjectCommand;
export function createDistributionBoardCreateFeederCircuitProjectCommand(
circuit: CircuitSnapshot,
boardUpdate: DistributionBoardUpdateProjectCommand
): DistributionBoardCreateFeederCircuitProjectCommand {
const command: DistributionBoardCreateFeederCircuitProjectCommand = {
schemaVersion: distributionBoardFeederCircuitCommandSchemaVersion,
type: distributionBoardCreateFeederCircuitCommandType,
payload: { circuit, boardUpdate },
};
assertDistributionBoardFeederCircuitProjectCommand(command);
return command;
}
export function createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit: CircuitSnapshot,
boardUpdate: DistributionBoardUpdateProjectCommand
): DistributionBoardRemoveFeederCircuitProjectCommand {
const command: DistributionBoardRemoveFeederCircuitProjectCommand = {
schemaVersion: distributionBoardFeederCircuitCommandSchemaVersion,
type: distributionBoardRemoveFeederCircuitCommandType,
payload: { circuit, boardUpdate },
};
assertDistributionBoardFeederCircuitProjectCommand(command);
return command;
}
export function assertDistributionBoardFeederCircuitProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardFeederCircuitProjectCommand {
if (
command.schemaVersion !==
distributionBoardFeederCircuitCommandSchemaVersion ||
(command.type !== distributionBoardCreateFeederCircuitCommandType &&
command.type !== distributionBoardRemoveFeederCircuitCommandType) ||
!isPlainObject(command.payload)
) {
throw new Error("Unsupported distribution-board feeder-circuit command.");
}
const { circuit, boardUpdate } = command.payload;
if (!isPlainObject(boardUpdate)) {
throw new Error("Feeder-circuit command requires a board update.");
}
assertCircuitInsertProjectCommand({
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit },
});
const boardUpdateCommand =
boardUpdate as unknown as SerializedProjectCommand<unknown>;
assertDistributionBoardUpdateProjectCommand(boardUpdateCommand);
const typedCircuit = circuit as CircuitSnapshot;
const typedBoardUpdate: DistributionBoardUpdateProjectCommand =
boardUpdateCommand;
if (typedCircuit.deviceRows.length !== 0 || !typedCircuit.isReserve) {
throw new Error(
"A generated feeder circuit must be empty before the board load is linked."
);
}
const feedingBoard = getChangeValue(
typedBoardUpdate,
"feedingDistributionBoardId"
);
const feedingCircuit = getChangeValue(
typedBoardUpdate,
"feedingCircuitId"
);
if (feedingBoard === undefined || feedingCircuit === undefined) {
throw new Error(
"Feeder-circuit commands must update both logical feeding references."
);
}
if (
command.type === distributionBoardCreateFeederCircuitCommandType &&
(typeof feedingBoard !== "string" || feedingCircuit !== typedCircuit.id)
) {
throw new Error(
"The created feeder circuit must be linked by the distribution-board update."
);
}
}
function getChangeValue(
command: DistributionBoardUpdateProjectCommand,
field: "feedingDistributionBoardId" | "feedingCircuitId"
) {
return command.payload.changes.find((change) => change.field === field)?.value;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,5 +1,6 @@
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js"; import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js"; import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
import type { DistributionBoardFeederCircuitProjectCommand } from "../models/distribution-board-feeder-circuit-project-command.model.js";
import type { import type {
AppendedProjectRevision, AppendedProjectRevision,
ProjectRevisionSource, ProjectRevisionSource,
@ -35,4 +36,15 @@ export interface DistributionBoardStructureProjectCommandStore {
revision: AppendedProjectRevision; revision: AppendedProjectRevision;
inverse: DistributionBoardUpdateProjectCommand; inverse: DistributionBoardUpdateProjectCommand;
}; };
executeFeederCircuit(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
): {
revision: AppendedProjectRevision;
inverse: DistributionBoardFeederCircuitProjectCommand;
};
} }

View file

@ -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<UserAccount[]>;
count(): Promise<number>;
findById(userId: string): Promise<UserAccount | null>;
findByUsername(username: string): Promise<UserAccount | null>;
create(input: CreateUserAccount): Promise<UserAccount | null>;
updateRoleAndActive(
userId: string,
patch: { role?: UserRole; active?: boolean }
): Promise<UserAccount | null>;
setPassword(
userId: string,
passwordHash: string,
mustChangePassword: boolean
): Promise<UserAccount | null>;
}

View file

@ -45,6 +45,11 @@ import {
assertDistributionBoardUpdateProjectCommand, assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType, distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js"; } from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardFeederCircuitProjectCommand,
distributionBoardCreateFeederCircuitCommandType,
distributionBoardRemoveFeederCircuitCommandType,
} from "../models/distribution-board-feeder-circuit-project-command.model.js";
import { import {
assertDistributionBoardComponentDeleteProjectCommand, assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand, assertDistributionBoardComponentInsertProjectCommand,
@ -418,6 +423,14 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case distributionBoardCreateFeederCircuitCommandType:
case distributionBoardRemoveFeederCircuitCommandType: {
assertDistributionBoardFeederCircuitProjectCommand(input.command);
return this.distributionBoardStructureStore.executeFeederCircuit({
...input,
command: input.command,
}).revision;
}
case distributionBoardComponentInsertCommandType: { case distributionBoardComponentInsertCommandType: {
assertDistributionBoardComponentInsertProjectCommand( assertDistributionBoardComponentInsertProjectCommand(
input.command input.command

View file

@ -1,8 +1,10 @@
import type { UserRepository } from "../../db/repositories/user.repository.js";
import { hashPassword, verifyPassword } from "../../server/auth/password.js";
import type { UserRole } from "../../shared/constants/user-roles.js"; import type { UserRole } from "../../shared/constants/user-roles.js";
import type { AuthConfig } from "../../server/auth/auth-config.js";
import { createLogger } from "../../shared/logging/logger.js"; import { createLogger } from "../../shared/logging/logger.js";
import {
LastActiveAdminStoreError,
UsernameAlreadyExistsStoreError,
type UserStore,
} from "../ports/user.store.js";
const logger = createLogger("user-service"); const logger = createLogger("user-service");
@ -36,20 +38,22 @@ export class LastAdminError extends Error {
} }
} }
function isUniqueConstraintError(error: unknown): boolean { export interface PasswordHasher {
return ( hash(password: string): Promise<string>;
error instanceof Error && verify(password: string, storedHash: string): Promise<boolean>;
"code" in error && }
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
); export interface UserServiceOptions {
bootstrap: { username: string; password: string } | null;
} }
export class UserService { export class UserService {
private dummyHash: Promise<string> | null = null; private dummyHash: Promise<string> | null = null;
constructor( constructor(
private readonly repository: UserRepository, private readonly repository: UserStore,
private readonly authConfig: AuthConfig private readonly options: UserServiceOptions,
private readonly passwords: PasswordHasher
) {} ) {}
/** /**
@ -65,21 +69,21 @@ export class UserService {
await this.getDummyHash(); await this.getDummyHash();
const existingCount = await this.repository.count(); const existingCount = await this.repository.count();
if (existingCount > 0) return; if (existingCount > 0) return;
if (!this.authConfig.bootstrap) { if (!this.options.bootstrap) {
logger.warn( logger.warn(
"no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in" "no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in"
); );
return; return;
} }
const passwordHash = await hashPassword(this.authConfig.bootstrap.password); const passwordHash = await this.passwords.hash(this.options.bootstrap.password);
await this.repository.create({ await this.repository.create({
username: this.authConfig.bootstrap.username, username: this.options.bootstrap.username,
name: "Administrator", name: "Administrator",
role: "admin", role: "admin",
passwordHash, passwordHash,
}); });
logger.info("bootstrap admin created", { logger.info("bootstrap admin created", {
username: this.authConfig.bootstrap.username, username: this.options.bootstrap.username,
}); });
} }
@ -89,7 +93,7 @@ export class UserService {
// scrypt run either way, so response time doesn't reveal which usernames // scrypt run either way, so response time doesn't reveal which usernames
// exist. // exist.
const passwordHash = user?.passwordHash ?? (await this.getDummyHash()); const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
const passwordMatches = await verifyPassword(password, passwordHash); const passwordMatches = await this.passwords.verify(password, passwordHash);
if (!user || !user.active || !passwordMatches) { if (!user || !user.active || !passwordMatches) {
throw new InvalidCredentialsError(); throw new InvalidCredentialsError();
} }
@ -104,7 +108,7 @@ export class UserService {
}) { }) {
const existing = await this.repository.findByUsername(input.username); const existing = await this.repository.findByUsername(input.username);
if (existing) throw new UsernameTakenError(); if (existing) throw new UsernameTakenError();
const passwordHash = await hashPassword(input.initialPassword); const passwordHash = await this.passwords.hash(input.initialPassword);
try { try {
return await this.repository.create({ return await this.repository.create({
username: input.username, username: input.username,
@ -114,29 +118,23 @@ export class UserService {
}); });
} catch (error) { } catch (error) {
// Two admins creating the same name at once: the unique index decides. // Two admins creating the same name at once: the unique index decides.
if (isUniqueConstraintError(error)) throw new UsernameTakenError(); if (error instanceof UsernameAlreadyExistsStoreError) throw new UsernameTakenError();
throw error; throw error;
} }
} }
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
const removesAdmin = patch.role === "user" || patch.active === false; try {
if (removesAdmin) { return await this.repository.updateRoleAndActive(userId, patch);
const target = await this.repository.findById(userId); } catch (error) {
const targetIsActiveAdmin = target?.role === "admin" && target.active; if (error instanceof LastActiveAdminStoreError) throw new LastAdminError();
if (targetIsActiveAdmin) { throw error;
const activeAdmins = (await this.repository.list()).filter(
(user) => user.role === "admin" && user.active
);
if (activeAdmins.length <= 1) throw new LastAdminError();
} }
} }
return this.repository.updateRoleAndActive(userId, patch);
}
/** Admin-triggered reset with a new start password; forces a change on next login. */ /** Admin-triggered reset with a new start password; forces a change on next login. */
async resetPassword(userId: string, newPassword: string) { async resetPassword(userId: string, newPassword: string) {
const passwordHash = await hashPassword(newPassword); const passwordHash = await this.passwords.hash(newPassword);
return this.repository.setPassword(userId, passwordHash, true); return this.repository.setPassword(userId, passwordHash, true);
} }
@ -144,15 +142,15 @@ export class UserService {
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) { async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.repository.findById(userId); const user = await this.repository.findById(userId);
if (!user) throw new WrongPasswordError(); if (!user) throw new WrongPasswordError();
const currentMatches = await verifyPassword(currentPassword, user.passwordHash); const currentMatches = await this.passwords.verify(currentPassword, user.passwordHash);
if (!currentMatches) throw new WrongPasswordError(); if (!currentMatches) throw new WrongPasswordError();
if (newPassword === currentPassword) throw new PasswordUnchangedError(); if (newPassword === currentPassword) throw new PasswordUnchangedError();
const passwordHash = await hashPassword(newPassword); const passwordHash = await this.passwords.hash(newPassword);
return this.repository.setPassword(userId, passwordHash, false); return this.repository.setPassword(userId, passwordHash, false);
} }
private getDummyHash(): Promise<string> { private getDummyHash(): Promise<string> {
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login"); this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login");
return this.dummyHash; return this.dummyHash;
} }
} }

View file

@ -7,6 +7,7 @@ import { listProjects } from "../utils/api";
import type { ProjectDto } from "../types"; import type { ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "./AuthProvider"; import { useAuth } from "./AuthProvider";
import { getConfigEntry } from "../utils/config-access";
// Anchors on the project overview page (/projects/[id]). // Anchors on the project overview page (/projects/[id]).
const PROJECT_SECTIONS = [ const PROJECT_SECTIONS = [
@ -32,6 +33,7 @@ interface SidebarProps {
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
const configEntry = getConfigEntry(isAdmin);
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
const activeProjectId = projectMatch ? projectMatch[1] : null; const activeProjectId = projectMatch ? projectMatch[1] : null;
const projectSubPath = projectMatch?.[2] ?? ""; const projectSubPath = projectMatch?.[2] ?? "";
@ -181,18 +183,16 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</> </>
)} )}
</nav> </nav>
{isAdmin && (
<nav className="sidebar-nav sidebar-nav-bottom"> <nav className="sidebar-nav sidebar-nav-bottom">
<Link <Link
href="/config" href={configEntry.href}
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"} className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
title="Config" title={configEntry.label}
> >
<span className="sidebar-icon">⚙</span> <span className="sidebar-icon">⚙</span>
<span className="sidebar-label">Config</span> <span className="sidebar-label">{configEntry.label}</span>
</Link> </Link>
</nav> </nav>
)}
<div className="sidebar-footer"> <div className="sidebar-footer">
{user && !isCollapsed && ( {user && !isCollapsed && (
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}> <div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>

View file

@ -6,6 +6,10 @@ import type {
CableSizingInput, CableSizingInput,
CableSizingResult, CableSizingResult,
} from "../../cable-sizing/domain/cable-sizing-calculation"; } from "../../cable-sizing/domain/cable-sizing-calculation";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../../shared/constants/request-security";
export interface CalculateCableSizingResponse { export interface CalculateCableSizingResponse {
result: CableSizingResult; result: CableSizingResult;
@ -17,7 +21,10 @@ export async function calculateCableSizing(
): Promise<CalculateCableSizingResponse> { ): Promise<CalculateCableSizingResponse> {
const response = await fetch("/api/cable-sizing/calculate", { const response = await fetch("/api/cable-sizing/calculate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
},
body: JSON.stringify(input), body: JSON.stringify(input),
cache: "no-store", cache: "no-store",
}); });

View file

@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const currentDisplay = const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? "" ? typedChar ?? ""
: String(visibleCell.value ?? ""); : String(visibleCell.editValue ?? visibleCell.value ?? "");
focusTokenRef.current += 1; focusTokenRef.current += 1;
setEditingCell({ setEditingCell({
...cell, ...cell,
@ -4077,7 +4077,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
key={row.rowKey} key={row.rowKey}
className={`${ className={`${
row.rowType === "circuitSummary" row.rowType === "circuitSummary"
? "summary-row" ? row.downstreamBoardLoad
? "feeder-circuit-row"
: "summary-row"
: row.rowType === "deviceRow" : row.rowType === "deviceRow"
? "device-row" ? "device-row"
: row.rowType === "downstreamBoardLoad" : row.rowType === "downstreamBoardLoad"
@ -4708,11 +4710,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={-1} tabIndex={-1}
/> />
<IconButton <IconButton
disabled={isSaving} disabled={
isSaving ||
(row.circuit.downstreamBoardLoads?.length ?? 0) > 0
}
icon={Trash2} icon={Trash2}
label="Stromkreis löschen" label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)} onClick={() => void handleDeleteCircuit(row.circuit!.id)}
tabIndex={-1} tabIndex={-1}
title={
(row.circuit.downstreamBoardLoads?.length ?? 0) > 0
? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern"
: undefined
}
variant="danger" variant="danger"
/> />
</> </>

View file

@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr> </tr>
); );
} }
if (rows.length === 0 && downstreamBoards.length === 1) {
const boardLabel =
downstreamBoards[0].boardNumber ?? downstreamBoards[0].name;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{boardLabel}
{circuit.displayName ? (
<span className="pd-sub"> · {circuit.displayName}</span>
) : null}
</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
if (rows.length === 1 && downstreamBoards.length === 0) { if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0]; const row = rows[0];
const title = circuit.displayName ?? row.displayName; const title = circuit.displayName ?? row.displayName;

View file

@ -1,3 +1,7 @@
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../../shared/constants/request-security";
import type { import type {
BuildingDto, BuildingDto,
CircuitListDto, CircuitListDto,
@ -122,6 +126,7 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
...init, ...init,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
...init?.headers, ...init?.headers,
}, },
cache: "no-store", cache: "no-store",
@ -366,20 +371,22 @@ export function createDistributionBoard(
); );
} }
export function updateDistributionBoard( export type UpdateDistributionBoardInput = {
projectId: string,
distributionBoardId: string,
input: {
floorId: string | null; floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>; supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number; simultaneityFactor: number;
} & Partial<DistributionBoardInfoDto> & } & Partial<DistributionBoardInfoDto> &
Partial< Partial<
Pick< Pick<
DistributionBoardDto, DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId" "feedingDistributionBoardId" | "feedingCircuitId"
> >
>, >;
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: UpdateDistributionBoardInput,
expectedRevision: number expectedRevision: number
) { ) {
return request<DistributionBoardCommandResultDto>( return request<DistributionBoardCommandResultDto>(
@ -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 const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() { export function getDocumentBranding() {

View file

@ -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 // 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 // separate from the large domain api.ts so this feature doesn't have to
// touch that file's shared `request()` internals. // touch that file's shared `request()` internals.
@ -39,7 +44,11 @@ export class AuthApiError extends Error {
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
...init, ...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", cache: "no-store",
}); });
if (!response.ok) { if (!response.ok) {

View file

@ -617,7 +617,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
if (rowType === "downstreamBoardLoad") return "readonly"; if (rowType === "downstreamBoardLoad") return "readonly";
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "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 (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField"; if (circuitFieldKeys.has(key)) return "circuitField";
} }

View file

@ -37,6 +37,8 @@ export interface VisibleGridCell {
editable: boolean; editable: boolean;
kind: CellKind; kind: CellKind;
value: GridValue; value: GridValue;
/** Separate persisted value when the displayed value is derived. */
editValue?: GridValue;
} }
export interface VisibleGridRow { export interface VisibleGridRow {
@ -144,6 +146,7 @@ function makeVisibleGridRow(
const kind = getCellKind(rowType, column.key); const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField"; const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue; let value: GridValue;
let editValue: GridValue | undefined;
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
value = column.key === "displayName" value = column.key === "displayName"
@ -155,6 +158,35 @@ function makeVisibleGridRow(
: column.key === "remark" : column.key === "remark"
? "Automatisch aus der verknüpften Verteilung" ? "Automatisch aus der verknüpften Verteilung"
: undefined; : 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") { } else if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) { } else if (kind === "deviceField" && device) {
@ -178,7 +210,7 @@ function makeVisibleGridRow(
value = circuit.voltage; value = circuit.voltage;
} }
return { cellKey: column.key, editable, kind, value }; return { cellKey: column.key, editable, kind, value, editValue };
}); });
return { return {
@ -247,6 +279,19 @@ export function buildVisibleGridRowsWithStructure(
const downstreamLoads = circuit.downstreamBoardLoads ?? []; const downstreamLoads = circuit.downstreamBoardLoads ?? [];
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); 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) { } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else { } else {

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"project.update-settings": "Projekteinstellungen bearbeitet", "project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt", "distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet", "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.delete": "Verteilung entfernt",
"distribution-board.insert-subtree": "Verteilung vollständig eingefügt", "distribution-board.insert-subtree": "Verteilung vollständig eingefügt",
"distribution-board.delete-subtree": "Verteilung vollständig entfernt", "distribution-board.delete-subtree": "Verteilung vollständig entfernt",

View file

@ -2,8 +2,12 @@ import type { NextFunction, Request, RequestHandler, Response } from "express";
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
import { readCookie } from "./cookie.js"; import { readCookie } from "./cookie.js";
import type { SessionStore } from "./sessions.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 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 { export interface AuthUser {
id: string; id: string;
@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: {
export async function identityFromRequest( export async function identityFromRequest(
req: { headers: { cookie?: string | undefined } }, req: { headers: { cookie?: string | undefined } },
sessions: SessionStore, sessions: SessionStore,
users: UserRepository, users: Pick<UserStore, "findById">,
slideExpiry = true slideExpiry = true
): Promise<AuthUser | undefined> { ): Promise<AuthUser | undefined> {
const session = sessions.get(sessionIdFromRequest(req), slideExpiry); 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. * CSRF defense for cookie-authenticated writes.
* *
* SameSite=Lax alone is not enough here: every other service on the same * 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 * host (other ports of the same IP) counts as "same-site" and may receive the
* cookie attached. Browsers send Sec-Fetch-Site on every request, so a write * cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN.
* is only accepted from the app's own origin. Clients without the header * Every write therefore needs an application header that HTML forms cannot
* (curl, scripts) carry no ambient browser cookie and are not a CSRF vector. * 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 { export function requireSameOriginForWrites(): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler {
next(); next();
return; 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"]; const fetchSite = req.headers["sec-fetch-site"];
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
next(); next();
@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler {
export function createAuthGuard( export function createAuthGuard(
authConfig: AuthConfig, authConfig: AuthConfig,
sessions: SessionStore, sessions: SessionStore,
users: UserRepository, users: Pick<UserStore, "findById">,
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
): RequestHandler { ): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {

View file

@ -4,6 +4,12 @@ const SCRYPT_N = 16384;
const SCRYPT_R = 8; const SCRYPT_R = 8;
const SCRYPT_P = 1; const SCRYPT_P = 1;
const KEY_LENGTH = 64; 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 // 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 // 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 // Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
// hash so they can be tuned later without invalidating existing rows. // hash so they can be tuned later without invalidating existing rows.
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16); const salt = randomBytes(SALT_LENGTH);
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: MAX_SCRYPT_MEMORY,
});
return [ return [
"scrypt", "scrypt",
SCRYPT_N, SCRYPT_N,
@ -52,12 +63,33 @@ export async function verifyPassword(
const n = Number(nRaw); const n = Number(nRaw);
const r = Number(rRaw); const r = Number(rRaw);
const p = Number(pRaw); 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; return false;
} }
const salt = Buffer.from(saltHex, "hex"); const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex"); const expected = Buffer.from(hashHex, "hex");
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); try {
if (derivedKey.length !== expected.length) return false; const derivedKey = await scryptAsync(password, salt, expected.length, {
N: n,
r,
p,
maxmem: MAX_SCRYPT_MEMORY,
});
return timingSafeEqual(derivedKey, expected); return timingSafeEqual(derivedKey, expected);
} catch {
return false;
}
} }

View file

@ -3,9 +3,14 @@ import { LoginThrottle } from "../auth/login-throttle.js";
import { SessionStore } from "../auth/sessions.js"; import { SessionStore } from "../auth/sessions.js";
import { UserService } from "../../domain/services/user.service.js"; import { UserService } from "../../domain/services/user.service.js";
import { userRepository } from "./application-repositories.js"; import { userRepository } from "./application-repositories.js";
import { hashPassword, verifyPassword } from "../auth/password.js";
export const authConfig = readAuthConfig(); export const authConfig = readAuthConfig();
export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs); export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
sessionStore.startSweeping(); sessionStore.startSweeping();
export const loginThrottle = new LoginThrottle(); 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 }
);

View file

@ -0,0 +1,2 @@
export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request";
export const WRITE_REQUEST_HEADER_VALUE = "1";

View file

@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j
import type { AuthConfig } from "../src/server/auth/auth-config.js"; import type { AuthConfig } from "../src/server/auth/auth-config.js";
import { createUserSchema } from "../src/shared/validation/user.schemas.js"; import { createUserSchema } from "../src/shared/validation/user.schemas.js";
import { loginSchema } from "../src/shared/validation/auth.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; const HOUR = 60 * 60 * 1000;
@ -34,7 +38,14 @@ function createUserService() {
cookieSecure: false, cookieSecure: false,
bootstrap: { username: "admin", password: "bootstrap-password-123" }, 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", () => { describe("password hashing", () => {
@ -52,6 +63,17 @@ describe("password hashing", () => {
it("rejects malformed stored hashes instead of throwing", async () => { it("rejects malformed stored hashes instead of throwing", async () => {
assert.equal(await verifyPassword("x", "not-a-hash"), false); 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$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", () => { 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 status: number | undefined;
let passed = false; let passed = false;
const req = { const req = {
method, 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; } as unknown as Request;
const res = { const res = {
status(code: number) { status(code: number) {
@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => {
} }
it("lets reads and same-origin writes through", () => { 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", "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("POST", "cross-site"), { passed: false, status: 403 });
assert.deepEqual(run("PATCH", "same-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 }); 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); 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 () => { it("requires the current password and a different new one", async () => {
const { service } = createUserService(); const { service } = createUserService();
const user = await service.createUser({ const user = await service.createUser({

View file

@ -133,7 +133,9 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); 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("placeholder", "displayName"), "deviceField");
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");

View file

@ -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 = { const feeder = {
...reserveCircuit, ...reserveCircuit,
displayName: undefined,
isReserve: false, isReserve: false,
circuitTotalPower: 4, circuitTotalPower: 4,
circuitApparentPower: 5, circuitApparentPower: 5,
@ -202,19 +203,65 @@ describe("circuit grid projection", () => {
assert.deepEqual(rows.map((row) => row.rowType), [ assert.deepEqual(rows.map((row) => row.rowType), [
"section", "section",
"circuitSummary", "circuitSummary",
"downstreamBoardLoad",
"placeholder", "placeholder",
]); ]);
const loadRow = rows[2]; const loadRow = rows[1];
assert.equal( assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, 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( assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
4 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", () => { it("shows circuit protection once on each visible circuit row", () => {

View file

@ -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);
});
});

View file

@ -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();
}
});
});

View file

@ -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"],
]
);
});
});

View file

@ -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
);
}
});
});