Compare commits
6 commits
728132e52a
...
3670f02ccd
| Author | SHA1 | Date | |
|---|---|---|---|
| 3670f02ccd | |||
| 847f4e9493 | |||
| 73579d1e79 | |||
| 0a37783635 | |||
| 9165b8238c | |||
| ade21fe386 |
46 changed files with 1632 additions and 185 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
16
AGENTS.md
16
AGENTS.md
|
|
@ -42,7 +42,10 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||||
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
|
`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.
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -7,7 +7,9 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
redirect("/config/benutzer");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/domain/ports/user.store.ts
Normal file
40
src/domain/ports/user.store.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,10 +371,7 @@ 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;
|
||||||
|
|
@ -379,7 +381,12 @@ export function updateDistributionBoard(
|
||||||
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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
25
src/frontend/utils/config-access.ts
Normal file
25
src/frontend/utils/config-access.ts
Normal 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;
|
||||||
|
}
|
||||||
68
src/frontend/utils/distribution-board-feeder-circuit.ts
Normal file
68
src/frontend/utils/distribution-board-feeder-circuit.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
|
|
||||||
2
src/shared/constants/request-security.ts
Normal file
2
src/shared/constants/request-security.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request";
|
||||||
|
export const WRITE_REQUEST_HEADER_VALUE = "1";
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
26
tests/config-access.test.ts
Normal file
26
tests/config-access.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
168
tests/distribution-board-feeder-circuit.test.ts
Normal file
168
tests/distribution-board-feeder-circuit.test.ts
Normal 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"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/write-request-headers.test.ts
Normal file
40
tests/write-request-headers.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue