Compare commits

..

6 commits

46 changed files with 1632 additions and 185 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Nur beim ersten Start mit leerer Benutzertabelle erforderlich.
AUTH_BOOTSTRAP_USERNAME=
AUTH_BOOTSTRAP_PASSWORT=
AUTH_SESSION_HOURS=12
AUTH_SESSION_MAX_DAYS=7
# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false.
COOKIE_SECURE=false

3
.gitignore vendored
View file

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

View file

@ -42,7 +42,10 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
(`src/app/layout.tsx`). See `docs/authentication.md` for the full picture.
(`src/app/layout.tsx`). Every unsafe API request also requires the explicit
`X-Leistungsbilanz-Request: 1` application header, including over plain HTTP.
Account settings are available to every signed-in user; user administration
remains admin-only. See `docs/authentication.md` for the full picture.
The supported runtime model is Circuit-First. The former Consumer UI, API,
tables and upgrade tooling are removed; do not reintroduce them.
@ -483,6 +486,17 @@ downstream board's demand after its own board simultaneity factor to the chosen
feeding circuit as a read-only virtual row. Recursive chains and several boards
on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected.
The distribution-board dialog can create one empty feeder circuit and link it
in the same `distribution-board.create-feeder-circuit` project command. Its
history inverse restores the complete previous board update before removing
only the unchanged generated circuit; Redo reuses its UUID and BMK. No
CircuitDeviceRow is created. The three-phase group is suggested, the optional
circuit display name stays empty by default, and protection remains open for
planner sizing. A circuit with exactly one downstream board and no device rows
is rendered as one compact row using the live board number/name; several
downstream boards or additional devices retain summary and child rows. Editing
the name or remark of an empty reserve circuit changes the circuit itself and
does not implicitly create a device row.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.

View file

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

View file

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

View file

@ -47,7 +47,7 @@ services:
LOG_LEVEL: "${LOG_LEVEL:-info}"
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
# .env - siehe docs/AUTH.md.
# .env - siehe docs/authentication.md.
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"

View file

@ -7,10 +7,9 @@ Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich
(Benutzerverwaltung, eigenes Konto)
- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer**
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
Grobbilanz, …), kein Zugriff auf Config
Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung
Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
@ -44,6 +43,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
**Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für
Administratoren.
## Schutzmechanismen
@ -52,12 +53,12 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). |
| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash |
| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung |
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten |
| CSRF über reines HTTP | Browser senden `Sec-Fetch-Site` nur an HTTPS-Adressen. Beim Zugriff per `http://…` im LAN greift deshalb nur SameSite=Lax (schützt gegen fremde Domains, nicht gegen andere Ports desselben Hosts). |
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. |
| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. |
| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel |
| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) |
| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen |
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt |
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. |
| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` |
| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt |
| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben |
@ -77,6 +78,18 @@ ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte
gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
in `compose.yaml` selbst.
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<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)
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen

View file

@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes
- The project-command endpoint additionally accepts
`distribution-board.create-feeder-circuit` with an empty complete circuit
snapshot and an embedded version-2 `distribution-board.update`. Both writes
are one transaction, revision and Undo step. The generated circuit has no
device row; Redo keeps its UUID and BMK. The history-only inverse
`distribution-board.remove-feeder-circuit` restores the previous board values
and removes only the unchanged circuit.
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs
@ -346,7 +353,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included. Circuit totals contain the demand of linked downstream
boards. `downstreamBoardLoads` exposes those contributions as read-only rows;
boards. `downstreamBoardLoads` exposes those contributions as read-only
values; the grid combines exactly one downstream board without device rows
into the circuit row and keeps child rows for mixed or multiple loads.
`feedingReference` resolves the current board/circuit labels by id.
Response sketch:

View file

@ -33,6 +33,15 @@ mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
Datenbank liegt über einen Host-Mount außerhalb des Containers.
Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus
`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar.
Schreibzugriffe verlangen zusätzlich den Anwendungsheader
`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN
ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren
vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu
Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher
des einzelnen API-Prozesses und enden bei dessen Neustart.
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
@ -57,6 +66,11 @@ vor dem Command und bleiben über Undo/Redo unverändert.
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung
und erzwungener Wechsel eines Startpassworts
- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz
- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` –
kontobezogene Regeln und schmale Persistenzgrenze
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
@ -477,6 +491,21 @@ Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt.
Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein
neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige
Gruppe und das nächste BMK vor. Der Befehl
`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und
wendet die vollständige Verteilungsänderung in derselben Transaktion und
Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst
offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo
stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach
nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut.
Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das
Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten
Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen
Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren
Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine
Gerätezeile.
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten

View file

@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
deshalb nicht öffentlich erreichbar gemacht werden.
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
## Topologie
@ -29,9 +29,10 @@ deshalb nicht öffentlich erreichbar gemacht werden.
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht
(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive
Session; Schreibzugriffe zusätzlich den Anwendungsheader
`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
@ -72,6 +73,26 @@ Verwendete Umgebungsvariablen:
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`.
- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei
leerer Benutzertabelle den ersten Administrator; das Startpasswort hat
mindestens 12 Zeichen.
- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden.
- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage.
- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN
`false`, da Browser das Cookie sonst nicht speichern.
Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen,
nicht versionierten `.env` neben `compose.yaml` gesetzt werden:
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<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
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
@ -137,7 +158,8 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
Vor einer produktiven Installation werden mindestens benötigt:
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei
festen Rollen später nicht ausreichen
- definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout

View file

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

View file

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

View file

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

View file

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

View file

@ -47,6 +47,7 @@ import {
import {
copyGlobalDeviceToProject,
copyDistributionBoard,
createFeederCircuitAndUpdateDistributionBoard,
copyProjectDeviceToGlobal,
createDistributionBoard,
createProjectDevice,
@ -71,6 +72,12 @@ import {
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
import { createClientId } from "../../../frontend/utils/client-id";
import {
buildFeederCircuitSnapshot,
getDefaultFeederCircuitSection,
getNextFeederCircuitIdentifier,
} from "../../../frontend/utils/distribution-board-feeder-circuit";
import type {
BuildingDto,
CircuitListDto,
@ -146,6 +153,7 @@ const boardFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices";
const createFeederCircuitSelectValue = "__create_feeder_circuit__";
export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>();
@ -175,6 +183,12 @@ export default function ProjectDetailPage() {
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [isCreatingFeederCircuit, setIsCreatingFeederCircuit] =
useState(false);
const [editingFeederCircuitSectionId, setEditingFeederCircuitSectionId] =
useState("");
const [editingFeederCircuitDisplayName, setEditingFeederCircuitDisplayName] =
useState("");
const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({
@ -350,6 +364,21 @@ export default function ProjectDetailPage() {
const selectedFeedingCircuit = selectedFeedingCircuits.find(
({ 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(
(board) => board.id === editingFeedingBoardId
);
@ -556,6 +585,9 @@ export default function ProjectDetailPage() {
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId("");
setEditingFeederCircuitDisplayName("");
}
function openBoardCreator() {
@ -584,28 +616,56 @@ export default function ProjectDetailPage() {
setIsSaving(true);
setError(null);
try {
const result = await updateDistributionBoard(
projectId,
editingBoard.id,
{
const boardInput = {
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...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
);
setBoards((current) =>
current.map((board) =>
board.id === result.distributionBoard.id
? result.distributionBoard
: board
)
} else {
result = await updateDistributionBoard(
projectId,
editingBoard.id,
boardInput,
project.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);
} catch (err) {
setError(
@ -1567,8 +1627,16 @@ export default function ProjectDetailPage() {
className="form-select"
id="feeding-distribution-board"
onChange={(event) => {
setEditingFeedingBoardId(event.target.value);
const boardId = event.target.value;
setEditingFeedingBoardId(boardId);
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId(
getDefaultFeederCircuitSection(
circuitTreeByBoardId.get(boardId)
)?.id ?? ""
);
setEditingFeederCircuitDisplayName("");
}}
value={editingFeedingBoardId}
>
@ -1588,10 +1656,31 @@ export default function ProjectDetailPage() {
className="form-select"
disabled={!editingFeedingBoardId}
id="feeding-circuit"
onChange={(event) => setEditingFeedingCircuitId(event.target.value)}
value={editingFeedingCircuitId}
onChange={(event) => {
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={createFeederCircuitSelectValue}>
+ Neuen Einspeiseabgang anlegen …
</option>
{selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier}
@ -1600,7 +1689,62 @@ export default function ProjectDetailPage() {
))}
</select>
</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="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
@ -1618,8 +1762,8 @@ export default function ProjectDetailPage() {
</div>
</div>
) : null}
{selectedFeedingCircuit &&
selectedFeedingCircuit.section.category !== "three_phase" ? (
{selectedFeedingCircuitCategory !== undefined &&
selectedFeedingCircuitCategory !== "three_phase" ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese

View file

@ -40,7 +40,7 @@ export class CircuitStructureProjectCommandRepository
this.database,
input,
(tx) =>
this.applyCommand(
this.applyInTransaction(
tx,
input.projectId,
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,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],

View file

@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { isDeepStrictEqual } from "node:util";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@ -17,6 +18,20 @@ import {
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} 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 {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@ -31,6 +46,7 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository
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(
database: AppDatabase,
projectId: string,
@ -367,6 +407,64 @@ export class DistributionBoardStructureProjectCommandRepository
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(
database: AppDatabase,
projectId: string,

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,40 @@
import type { UserRole } from "../../shared/constants/user-roles.js";
export interface UserAccount {
id: string;
username: string;
passwordHash: string;
name: string;
role: UserRole;
active: boolean;
mustChangePassword: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserAccount {
username: string;
name: string;
role: UserRole;
passwordHash: string;
}
export class UsernameAlreadyExistsStoreError extends Error {}
export class LastActiveAdminStoreError extends Error {}
export interface UserStore {
list(): Promise<UserAccount[]>;
count(): Promise<number>;
findById(userId: string): Promise<UserAccount | null>;
findByUsername(username: string): Promise<UserAccount | null>;
create(input: CreateUserAccount): Promise<UserAccount | null>;
updateRoleAndActive(
userId: string,
patch: { role?: UserRole; active?: boolean }
): Promise<UserAccount | null>;
setPassword(
userId: string,
passwordHash: string,
mustChangePassword: boolean
): Promise<UserAccount | null>;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</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) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;

View file

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

View file

@ -1,3 +1,8 @@
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../../shared/constants/request-security";
// Small, self-contained fetch helper for the auth/user endpoints - kept
// separate from the large domain api.ts so this feature doesn't have to
// touch that file's shared `request()` internals.
@ -39,7 +44,11 @@ export class AuthApiError extends Error {
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
headers: {
"Content-Type": "application/json",
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
...init?.headers,
},
cache: "no-store",
});
if (!response.ok) {

View file

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

View file

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

View file

@ -0,0 +1,25 @@
export interface ConfigNavigationEntry {
href: string;
label: string;
}
const ACCOUNT_ENTRY = { href: "/config/konto", label: "Konto" } as const;
const USERS_ENTRY = { href: "/config/benutzer", label: "Benutzer" } as const;
export function getConfigTabs(isAdmin: boolean): ConfigNavigationEntry[] {
return isAdmin ? [USERS_ENTRY, ACCOUNT_ENTRY] : [ACCOUNT_ENTRY];
}
export function getConfigEntry(isAdmin: boolean): ConfigNavigationEntry {
return isAdmin ? { href: USERS_ENTRY.href, label: "Config" } : ACCOUNT_ENTRY;
}
export function getConfigRedirect(pathname: string | null, isAdmin: boolean): string | null {
if (pathname === "/config") {
return isAdmin ? USERS_ENTRY.href : ACCOUNT_ENTRY.href;
}
if (!isAdmin && pathname?.startsWith(USERS_ENTRY.href)) {
return ACCOUNT_ENTRY.href;
}
return null;
}

View file

@ -0,0 +1,68 @@
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
import type {
CircuitTreeResponseDto,
CircuitTreeSectionDto,
} from "../types";
import { buildCircuitInsertSnapshot } from "./circuit-structure-command";
export function getDefaultFeederCircuitSection(
tree: CircuitTreeResponseDto | undefined
) {
if (!tree) return undefined;
return (
tree.sections.find((section) => section.category === "three_phase") ??
tree.sections.find((section) => section.category !== undefined)
);
}
export function getNextFeederCircuitIdentifier(
section: CircuitTreeSectionDto
) {
let highestSuffix = 0;
for (const circuit of section.circuits) {
if (!circuit.equipmentIdentifier.startsWith(section.prefix)) continue;
const suffix = circuit.equipmentIdentifier.slice(section.prefix.length);
if (!/^\d+$/.test(suffix)) continue;
highestSuffix = Math.max(highestSuffix, Number(suffix));
}
return `${section.prefix}${highestSuffix + 1}`;
}
export function buildFeederCircuitSnapshot(input: {
id: string;
tree: CircuitTreeResponseDto;
sectionId: string;
displayName: string;
}): CircuitSnapshot {
const section = input.tree.sections.find(
(candidate) => candidate.id === input.sectionId
);
if (!section || !section.category) {
throw new Error("Die gewählte Stromkreisgruppe ist nicht verfügbar.");
}
const snapshot = buildCircuitInsertSnapshot({
id: input.id,
circuitListId: input.tree.circuitListId,
category: section.category,
deviceRows: [],
values: {
sectionId: section.id,
equipmentIdentifier: getNextFeederCircuitIdentifier(section),
displayName: input.displayName.trim() || undefined,
sortOrder:
section.circuits.length === 0
? 10
: Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) +
10,
voltage:
section.category === "three_phase"
? input.tree.threePhaseVoltageV
: input.tree.singlePhaseVoltageV,
isReserve: true,
},
});
// A distribution-board feeder is not a generic final circuit. Its protection
// must be selected from the calculated load and cable, so no 16 A group
// default is persisted here.
return { ...snapshot, protectionDevice: null };
}

View file

@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"project.update-settings": "Projekteinstellungen bearbeitet",
"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",

View file

@ -2,8 +2,12 @@ import type { NextFunction, Request, RequestHandler, Response } from "express";
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
import { readCookie } from "./cookie.js";
import type { SessionStore } from "./sessions.js";
import type { UserRepository } from "../../db/repositories/user.repository.js";
import type { UserStore } from "../../domain/ports/user.store.js";
import type { UserRole } from "../../shared/constants/user-roles.js";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../../shared/constants/request-security.js";
export interface AuthUser {
id: string;
@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: {
export async function identityFromRequest(
req: { headers: { cookie?: string | undefined } },
sessions: SessionStore,
users: UserRepository,
users: Pick<UserStore, "findById">,
slideExpiry = true
): Promise<AuthUser | undefined> {
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
@ -74,10 +78,11 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
* CSRF defense for cookie-authenticated writes.
*
* SameSite=Lax alone is not enough here: every other service on the same
* host (other ports of the same IP) counts as "same-site" and would get the
* cookie attached. Browsers send Sec-Fetch-Site on every request, so a write
* is only accepted from the app's own origin. Clients without the header
* (curl, scripts) carry no ambient browser cookie and are not a CSRF vector.
* host (other ports of the same IP) counts as "same-site" and may receive the
* cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN.
* Every write therefore needs an application header that HTML forms cannot
* set; a cross-origin fetch would require a CORS preflight, which the API does
* not permit. Sec-Fetch-Site remains an additional browser-side check.
*/
export function requireSameOriginForWrites(): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler {
next();
return;
}
if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) {
res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." });
return;
}
const fetchSite = req.headers["sec-fetch-site"];
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
next();
@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler {
export function createAuthGuard(
authConfig: AuthConfig,
sessions: SessionStore,
users: UserRepository,
users: Pick<UserStore, "findById">,
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {

View file

@ -4,6 +4,12 @@ const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LENGTH = 64;
const SALT_LENGTH = 16;
const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024;
const MIN_SCRYPT_N = 1 << 14;
const MAX_SCRYPT_N = 1 << 16;
const MAX_SCRYPT_R = 16;
const MAX_SCRYPT_P = 8;
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
@ -30,8 +36,13 @@ function scryptAsync(
// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
// hash so they can be tuned later without invalidating existing rows.
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
const salt = randomBytes(SALT_LENGTH);
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: MAX_SCRYPT_MEMORY,
});
return [
"scrypt",
SCRYPT_N,
@ -52,12 +63,33 @@ export async function verifyPassword(
const n = Number(nRaw);
const r = Number(rRaw);
const p = Number(pRaw);
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
if (
!Number.isSafeInteger(n) ||
n < MIN_SCRYPT_N ||
n > MAX_SCRYPT_N ||
(n & (n - 1)) !== 0 ||
!Number.isSafeInteger(r) ||
r < 1 ||
r > MAX_SCRYPT_R ||
!Number.isSafeInteger(p) ||
p < 1 ||
p > MAX_SCRYPT_P ||
!/^[0-9a-f]{32}$/i.test(saltHex) ||
!/^[0-9a-f]{128}$/i.test(hashHex)
) {
return false;
}
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p });
if (derivedKey.length !== expected.length) return false;
try {
const derivedKey = await scryptAsync(password, salt, expected.length, {
N: n,
r,
p,
maxmem: MAX_SCRYPT_MEMORY,
});
return timingSafeEqual(derivedKey, expected);
} catch {
return false;
}
}

View file

@ -3,9 +3,14 @@ import { LoginThrottle } from "../auth/login-throttle.js";
import { SessionStore } from "../auth/sessions.js";
import { UserService } from "../../domain/services/user.service.js";
import { userRepository } from "./application-repositories.js";
import { hashPassword, verifyPassword } from "../auth/password.js";
export const authConfig = readAuthConfig();
export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
sessionStore.startSweeping();
export const loginThrottle = new LoginThrottle();
export const userService = new UserService(userRepository, authConfig);
export const userService = new UserService(
userRepository,
{ bootstrap: authConfig.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
);

View file

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

View file

@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j
import type { AuthConfig } from "../src/server/auth/auth-config.js";
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
import { loginSchema } from "../src/shared/validation/auth.schemas.js";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../src/shared/constants/request-security.js";
const HOUR = 60 * 60 * 1000;
@ -34,7 +38,14 @@ function createUserService() {
cookieSecure: false,
bootstrap: { username: "admin", password: "bootstrap-password-123" },
};
return { repository, service: new UserService(repository, config) };
return {
repository,
service: new UserService(
repository,
{ bootstrap: config.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
),
};
}
describe("password hashing", () => {
@ -52,6 +63,17 @@ describe("password hashing", () => {
it("rejects malformed stored hashes instead of throwing", async () => {
assert.equal(await verifyPassword("x", "not-a-hash"), false);
assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false);
assert.equal(
await verifyPassword(
"x",
`scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}`
),
false
);
assert.equal(
await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`),
false
);
});
});
@ -135,12 +157,20 @@ describe("cookie parsing", () => {
});
describe("same-origin guard for writes", () => {
function run(method: string, fetchSite?: string) {
function run(method: string, fetchSite?: string, includeApplicationHeader = true) {
let status: number | undefined;
let passed = false;
const req = {
method,
headers: fetchSite ? { "sec-fetch-site": fetchSite } : {},
headers: {
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
...(includeApplicationHeader
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
: {}),
},
get(name: string) {
return this.headers[name.toLowerCase() as keyof typeof this.headers];
},
} as unknown as Request;
const res = {
status(code: number) {
@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => {
}
it("lets reads and same-origin writes through", () => {
assert.equal(run("GET", "cross-site").passed, true);
assert.equal(run("GET", "cross-site", false).passed, true);
assert.equal(run("POST", "same-origin").passed, true);
assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie");
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
});
it("rejects writes from other sites and other ports of the same host", () => {
it("rejects writes without the application header or from another origin", () => {
assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 });
assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 });
assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 });
assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 });
@ -239,6 +270,30 @@ describe("user service", () => {
await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError);
});
it("preserves one active admin across concurrent updates", async () => {
const { repository, service } = createUserService();
await service.ensureBootstrapAdmin();
const second = await service.createUser({
username: "zweiter-admin",
name: "Zweiter Admin",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(second);
const [first] = (await repository.list()).filter((user) => user.username === "admin");
const results = await Promise.allSettled([
service.updateRoleAndActive(first.id, { role: "user" }),
service.updateRoleAndActive(second.id, { role: "user" }),
]);
assert.equal(results.filter((result) => result.status === "fulfilled").length, 1);
assert.equal(results.filter((result) => result.status === "rejected").length, 1);
const activeAdmins = (await repository.list()).filter(
(user) => user.role === "admin" && user.active
);
assert.equal(activeAdmins.length, 1);
});
it("requires the current password and a different new one", async () => {
const { service } = createUserService();
const user = await service.createUser({

View file

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

View file

@ -179,9 +179,10 @@ describe("circuit grid projection", () => {
);
});
it("renders linked downstream boards as separate read-only rows", () => {
it("renders one linked downstream board as one compact editable circuit row", () => {
const feeder = {
...reserveCircuit,
displayName: undefined,
isReserve: false,
circuitTotalPower: 4,
circuitApparentPower: 5,
@ -202,19 +203,65 @@ describe("circuit grid projection", () => {
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitSummary",
"downstreamBoardLoad",
"placeholder",
]);
const loadRow = rows[2];
const loadRow = rows[1];
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value,
"Unterverteilung UV-02"
"UV-02"
);
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue,
""
);
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
4
);
assert.equal(loadRow.cells.every((cell) => !cell.editable), true);
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.kind,
"circuitField"
);
});
it("keeps separate read-only rows for several linked downstream boards", () => {
const feeder = {
...reserveCircuit,
isReserve: false,
circuitTotalPower: 6,
circuitApparentPower: 7,
circuitCosPhi: 6 / 7,
downstreamBoardLoads: [
{
distributionBoardId: "board-a",
name: "Unterverteilung A",
boardNumber: "UV-A",
activePowerKw: 4,
apparentPowerKva: 5,
cosPhi: 0.8,
},
{
distributionBoardId: "board-b",
name: "Unterverteilung B",
boardNumber: "UV-B",
activePowerKw: 2,
apparentPowerKva: 2,
cosPhi: 1,
},
],
};
const rows = buildVisibleGridRowsWithStructure(
[{ ...sections[0], circuits: [feeder] }],
{ headerComponents: [], footerComponents: [] }
);
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitSummary",
"downstreamBoardLoad",
"downstreamBoardLoad",
"placeholder",
]);
assert.equal(rows[2].cells.every((cell) => !cell.editable), true);
});
it("shows circuit protection once on each visible circuit row", () => {

View file

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getConfigEntry,
getConfigRedirect,
getConfigTabs,
} from "../src/frontend/utils/config-access.js";
describe("config access", () => {
it("offers account settings to every signed-in user", () => {
assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" });
assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]);
assert.equal(getConfigRedirect("/config", false), "/config/konto");
assert.equal(getConfigRedirect("/config/konto", false), null);
});
it("keeps user administration admin-only", () => {
assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto");
assert.deepEqual(getConfigTabs(true), [
{ href: "/config/benutzer", label: "Benutzer" },
{ href: "/config/konto", label: "Konto" },
]);
assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" });
assert.equal(getConfigRedirect("/config/benutzer", true), null);
});
});

View file

@ -0,0 +1,240 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projects } from "../src/db/schema/projects.js";
import {
assertDistributionBoardFeederCircuitProjectCommand,
createDistributionBoardCreateFeederCircuitProjectCommand,
} from "../src/domain/models/distribution-board-feeder-circuit-project-command.model.js";
import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import { buildCircuitInsertSnapshot } from "../src/frontend/utils/circuit-structure-command.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values({ id: "project-1", name: "Test project" })
.run();
return context;
}
function setupBoards(
context: DatabaseContext,
repository: DistributionBoardStructureProjectCommandRepository
) {
const source = createDistributionBoardStructureSnapshot(
"project-1",
"NSHV"
);
const downstream = createDistributionBoardStructureSnapshot(
"project-1",
"UV EG"
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(source),
});
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createDistributionBoardInsertProjectCommand(downstream),
});
const section = source.sections.find(
(candidate) => candidate.key === "three_phase"
)!;
const circuit = {
...buildCircuitInsertSnapshot({
id: "feeder-circuit",
circuitListId: source.circuitList.id,
category: "three_phase",
deviceRows: [],
values: {
sectionId: section.id,
equipmentIdentifier: `${section.prefix}1`,
displayName: undefined,
sortOrder: 10,
voltage: 400,
isReserve: true,
},
}),
protectionDevice: null,
};
const boardUpdate = createDistributionBoardUpdateProjectCommand(
downstream.distributionBoard.id,
{
feedingDistributionBoardId: source.distributionBoard.id,
feedingCircuitId: circuit.id,
}
);
const command =
createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
boardUpdate
);
return { source, downstream, circuit, command };
}
describe("distribution-board feeder-circuit project command", () => {
it("creates and links one feeder atomically and supports undo and redo", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
const { source, downstream, circuit, command } = setupBoards(
context,
repository
);
repository.executeFeederCircuit({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command,
});
assert.ok(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuit.id))
.get()
);
assert.deepEqual(
context.db
.select({
boardId: distributionBoards.feedingDistributionBoardId,
circuitId: distributionBoards.feedingCircuitId,
})
.from(distributionBoards)
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
.get(),
{
boardId: source.distributionBoard.id,
circuitId: circuit.id,
}
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
assertDistributionBoardFeederCircuitProjectCommand(undo.command);
repository.executeFeederCircuit({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: undo.command,
});
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuit.id))
.get(),
undefined
);
assert.deepEqual(
context.db
.select({
boardId: distributionBoards.feedingDistributionBoardId,
circuitId: distributionBoards.feedingCircuitId,
})
.from(distributionBoards)
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
.get(),
{ boardId: null, circuitId: null }
);
const redo = history.getNextCommand("project-1", "redo");
assert.ok(redo);
assertDistributionBoardFeederCircuitProjectCommand(redo.command);
repository.executeFeederCircuit({
projectId: "project-1",
expectedRevision: 4,
source: "redo",
historyTargetChangeSetId: redo.changeSetId,
command: redo.command,
});
assert.equal(
context.db
.select({ equipmentIdentifier: circuits.equipmentIdentifier })
.from(circuits)
.where(eq(circuits.id, circuit.id))
.get()?.equipmentIdentifier,
circuit.equipmentIdentifier
);
} finally {
context.close();
}
});
it("rolls back unlinking when the generated circuit changed before undo", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(context.db);
const history = new ProjectHistoryRepository(context.db);
const { downstream, circuit, command } = setupBoards(context, repository);
repository.executeFeederCircuit({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command,
});
context.db
.update(circuits)
.set({ displayName: "Außerhalb der Historie geändert" })
.where(eq(circuits.id, circuit.id))
.run();
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
assertDistributionBoardFeederCircuitProjectCommand(undo.command);
assert.throws(
() =>
repository.executeFeederCircuit({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: undo.command,
}),
/changed before history removal/
);
const board = context.db
.select()
.from(distributionBoards)
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
.get();
assert.equal(board?.feedingCircuitId, circuit.id);
assert.ok(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuit.id))
.get()
);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildFeederCircuitSnapshot,
getDefaultFeederCircuitSection,
getNextFeederCircuitIdentifier,
} from "../src/frontend/utils/distribution-board-feeder-circuit.js";
import type { CircuitTreeResponseDto } from "../src/frontend/types.js";
import { createFeederCircuitAndUpdateDistributionBoard } from "../src/frontend/utils/api.js";
function tree(): CircuitTreeResponseDto {
return {
circuitListId: "list-main",
distributionBoardId: "board-main",
distributionBoardName: "NSHV",
currentRevision: 2,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
distributionBoardSimultaneityFactor: 1,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
footerComponents: [],
sections: [
{
id: "single",
key: "single_phase",
category: "single_phase",
groupNumber: 1,
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 10,
sectionTotalPower: 0,
components: [],
circuits: [],
},
{
id: "three",
key: "three_phase",
category: "three_phase",
groupNumber: 1,
displayName: "3-phasig 1",
prefix: "-3F1.",
sortOrder: 20,
sectionTotalPower: 0,
components: [],
circuits: [
{
id: "existing-2",
circuitListId: "list-main",
sectionId: "three",
equipmentIdentifier: "-3F1.2",
sortOrder: 10,
isReserve: true,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [],
downstreamBoardLoads: [],
},
{
id: "existing-5",
circuitListId: "list-main",
sectionId: "three",
equipmentIdentifier: "-3F1.5",
sortOrder: 30,
isReserve: true,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [],
downstreamBoardLoads: [],
},
],
},
],
};
}
describe("distribution-board feeder circuit planning", () => {
it("prefers a three-phase section and does not fill BMK gaps", () => {
const input = tree();
const section = getDefaultFeederCircuitSection(input)!;
assert.equal(section.id, "three");
assert.equal(getNextFeederCircuitIdentifier(section), "-3F1.6");
});
it("builds an empty circuit with stable id, optional name and defaults", () => {
const input = tree();
const snapshot = buildFeederCircuitSnapshot({
id: "new-feeder",
tree: input,
sectionId: "three",
displayName: " ",
});
assert.equal(snapshot.id, "new-feeder");
assert.equal(snapshot.equipmentIdentifier, "-3F1.6");
assert.equal(snapshot.displayName, null);
assert.equal(snapshot.sortOrder, 40);
assert.equal(snapshot.voltage, 400);
assert.equal(snapshot.isReserve, true);
assert.deepEqual(snapshot.deviceRows, []);
assert.equal(snapshot.protectionDevice, null);
});
it("sends circuit creation and board linking as one project command", async () => {
const input = tree();
const circuit = buildFeederCircuitSnapshot({
id: "new-feeder",
tree: input,
sectionId: "three",
displayName: "",
});
const requests: Array<{ url: string; method: string; body: any }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (request, init) => {
requests.push({
url: String(request),
method: init?.method ?? "GET",
body: JSON.parse(String(init?.body)),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await createFeederCircuitAndUpdateDistributionBoard(
"project-1",
"board-child",
{
floorId: null,
supplyType: "AV",
simultaneityFactor: 0.8,
feedingDistributionBoardId: "board-main",
feedingCircuitId: circuit.id,
},
circuit,
7
);
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(requests.length, 1);
assert.equal(requests[0].url, "/api/projects/project-1/commands");
assert.equal(requests[0].method, "POST");
assert.equal(requests[0].body.expectedRevision, 7);
assert.equal(
requests[0].body.command.type,
"distribution-board.create-feeder-circuit"
);
assert.equal(requests[0].body.command.payload.circuit.id, "new-feeder");
assert.deepEqual(
requests[0].body.command.payload.boardUpdate.payload.changes
.filter((change: { field: string }) =>
change.field.startsWith("feeding")
)
.map((change: { field: string; value: unknown }) => [
change.field,
change.value,
]),
[
["feedingDistributionBoardId", "board-main"],
["feedingCircuitId", "new-feeder"],
]
);
});
});

View file

@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { authApi } from "../src/frontend/utils/auth-api.js";
import { createProject } from "../src/frontend/utils/api.js";
import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js";
import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../src/shared/constants/request-security.js";
describe("write request clients", () => {
it("send the application header from every frontend API client", async () => {
const originalFetch = globalThis.fetch;
const requests: RequestInit[] = [];
globalThis.fetch = async (_input, init) => {
requests.push(init ?? {});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await authApi.login("admin", "start-passwort");
await createProject("Testprojekt");
await calculateCableSizing({} as CableSizingInput);
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(requests.length, 3);
for (const request of requests) {
assert.equal(
new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME),
WRITE_REQUEST_HEADER_VALUE
);
}
});
});