diff --git a/.env.example b/.env.example deleted file mode 100644 index 46fa42a..0000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Nur beim ersten Start mit leerer Benutzertabelle erforderlich. -AUTH_BOOTSTRAP_USERNAME= -AUTH_BOOTSTRAP_PASSWORT= -AUTH_SESSION_HOURS=12 -AUTH_SESSION_MAX_DAYS=7 -# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false. -COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore index b237969..a6e1ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ node_modules/ dist/ .next/ -.env -.env.* -!.env.example # Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. /drizzle.config.js data/*.db diff --git a/AGENTS.md b/AGENTS.md index b01e6b5..4300b38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,10 +42,7 @@ It must support circuits, device rows, project devices, drag-and-drop restructur `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a signed-in user (`src/server/index.ts`); `/api/users/*` additionally requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` - (`src/app/layout.tsx`). 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. + (`src/app/layout.tsx`). See `docs/authentication.md` for the full picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. @@ -486,17 +483,6 @@ downstream board's demand after its own board simultaneity factor to the chosen feeding circuit as a read-only virtual row. Recursive chains and several boards on one circuit are supported. A board-only link affects hierarchy but adds no load until a feeding circuit is selected. -The distribution-board dialog can create one empty feeder circuit and link it -in the same `distribution-board.create-feeder-circuit` project command. Its -history inverse restores the complete previous board update before removing -only the unchanged generated circuit; Redo reuses its UUID and BMK. No -CircuitDeviceRow is created. The three-phase group is suggested, the optional -circuit display name stays empty by default, and protection remains open for -planner sizing. A circuit with exactly one downstream board and no device rows -is rendered as one compact row using the live board number/name; several -downstream boards or additional devices retain summary and child rows. Editing -the name or remark of an empty reserve circuit changes the circuit itself and -does not implicitly create a device row. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. diff --git a/README.md b/README.md index f64ae04..b01ae85 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,7 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein Docker-Server-Stack mit gebautem Anwendungscode und ein separater Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment -für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes -Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind -vorhanden. Revit-CSV-Konfiguration, Vorschau +und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau und bestätigter Erstimport sind implementiert; die Editoroberfläche zur Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import @@ -62,8 +60,6 @@ Voraussetzungen: ```powershell git clone Set-Location leistungsbilanz-ts -$env:AUTH_BOOTSTRAP_USERNAME = "admin" -$env:AUTH_BOOTSTRAP_PASSWORT = "" docker compose up --build --detach ``` @@ -72,15 +68,9 @@ Danach: - Frontend: - API-Healthcheck: (über den Next.js-Rewrite) -Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator, -solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für -einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte -`.env` neben `compose.yaml`; Details stehen unter -[Anmeldung und Benutzerverwaltung](docs/authentication.md). - -Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen -API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den -Rewrite im Web-Dienst. +Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine +Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der +Zugriff läuft über den Rewrite im Web-Dienst. Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start automatisch aus. Die SQLite-Datei liegt auf dem Host unter @@ -126,8 +116,6 @@ Voraussetzungen: npm ci npm run db:migrate npm run db:verify:circuit-schema -$env:AUTH_BOOTSTRAP_USERNAME = "admin" -$env:AUTH_BOOTSTRAP_PASSWORT = "" npm run dev:api ``` @@ -174,7 +162,6 @@ vor der Baseline werden nicht unterstützt. - [Gebäudestruktur und Raumflächen](docs/building-structure.md) - [Grobe Leistungsbilanz](docs/estimate-balance.md) - [Leitungsberechnung](docs/cable-sizing-module.md) -- [Anmeldung und Benutzerverwaltung](docs/authentication.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md) diff --git a/compose.dev.yaml b/compose.dev.yaml index f49e9e6..2c362bc 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -38,8 +38,6 @@ services: AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" - AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" - COOKIE_SECURE: "${COOKIE_SECURE:-false}" # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. ports: - "127.0.0.1:3000:3000" diff --git a/compose.yaml b/compose.yaml index 1de9104..2a2eecd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -47,7 +47,7 @@ services: LOG_LEVEL: "${LOG_LEVEL:-info}" # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte - # .env - siehe docs/authentication.md. + # .env - siehe docs/AUTH.md. AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" diff --git a/docs/authentication.md b/docs/authentication.md index 32039cf..15729aa 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -7,9 +7,10 @@ Zugang wird von einem Administrator über **Config → Benutzer** angelegt. Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): -- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer** +- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich + (Benutzerverwaltung, eigenes Konto) - **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, - Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung + Grobbilanz, …), kein Zugriff auf Config Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. @@ -43,8 +44,6 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. - **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate` (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. - **Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für - Administratoren. ## Schutzmechanismen @@ -53,12 +52,12 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. | Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). | | Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash | | Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung | -| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. | -| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. | +| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten | +| CSRF über reines HTTP | Browser senden `Sec-Fetch-Site` nur an HTTPS-Adressen. Beim Zugriff per `http://…` im LAN greift deshalb nur SameSite=Lax (schützt gegen fremde Domains, nicht gegen andere Ports desselben Hosts). | | Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel | | Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) | | Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen | -| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. | +| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt | | Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` | | DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt | | Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben | @@ -78,18 +77,6 @@ ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht in `compose.yaml` selbst. -```dotenv -AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORT= -AUTH_SESSION_HOURS=12 -AUTH_SESSION_MAX_DAYS=7 -COOKIE_SECURE=false -``` - -Bei Zugriff über HTTPS muss `COOKIE_SECURE=true` gesetzt werden. Bei direktem -HTTP-Zugriff im LAN bleibt es `false`, da der Browser das Session-Cookie sonst -nicht speichert. - ## Bewusst nicht umgesetzt (Stand jetzt) - Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index de724aa..a8173f5 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -269,13 +269,6 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. submitted info fields, are restored together by persistent Undo/Redo - `fedFrom` remains free text for external feeds such as HAK, metering, transformer or generator and for supplementary notes -- The project-command endpoint additionally accepts - `distribution-board.create-feeder-circuit` with an empty complete circuit - snapshot and an embedded version-2 `distribution-board.update`. Both writes - are one transaction, revision and Undo step. The generated circuit has no - device row; Redo keeps its UUID and BMK. The history-only inverse - `distribution-board.remove-feeder-circuit` restores the previous board values - and removes only the unchanged circuit. - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - duplicates the complete distribution-board subtree with new owning UUIDs @@ -353,9 +346,7 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. footer components. Circuit and component protection devices use their separate one-to-one DTOs. Calculated row, circuit, section and board totals remain included. Circuit totals contain the demand of linked downstream - boards. `downstreamBoardLoads` exposes those contributions as read-only - values; the grid combines exactly one downstream board without device rows - into the circuit row and keeps child rows for mixed or multiple loads. + boards. `downstreamBoardLoads` exposes those contributions as read-only rows; `feedingReference` resolves the current board/circuit labels by id. Response sketch: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 20c7ccd..920002d 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -33,15 +33,6 @@ mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die Datenbank liegt über einen Host-Mount außerhalb des Containers. -Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus -`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar. -Schreibzugriffe verlangen zusätzlich den Anwendungsheader -`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN -ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren -vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu -Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher -des einzelnen API-Prozesses und enden bei dessen Neustart. - Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls @@ -66,11 +57,6 @@ vor dem Command und bleiben über Undo/Redo unverändert. - `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion, Zellbesitz, Einfügen und Sicherheitsregeln - `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe -- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung - und erzwungener Wechsel eines Startpassworts -- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz -- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` – - kontobezogene Regeln und schmale Persistenzgrenze - `src/server/index.ts` und `src/server/routes/` – API-Komposition - `src/domain/services/` – fachliche Command- und Synchronisierungsregeln - `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten @@ -491,21 +477,6 @@ Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden vor der Datenbankänderung fachlich gesperrt. -Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein -neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige -Gruppe und das nächste BMK vor. Der Befehl -`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und -wendet die vollständige Verteilungsänderung in derselben Transaktion und -Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst -offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo -stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach -nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut. -Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das -Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten -Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen -Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren -Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine -Gerätezeile. Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten diff --git a/docs/deployment.md b/docs/deployment.md index 89c99a3..0b5baba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen Entwicklungsrechner. -Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen -vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem. -Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt -übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden. +Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten +Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten +Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf +deshalb nicht öffentlich erreichbar gemacht werden. ## Topologie @@ -29,10 +29,9 @@ Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt | Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` | | SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | -Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht -(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive -Session; Schreibzugriffe zusätzlich den Anwendungsheader -`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem +Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht +(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie +erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. @@ -73,26 +72,6 @@ Verwendete Umgebungsvariablen: JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei - leerer Benutzertabelle den ersten Administrator; das Startpasswort hat - mindestens 12 Zeichen. -- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden. -- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage. -- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN - `false`, da Browser das Cookie sonst nicht speichern. - -Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen, -nicht versionierten `.env` neben `compose.yaml` gesetzt werden: - -```dotenv -AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORT= -``` - -Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange -mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in -**Config → Benutzer** an. Details stehen in -[Anmeldung und Benutzerverwaltung](authentication.md). Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung (`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im @@ -158,8 +137,7 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie. Vor einer produktiven Installation werden mindestens benötigt: - TLS-Termination und Reverse Proxy -- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei - festen Rollen später nicht ausreichen +- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung - persistenter, gesicherter Datenbankbetrieb - kontrollierter einmaliger Migrationsschritt vor dem API-Rollout diff --git a/docs/print-view.md b/docs/print-view.md index ecc00e2..a843030 100644 --- a/docs/print-view.md +++ b/docs/print-view.md @@ -38,9 +38,8 @@ automatisch bei späteren Änderungen in einem anderen Tab. 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Pgl; darunter Kopfkomponenten, Gruppen, Stromkreise - (reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit - Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten - Unterverteilungszeilen), + (mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und + schreibgeschützten Unterverteilungszeilen), Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim Drucken auf Folgeseiten. diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx index 72d9b6d..1356d78 100644 --- a/src/app/config/layout.tsx +++ b/src/app/config/layout.tsx @@ -4,27 +4,26 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "../../frontend/components/AuthProvider"; -import { - getConfigRedirect, - getConfigTabs, -} from "../../frontend/utils/config-access"; + +const CONFIG_TABS = [ + { href: "/config/benutzer", label: "Benutzer" }, + { href: "/config/konto", label: "Konto" }, +] as const; export default function ConfigLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); const { status, isAdmin } = useAuth(); - const tabs = getConfigTabs(isAdmin); - const redirectTarget = getConfigRedirect(pathname, isAdmin); useEffect(() => { - if (status !== "signed-in") return; - if (redirectTarget) router.replace(redirectTarget); - }, [status, redirectTarget, router]); + // LoginGate already guarantees a signed-in user with a set password by + // the time this layout renders; only the role still needs checking. + if (status === "signed-in" && !isAdmin) { + router.replace("/"); + } + }, [status, isAdmin, router]); - if ( - status !== "signed-in" || - redirectTarget !== null - ) { + if (status !== "signed-in" || !isAdmin) { return null; } @@ -35,7 +34,7 @@ export default function ConfigLayout({ children }: { children: React.ReactNode } - + {isAdmin && ( + + )}
{user && !isCollapsed && (
diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts index 73582b5..28530d2 100644 --- a/src/frontend/components/cable-sizing-api.ts +++ b/src/frontend/components/cable-sizing-api.ts @@ -6,10 +6,6 @@ import type { CableSizingInput, CableSizingResult, } from "../../cable-sizing/domain/cable-sizing-calculation"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; export interface CalculateCableSizingResponse { result: CableSizingResult; @@ -21,10 +17,7 @@ export async function calculateCableSizing( ): Promise { const response = await fetch("/api/cable-sizing/calculate", { method: "POST", - headers: { - "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), cache: "no-store", }); diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index f75b64e..bac08ce 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const currentDisplay = mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" ? typedChar ?? "" - : String(visibleCell.editValue ?? visibleCell.value ?? ""); + : String(visibleCell.value ?? ""); focusTokenRef.current += 1; setEditingCell({ ...cell, @@ -4077,9 +4077,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str key={row.rowKey} className={`${ row.rowType === "circuitSummary" - ? row.downstreamBoardLoad - ? "feeder-circuit-row" - : "summary-row" + ? "summary-row" : row.rowType === "deviceRow" ? "device-row" : row.rowType === "downstreamBoardLoad" @@ -4710,19 +4708,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str tabIndex={-1} /> 0 - } + disabled={isSaving} icon={Trash2} label="Stromkreis löschen" onClick={() => void handleDeleteCircuit(row.circuit!.id)} tabIndex={-1} - title={ - (row.circuit.downstreamBoardLoads?.length ?? 0) > 0 - ? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern" - : undefined - } variant="danger" /> diff --git a/src/frontend/print/print-document.tsx b/src/frontend/print/print-document.tsx index a13d7e1..5a0152b 100644 --- a/src/frontend/print/print-document.tsx +++ b/src/frontend/print/print-document.tsx @@ -431,25 +431,6 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) { ); } - if (rows.length === 0 && downstreamBoards.length === 1) { - const boardLabel = - downstreamBoards[0].boardNumber ?? downstreamBoards[0].name; - return ( - - {circuit.equipmentIdentifier} - - {boardLabel} - {circuit.displayName ? ( - · {circuit.displayName} - ) : null} - - – - - {formatKilowatt(circuit.circuitTotalPower)} - {circuitCells} - - ); - } if (rows.length === 1 && downstreamBoards.length === 0) { const row = rows[0]; const title = circuit.displayName ?? row.displayName; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 5256195..8473bfa 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,7 +1,3 @@ -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; import type { BuildingDto, CircuitListDto, @@ -126,7 +122,6 @@ async function request(url: string, init?: RequestInit): Promise { ...init, headers: { "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, ...init?.headers, }, cache: "no-store", @@ -371,22 +366,20 @@ export function createDistributionBoard( ); } -export type UpdateDistributionBoardInput = { - floorId: string | null; - supplyType: NonNullable; - simultaneityFactor: number; -} & Partial & - Partial< - Pick< - DistributionBoardDto, - "feedingDistributionBoardId" | "feedingCircuitId" - > - >; - export function updateDistributionBoard( projectId: string, distributionBoardId: string, - input: UpdateDistributionBoardInput, + input: { + floorId: string | null; + supplyType: NonNullable; + simultaneityFactor: number; + } & Partial & + Partial< + Pick< + DistributionBoardDto, + "feedingDistributionBoardId" | "feedingCircuitId" + > + >, expectedRevision: number ) { return request( @@ -398,41 +391,6 @@ export function updateDistributionBoard( ); } -export function createFeederCircuitAndUpdateDistributionBoard( - projectId: string, - distributionBoardId: string, - input: UpdateDistributionBoardInput & { - feedingDistributionBoardId: string; - feedingCircuitId: string; - }, - circuit: CircuitSnapshot, - expectedRevision: number -) { - return executeProjectCommand( - projectId, - expectedRevision, - { - schemaVersion: 1, - type: "distribution-board.create-feeder-circuit", - payload: { - circuit, - boardUpdate: { - schemaVersion: 2, - type: "distribution-board.update", - payload: { - distributionBoardId, - changes: Object.entries(input).map(([field, value]) => ({ - field, - value, - })), - }, - }, - }, - }, - "Einspeiseabgang anlegen und Verteilung verknüpfen" - ); -} - export const documentLogoUrl = "/api/document-branding/logo"; export function getDocumentBranding() { diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts index a6f12b3..f7057f9 100644 --- a/src/frontend/utils/auth-api.ts +++ b/src/frontend/utils/auth-api.ts @@ -1,8 +1,3 @@ -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security"; - // Small, self-contained fetch helper for the auth/user endpoints - kept // separate from the large domain api.ts so this feature doesn't have to // touch that file's shared `request()` internals. @@ -44,11 +39,7 @@ export class AuthApiError extends Error { async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, - headers: { - "Content-Type": "application/json", - [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, - ...init?.headers, - }, + headers: { "Content-Type": "application/json", ...init?.headers }, cache: "no-store", }); if (!response.ok) { diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index fafeca4..faa4a11 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -617,11 +617,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind { if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; if (rowType === "downstreamBoardLoad") return "readonly"; if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly"; - if (rowType === "reserveCircuit") { - if (circuitFieldKeys.has(key)) return "circuitField"; - if (deviceFieldKeys.has(key)) return "deviceField"; - } - if (rowType === "circuitCompact") { + if (rowType === "reserveCircuit" || rowType === "circuitCompact") { if (deviceFieldKeys.has(key)) return "deviceField"; if (circuitFieldKeys.has(key)) return "circuitField"; } diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts index 30e5ecf..a9b11de 100644 --- a/src/frontend/utils/circuit-grid-projection.ts +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -37,8 +37,6 @@ export interface VisibleGridCell { editable: boolean; kind: CellKind; value: GridValue; - /** Separate persisted value when the displayed value is derived. */ - editValue?: GridValue; } export interface VisibleGridRow { @@ -146,7 +144,6 @@ function makeVisibleGridRow( const kind = getCellKind(rowType, column.key); const editable = kind === "circuitField" || kind === "deviceField"; let value: GridValue; - let editValue: GridValue | undefined; if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { value = column.key === "displayName" @@ -158,35 +155,6 @@ function makeVisibleGridRow( : column.key === "remark" ? "Automatisch aus der verknüpften Verteilung" : undefined; - } else if ( - rowType === "circuitSummary" && - circuit && - downstreamBoardLoad && - circuit.deviceRows.length === 0 - ) { - if (column.key === "displayName") { - const boardLabel = - downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name; - value = circuit.displayName - ? `${boardLabel} · ${circuit.displayName}` - : boardLabel; - editValue = circuit.displayName ?? ""; - } else if (column.key === "cosPhi") { - value = circuit.circuitCosPhi; - } else if ( - kind === "computed" && - circuitOnlyColumns.has(column.key) - ) { - value = getCircuitValue(circuit, column.key); - } else if (kind === "circuitField") { - value = getCircuitValue(circuit, column.key); - } else if (column.key === "rowTotalPower") { - value = circuit.circuitTotalPower; - } else if (column.key === "circuitTotalPower") { - value = circuit.circuitTotalPower; - } else if (column.key === "voltage") { - value = circuit.voltage; - } } else if (rowType === "placeholder") { value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; } else if (kind === "deviceField" && device) { @@ -210,7 +178,7 @@ function makeVisibleGridRow( value = circuit.voltage; } - return { cellKey: column.key, editable, kind, value, editValue }; + return { cellKey: column.key, editable, kind, value }; }); return { @@ -279,19 +247,6 @@ export function buildVisibleGridRowsWithStructure( const downstreamLoads = circuit.downstreamBoardLoads ?? []; if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); - } else if ( - circuit.deviceRows.length === 0 && - downstreamLoads.length === 1 - ) { - rows.push( - makeVisibleGridRow( - "circuitSummary", - section.id, - circuit, - undefined, - downstreamLoads[0] - ) - ); } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); } else { diff --git a/src/frontend/utils/config-access.ts b/src/frontend/utils/config-access.ts deleted file mode 100644 index 86dd2a2..0000000 --- a/src/frontend/utils/config-access.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface ConfigNavigationEntry { - href: string; - label: string; -} - -const ACCOUNT_ENTRY = { href: "/config/konto", label: "Konto" } as const; -const USERS_ENTRY = { href: "/config/benutzer", label: "Benutzer" } as const; - -export function getConfigTabs(isAdmin: boolean): ConfigNavigationEntry[] { - return isAdmin ? [USERS_ENTRY, ACCOUNT_ENTRY] : [ACCOUNT_ENTRY]; -} - -export function getConfigEntry(isAdmin: boolean): ConfigNavigationEntry { - return isAdmin ? { href: USERS_ENTRY.href, label: "Config" } : ACCOUNT_ENTRY; -} - -export function getConfigRedirect(pathname: string | null, isAdmin: boolean): string | null { - if (pathname === "/config") { - return isAdmin ? USERS_ENTRY.href : ACCOUNT_ENTRY.href; - } - if (!isAdmin && pathname?.startsWith(USERS_ENTRY.href)) { - return ACCOUNT_ENTRY.href; - } - return null; -} diff --git a/src/frontend/utils/distribution-board-feeder-circuit.ts b/src/frontend/utils/distribution-board-feeder-circuit.ts deleted file mode 100644 index c2c7f43..0000000 --- a/src/frontend/utils/distribution-board-feeder-circuit.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js"; -import type { - CircuitTreeResponseDto, - CircuitTreeSectionDto, -} from "../types"; -import { buildCircuitInsertSnapshot } from "./circuit-structure-command"; - -export function getDefaultFeederCircuitSection( - tree: CircuitTreeResponseDto | undefined -) { - if (!tree) return undefined; - return ( - tree.sections.find((section) => section.category === "three_phase") ?? - tree.sections.find((section) => section.category !== undefined) - ); -} - -export function getNextFeederCircuitIdentifier( - section: CircuitTreeSectionDto -) { - let highestSuffix = 0; - for (const circuit of section.circuits) { - if (!circuit.equipmentIdentifier.startsWith(section.prefix)) continue; - const suffix = circuit.equipmentIdentifier.slice(section.prefix.length); - if (!/^\d+$/.test(suffix)) continue; - highestSuffix = Math.max(highestSuffix, Number(suffix)); - } - return `${section.prefix}${highestSuffix + 1}`; -} - -export function buildFeederCircuitSnapshot(input: { - id: string; - tree: CircuitTreeResponseDto; - sectionId: string; - displayName: string; -}): CircuitSnapshot { - const section = input.tree.sections.find( - (candidate) => candidate.id === input.sectionId - ); - if (!section || !section.category) { - throw new Error("Die gewählte Stromkreisgruppe ist nicht verfügbar."); - } - const snapshot = buildCircuitInsertSnapshot({ - id: input.id, - circuitListId: input.tree.circuitListId, - category: section.category, - deviceRows: [], - values: { - sectionId: section.id, - equipmentIdentifier: getNextFeederCircuitIdentifier(section), - displayName: input.displayName.trim() || undefined, - sortOrder: - section.circuits.length === 0 - ? 10 - : Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + - 10, - voltage: - section.category === "three_phase" - ? input.tree.threePhaseVoltageV - : input.tree.singlePhaseVoltageV, - isReserve: true, - }, - }); - // A distribution-board feeder is not a generic final circuit. Its protection - // must be selected from the calculated load and cable, so no 16 A group - // default is persisted here. - return { ...snapshot, protectionDevice: null }; -} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index eb195d0..146cda8 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -32,10 +32,6 @@ const commandTypeLabels: Record = { "project.update-settings": "Projekteinstellungen bearbeitet", "distribution-board.insert": "Verteilung angelegt", "distribution-board.update": "Verteilung bearbeitet", - "distribution-board.create-feeder-circuit": - "Einspeiseabgang angelegt und verknüpft", - "distribution-board.remove-feeder-circuit": - "Einspeiseabgang und Verknüpfung entfernt", "distribution-board.delete": "Verteilung entfernt", "distribution-board.insert-subtree": "Verteilung vollständig eingefügt", "distribution-board.delete-subtree": "Verteilung vollständig entfernt", diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts index bafae03..ce65bfb 100644 --- a/src/server/auth/auth.middleware.ts +++ b/src/server/auth/auth.middleware.ts @@ -2,12 +2,8 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; import { readCookie } from "./cookie.js"; import type { SessionStore } from "./sessions.js"; -import type { UserStore } from "../../domain/ports/user.store.js"; +import type { UserRepository } from "../../db/repositories/user.repository.js"; import type { UserRole } from "../../shared/constants/user-roles.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../../shared/constants/request-security.js"; export interface AuthUser { id: string; @@ -34,7 +30,7 @@ export function sessionIdFromRequest(req: { export async function identityFromRequest( req: { headers: { cookie?: string | undefined } }, sessions: SessionStore, - users: Pick, + users: UserRepository, slideExpiry = true ): Promise { const session = sessions.get(sessionIdFromRequest(req), slideExpiry); @@ -78,11 +74,10 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); * CSRF defense for cookie-authenticated writes. * * SameSite=Lax alone is not enough here: every other service on the same - * host (other ports of the same IP) counts as "same-site" and may receive the - * cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN. - * Every write therefore needs an application header that HTML forms cannot - * set; a cross-origin fetch would require a CORS preflight, which the API does - * not permit. Sec-Fetch-Site remains an additional browser-side check. + * host (other ports of the same IP) counts as "same-site" and would get the + * cookie attached. Browsers send Sec-Fetch-Site on every request, so a write + * is only accepted from the app's own origin. Clients without the header + * (curl, scripts) carry no ambient browser cookie and are not a CSRF vector. */ export function requireSameOriginForWrites(): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { @@ -90,10 +85,6 @@ export function requireSameOriginForWrites(): RequestHandler { next(); return; } - if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) { - res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." }); - return; - } const fetchSite = req.headers["sec-fetch-site"]; if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { next(); @@ -107,7 +98,7 @@ export function requireSameOriginForWrites(): RequestHandler { export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, - users: Pick, + users: UserRepository, openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] ): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts index 97132af..96814e1 100644 --- a/src/server/auth/password.ts +++ b/src/server/auth/password.ts @@ -4,12 +4,6 @@ const SCRYPT_N = 16384; const SCRYPT_R = 8; const SCRYPT_P = 1; const KEY_LENGTH = 64; -const SALT_LENGTH = 16; -const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024; -const MIN_SCRYPT_N = 1 << 14; -const MAX_SCRYPT_N = 1 << 16; -const MAX_SCRYPT_R = 16; -const MAX_SCRYPT_P = 8; // A hand-rolled wrapper instead of util.promisify(scrypt): promisify only // resolves to scrypt's 3-argument overload, so the 4-argument form used for @@ -36,13 +30,8 @@ function scryptAsync( // Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the // hash so they can be tuned later without invalidating existing rows. export async function hashPassword(password: string): Promise { - const salt = randomBytes(SALT_LENGTH); - const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, - maxmem: MAX_SCRYPT_MEMORY, - }); + const salt = randomBytes(16); + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); return [ "scrypt", SCRYPT_N, @@ -63,33 +52,12 @@ export async function verifyPassword( const n = Number(nRaw); const r = Number(rRaw); const p = Number(pRaw); - if ( - !Number.isSafeInteger(n) || - n < MIN_SCRYPT_N || - n > MAX_SCRYPT_N || - (n & (n - 1)) !== 0 || - !Number.isSafeInteger(r) || - r < 1 || - r > MAX_SCRYPT_R || - !Number.isSafeInteger(p) || - p < 1 || - p > MAX_SCRYPT_P || - !/^[0-9a-f]{32}$/i.test(saltHex) || - !/^[0-9a-f]{128}$/i.test(hashHex) - ) { + if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) { return false; } const salt = Buffer.from(saltHex, "hex"); const expected = Buffer.from(hashHex, "hex"); - try { - const derivedKey = await scryptAsync(password, salt, expected.length, { - N: n, - r, - p, - maxmem: MAX_SCRYPT_MEMORY, - }); - return timingSafeEqual(derivedKey, expected); - } catch { - return false; - } + const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); + if (derivedKey.length !== expected.length) return false; + return timingSafeEqual(derivedKey, expected); } diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts index 07b822b..1b5ce09 100644 --- a/src/server/composition/auth.ts +++ b/src/server/composition/auth.ts @@ -3,14 +3,9 @@ import { LoginThrottle } from "../auth/login-throttle.js"; import { SessionStore } from "../auth/sessions.js"; import { UserService } from "../../domain/services/user.service.js"; import { userRepository } from "./application-repositories.js"; -import { hashPassword, verifyPassword } from "../auth/password.js"; export const authConfig = readAuthConfig(); export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs); sessionStore.startSweeping(); export const loginThrottle = new LoginThrottle(); -export const userService = new UserService( - userRepository, - { bootstrap: authConfig.bootstrap }, - { hash: hashPassword, verify: verifyPassword } -); +export const userService = new UserService(userRepository, authConfig); diff --git a/src/shared/constants/request-security.ts b/src/shared/constants/request-security.ts deleted file mode 100644 index f89ce10..0000000 --- a/src/shared/constants/request-security.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request"; -export const WRITE_REQUEST_HEADER_VALUE = "1"; diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts index e5c431d..e634716 100644 --- a/tests/authentication.test.ts +++ b/tests/authentication.test.ts @@ -21,10 +21,6 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j import type { AuthConfig } from "../src/server/auth/auth-config.js"; import { createUserSchema } from "../src/shared/validation/user.schemas.js"; import { loginSchema } from "../src/shared/validation/auth.schemas.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../src/shared/constants/request-security.js"; const HOUR = 60 * 60 * 1000; @@ -38,14 +34,7 @@ function createUserService() { cookieSecure: false, bootstrap: { username: "admin", password: "bootstrap-password-123" }, }; - return { - repository, - service: new UserService( - repository, - { bootstrap: config.bootstrap }, - { hash: hashPassword, verify: verifyPassword } - ), - }; + return { repository, service: new UserService(repository, config) }; } describe("password hashing", () => { @@ -63,17 +52,6 @@ describe("password hashing", () => { it("rejects malformed stored hashes instead of throwing", async () => { assert.equal(await verifyPassword("x", "not-a-hash"), false); assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false); - assert.equal( - await verifyPassword( - "x", - `scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}` - ), - false - ); - assert.equal( - await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`), - false - ); }); }); @@ -157,20 +135,12 @@ describe("cookie parsing", () => { }); describe("same-origin guard for writes", () => { - function run(method: string, fetchSite?: string, includeApplicationHeader = true) { + function run(method: string, fetchSite?: string) { let status: number | undefined; let passed = false; const req = { method, - headers: { - ...(fetchSite ? { "sec-fetch-site": fetchSite } : {}), - ...(includeApplicationHeader - ? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE } - : {}), - }, - get(name: string) { - return this.headers[name.toLowerCase() as keyof typeof this.headers]; - }, + headers: fetchSite ? { "sec-fetch-site": fetchSite } : {}, } as unknown as Request; const res = { status(code: number) { @@ -188,13 +158,12 @@ describe("same-origin guard for writes", () => { } it("lets reads and same-origin writes through", () => { - assert.equal(run("GET", "cross-site", false).passed, true); + assert.equal(run("GET", "cross-site").passed, true); assert.equal(run("POST", "same-origin").passed, true); - assert.equal(run("POST").passed, true, "scripts can send the explicit application header"); + assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie"); }); - it("rejects writes without the application header or from another origin", () => { - assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 }); + it("rejects writes from other sites and other ports of the same host", () => { assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 }); assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 }); assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 }); @@ -270,30 +239,6 @@ describe("user service", () => { await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError); }); - it("preserves one active admin across concurrent updates", async () => { - const { repository, service } = createUserService(); - await service.ensureBootstrapAdmin(); - const second = await service.createUser({ - username: "zweiter-admin", - name: "Zweiter Admin", - role: "admin", - initialPassword: "start-passwort", - }); - assert.ok(second); - const [first] = (await repository.list()).filter((user) => user.username === "admin"); - - const results = await Promise.allSettled([ - service.updateRoleAndActive(first.id, { role: "user" }), - service.updateRoleAndActive(second.id, { role: "user" }), - ]); - assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); - assert.equal(results.filter((result) => result.status === "rejected").length, 1); - const activeAdmins = (await repository.list()).filter( - (user) => user.role === "admin" && user.active - ); - assert.equal(activeAdmins.length, 1); - }); - it("requires the current password and a different new one", async () => { const { service } = createUserService(); const user = await service.createUser({ diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts index 475d781..641a770 100644 --- a/tests/circuit-grid-model.test.ts +++ b/tests/circuit-grid-model.test.ts @@ -133,9 +133,7 @@ describe("circuit grid model", () => { assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); - assert.equal(getCellKind("reserveCircuit", "displayName"), "circuitField"); - assert.equal(getCellKind("reserveCircuit", "remark"), "circuitField"); - assert.equal(getCellKind("reserveCircuit", "powerPerUnit"), "deviceField"); + assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField"); assert.equal(getCellKind("placeholder", "displayName"), "deviceField"); assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index 6551aee..1a16913 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -179,10 +179,9 @@ describe("circuit grid projection", () => { ); }); - it("renders one linked downstream board as one compact editable circuit row", () => { + it("renders linked downstream boards as separate read-only rows", () => { const feeder = { ...reserveCircuit, - displayName: undefined, isReserve: false, circuitTotalPower: 4, circuitApparentPower: 5, @@ -203,65 +202,19 @@ describe("circuit grid projection", () => { assert.deepEqual(rows.map((row) => row.rowType), [ "section", "circuitSummary", + "downstreamBoardLoad", "placeholder", ]); - const loadRow = rows[1]; + const loadRow = rows[2]; assert.equal( loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, - "UV-02" - ); - assert.equal( - loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue, - "" + "Unterverteilung UV-02" ); assert.equal( loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, 4 ); - 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); + assert.equal(loadRow.cells.every((cell) => !cell.editable), true); }); it("shows circuit protection once on each visible circuit row", () => { diff --git a/tests/config-access.test.ts b/tests/config-access.test.ts deleted file mode 100644 index 3fdc83d..0000000 --- a/tests/config-access.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - getConfigEntry, - getConfigRedirect, - getConfigTabs, -} from "../src/frontend/utils/config-access.js"; - -describe("config access", () => { - it("offers account settings to every signed-in user", () => { - assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" }); - assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]); - assert.equal(getConfigRedirect("/config", false), "/config/konto"); - assert.equal(getConfigRedirect("/config/konto", false), null); - }); - - it("keeps user administration admin-only", () => { - assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto"); - assert.deepEqual(getConfigTabs(true), [ - { href: "/config/benutzer", label: "Benutzer" }, - { href: "/config/konto", label: "Konto" }, - ]); - assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" }); - assert.equal(getConfigRedirect("/config/benutzer", true), null); - }); -}); diff --git a/tests/distribution-board-feeder-circuit-project-command.repository.test.ts b/tests/distribution-board-feeder-circuit-project-command.repository.test.ts deleted file mode 100644 index d4d2c26..0000000 --- a/tests/distribution-board-feeder-circuit-project-command.repository.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { eq } from "drizzle-orm"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { - createDatabaseContext, - type DatabaseContext, -} from "../src/db/database-context.js"; -import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; -import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; -import { circuits } from "../src/db/schema/circuits.js"; -import { distributionBoards } from "../src/db/schema/distribution-boards.js"; -import { projects } from "../src/db/schema/projects.js"; -import { - assertDistributionBoardFeederCircuitProjectCommand, - createDistributionBoardCreateFeederCircuitProjectCommand, -} from "../src/domain/models/distribution-board-feeder-circuit-project-command.model.js"; -import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js"; -import { - createDistributionBoardInsertProjectCommand, - createDistributionBoardStructureSnapshot, -} from "../src/domain/models/distribution-board-structure-project-command.model.js"; -import { buildCircuitInsertSnapshot } from "../src/frontend/utils/circuit-structure-command.js"; - -function createTestDatabase(): DatabaseContext { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { - migrationsFolder: path.resolve("src", "db", "migrations"), - }); - context.db - .insert(projects) - .values({ id: "project-1", name: "Test project" }) - .run(); - return context; -} - -function setupBoards( - context: DatabaseContext, - repository: DistributionBoardStructureProjectCommandRepository -) { - const source = createDistributionBoardStructureSnapshot( - "project-1", - "NSHV" - ); - const downstream = createDistributionBoardStructureSnapshot( - "project-1", - "UV EG" - ); - repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createDistributionBoardInsertProjectCommand(source), - }); - repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createDistributionBoardInsertProjectCommand(downstream), - }); - const section = source.sections.find( - (candidate) => candidate.key === "three_phase" - )!; - const circuit = { - ...buildCircuitInsertSnapshot({ - id: "feeder-circuit", - circuitListId: source.circuitList.id, - category: "three_phase", - deviceRows: [], - values: { - sectionId: section.id, - equipmentIdentifier: `${section.prefix}1`, - displayName: undefined, - sortOrder: 10, - voltage: 400, - isReserve: true, - }, - }), - protectionDevice: null, - }; - const boardUpdate = createDistributionBoardUpdateProjectCommand( - downstream.distributionBoard.id, - { - feedingDistributionBoardId: source.distributionBoard.id, - feedingCircuitId: circuit.id, - } - ); - const command = - createDistributionBoardCreateFeederCircuitProjectCommand( - circuit, - boardUpdate - ); - return { source, downstream, circuit, command }; -} - -describe("distribution-board feeder-circuit project command", () => { - it("creates and links one feeder atomically and supports undo and redo", () => { - const context = createTestDatabase(); - try { - const repository = - new DistributionBoardStructureProjectCommandRepository(context.db); - const history = new ProjectHistoryRepository(context.db); - const { source, downstream, circuit, command } = setupBoards( - context, - repository - ); - - repository.executeFeederCircuit({ - projectId: "project-1", - expectedRevision: 2, - source: "user", - command, - }); - assert.ok( - context.db - .select() - .from(circuits) - .where(eq(circuits.id, circuit.id)) - .get() - ); - assert.deepEqual( - context.db - .select({ - boardId: distributionBoards.feedingDistributionBoardId, - circuitId: distributionBoards.feedingCircuitId, - }) - .from(distributionBoards) - .where(eq(distributionBoards.id, downstream.distributionBoard.id)) - .get(), - { - boardId: source.distributionBoard.id, - circuitId: circuit.id, - } - ); - - const undo = history.getNextCommand("project-1", "undo"); - assert.ok(undo); - assertDistributionBoardFeederCircuitProjectCommand(undo.command); - repository.executeFeederCircuit({ - projectId: "project-1", - expectedRevision: 3, - source: "undo", - historyTargetChangeSetId: undo.changeSetId, - command: undo.command, - }); - assert.equal( - context.db - .select() - .from(circuits) - .where(eq(circuits.id, circuit.id)) - .get(), - undefined - ); - assert.deepEqual( - context.db - .select({ - boardId: distributionBoards.feedingDistributionBoardId, - circuitId: distributionBoards.feedingCircuitId, - }) - .from(distributionBoards) - .where(eq(distributionBoards.id, downstream.distributionBoard.id)) - .get(), - { boardId: null, circuitId: null } - ); - - const redo = history.getNextCommand("project-1", "redo"); - assert.ok(redo); - assertDistributionBoardFeederCircuitProjectCommand(redo.command); - repository.executeFeederCircuit({ - projectId: "project-1", - expectedRevision: 4, - source: "redo", - historyTargetChangeSetId: redo.changeSetId, - command: redo.command, - }); - assert.equal( - context.db - .select({ equipmentIdentifier: circuits.equipmentIdentifier }) - .from(circuits) - .where(eq(circuits.id, circuit.id)) - .get()?.equipmentIdentifier, - circuit.equipmentIdentifier - ); - } finally { - context.close(); - } - }); - - it("rolls back unlinking when the generated circuit changed before undo", () => { - const context = createTestDatabase(); - try { - const repository = - new DistributionBoardStructureProjectCommandRepository(context.db); - const history = new ProjectHistoryRepository(context.db); - const { downstream, circuit, command } = setupBoards(context, repository); - repository.executeFeederCircuit({ - projectId: "project-1", - expectedRevision: 2, - source: "user", - command, - }); - context.db - .update(circuits) - .set({ displayName: "Außerhalb der Historie geändert" }) - .where(eq(circuits.id, circuit.id)) - .run(); - - const undo = history.getNextCommand("project-1", "undo"); - assert.ok(undo); - assertDistributionBoardFeederCircuitProjectCommand(undo.command); - assert.throws( - () => - repository.executeFeederCircuit({ - projectId: "project-1", - expectedRevision: 3, - source: "undo", - historyTargetChangeSetId: undo.changeSetId, - command: undo.command, - }), - /changed before history removal/ - ); - const board = context.db - .select() - .from(distributionBoards) - .where(eq(distributionBoards.id, downstream.distributionBoard.id)) - .get(); - assert.equal(board?.feedingCircuitId, circuit.id); - assert.ok( - context.db - .select() - .from(circuits) - .where(eq(circuits.id, circuit.id)) - .get() - ); - } finally { - context.close(); - } - }); -}); diff --git a/tests/distribution-board-feeder-circuit.test.ts b/tests/distribution-board-feeder-circuit.test.ts deleted file mode 100644 index fb143da..0000000 --- a/tests/distribution-board-feeder-circuit.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - buildFeederCircuitSnapshot, - getDefaultFeederCircuitSection, - getNextFeederCircuitIdentifier, -} from "../src/frontend/utils/distribution-board-feeder-circuit.js"; -import type { CircuitTreeResponseDto } from "../src/frontend/types.js"; -import { createFeederCircuitAndUpdateDistributionBoard } from "../src/frontend/utils/api.js"; - -function tree(): CircuitTreeResponseDto { - return { - circuitListId: "list-main", - distributionBoardId: "board-main", - distributionBoardName: "NSHV", - currentRevision: 2, - singlePhaseVoltageV: 230, - threePhaseVoltageV: 400, - distributionBoardSimultaneityFactor: 1, - distributionBoardTotalPower: 0, - distributionBoardTotalPowerWithSimultaneityFactor: 0, - headerComponents: [], - footerComponents: [], - sections: [ - { - id: "single", - key: "single_phase", - category: "single_phase", - groupNumber: 1, - displayName: "1-phasig 1", - prefix: "-2F1.", - sortOrder: 10, - sectionTotalPower: 0, - components: [], - circuits: [], - }, - { - id: "three", - key: "three_phase", - category: "three_phase", - groupNumber: 1, - displayName: "3-phasig 1", - prefix: "-3F1.", - sortOrder: 20, - sectionTotalPower: 0, - components: [], - circuits: [ - { - id: "existing-2", - circuitListId: "list-main", - sectionId: "three", - equipmentIdentifier: "-3F1.2", - sortOrder: 10, - isReserve: true, - circuitTotalPower: 0, - circuitApparentPower: 0, - circuitCosPhi: 1, - deviceRows: [], - downstreamBoardLoads: [], - }, - { - id: "existing-5", - circuitListId: "list-main", - sectionId: "three", - equipmentIdentifier: "-3F1.5", - sortOrder: 30, - isReserve: true, - circuitTotalPower: 0, - circuitApparentPower: 0, - circuitCosPhi: 1, - deviceRows: [], - downstreamBoardLoads: [], - }, - ], - }, - ], - }; -} - -describe("distribution-board feeder circuit planning", () => { - it("prefers a three-phase section and does not fill BMK gaps", () => { - const input = tree(); - const section = getDefaultFeederCircuitSection(input)!; - assert.equal(section.id, "three"); - assert.equal(getNextFeederCircuitIdentifier(section), "-3F1.6"); - }); - - it("builds an empty circuit with stable id, optional name and defaults", () => { - const input = tree(); - const snapshot = buildFeederCircuitSnapshot({ - id: "new-feeder", - tree: input, - sectionId: "three", - displayName: " ", - }); - assert.equal(snapshot.id, "new-feeder"); - assert.equal(snapshot.equipmentIdentifier, "-3F1.6"); - assert.equal(snapshot.displayName, null); - assert.equal(snapshot.sortOrder, 40); - assert.equal(snapshot.voltage, 400); - assert.equal(snapshot.isReserve, true); - assert.deepEqual(snapshot.deviceRows, []); - assert.equal(snapshot.protectionDevice, null); - }); - - it("sends circuit creation and board linking as one project command", async () => { - const input = tree(); - const circuit = buildFeederCircuitSnapshot({ - id: "new-feeder", - tree: input, - sectionId: "three", - displayName: "", - }); - const requests: Array<{ url: string; method: string; body: any }> = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (request, init) => { - requests.push({ - url: String(request), - method: init?.method ?? "GET", - body: JSON.parse(String(init?.body)), - }); - return new Response(JSON.stringify({}), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - try { - await createFeederCircuitAndUpdateDistributionBoard( - "project-1", - "board-child", - { - floorId: null, - supplyType: "AV", - simultaneityFactor: 0.8, - feedingDistributionBoardId: "board-main", - feedingCircuitId: circuit.id, - }, - circuit, - 7 - ); - } finally { - globalThis.fetch = originalFetch; - } - assert.equal(requests.length, 1); - assert.equal(requests[0].url, "/api/projects/project-1/commands"); - assert.equal(requests[0].method, "POST"); - assert.equal(requests[0].body.expectedRevision, 7); - assert.equal( - requests[0].body.command.type, - "distribution-board.create-feeder-circuit" - ); - assert.equal(requests[0].body.command.payload.circuit.id, "new-feeder"); - assert.deepEqual( - requests[0].body.command.payload.boardUpdate.payload.changes - .filter((change: { field: string }) => - change.field.startsWith("feeding") - ) - .map((change: { field: string; value: unknown }) => [ - change.field, - change.value, - ]), - [ - ["feedingDistributionBoardId", "board-main"], - ["feedingCircuitId", "new-feeder"], - ] - ); - }); -}); diff --git a/tests/write-request-headers.test.ts b/tests/write-request-headers.test.ts deleted file mode 100644 index c78a277..0000000 --- a/tests/write-request-headers.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { authApi } from "../src/frontend/utils/auth-api.js"; -import { createProject } from "../src/frontend/utils/api.js"; -import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js"; -import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js"; -import { - WRITE_REQUEST_HEADER_NAME, - WRITE_REQUEST_HEADER_VALUE, -} from "../src/shared/constants/request-security.js"; - -describe("write request clients", () => { - it("send the application header from every frontend API client", async () => { - const originalFetch = globalThis.fetch; - const requests: RequestInit[] = []; - globalThis.fetch = async (_input, init) => { - requests.push(init ?? {}); - return new Response(JSON.stringify({}), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - - try { - await authApi.login("admin", "start-passwort"); - await createProject("Testprojekt"); - await calculateCableSizing({} as CableSizingInput); - } finally { - globalThis.fetch = originalFetch; - } - - assert.equal(requests.length, 3); - for (const request of requests) { - assert.equal( - new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME), - WRITE_REQUEST_HEADER_VALUE - ); - } - }); -});