Compare commits
No commits in common. "68d986ff80fa64ffaa0706981545193db411983c" and "0a37783635c0622bc075317f0b818ac852f24a24" have entirely different histories.
68d986ff80
...
0a37783635
53 changed files with 86 additions and 6019 deletions
|
|
@ -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
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,9 +1,6 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
||||||
/drizzle.config.js
|
/drizzle.config.js
|
||||||
data/*.db
|
data/*.db
|
||||||
|
|
|
||||||
15
AGENTS.md
15
AGENTS.md
|
|
@ -36,16 +36,6 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||||
repositories expose only active reads. Runtime writes belong in typed command
|
repositories expose only active reads. Runtime writes belong in typed command
|
||||||
repositories; direct integration fixtures belong under `tests/support`.
|
repositories; direct integration fixtures belong under `tests/support`.
|
||||||
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
|
|
||||||
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
|
|
||||||
middleware), `src/domain/services/user.service.ts`,
|
|
||||||
`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.
|
|
||||||
|
|
||||||
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
||||||
tables and upgrade tooling are removed; do not reintroduce them.
|
tables and upgrade tooling are removed; do not reintroduce them.
|
||||||
|
|
@ -742,11 +732,6 @@ for the early-phase power balance by DIN 276 cost group (see
|
||||||
- second/replacement board feeds, including changeover semantics for SV/SiBe
|
- second/replacement board feeds, including changeover semantics for SV/SiBe
|
||||||
- Revit prefill proposals for logical board feeds through the Phase 14 preview
|
- Revit prefill proposals for logical board feeds through the Phase 14 preview
|
||||||
and explicit-confirmation workflow
|
and explicit-confirmation workflow
|
||||||
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
|
|
||||||
- permanent account lockout (only temporary per-username login throttling
|
|
||||||
exists)
|
|
||||||
- email/one-time-link password provisioning, OIDC/Entra login
|
|
||||||
- linking user accounts to a personnel/employee master table
|
|
||||||
|
|
||||||
Do not implement these while working on an unrelated phase.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
21
README.md
21
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
|
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
||||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||||
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
|
||||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
|
||||||
vorhanden. Revit-CSV-Konfiguration, Vorschau
|
|
||||||
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
||||||
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere
|
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere
|
||||||
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import
|
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import
|
||||||
|
|
@ -62,8 +60,6 @@ Voraussetzungen:
|
||||||
```powershell
|
```powershell
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
Set-Location leistungsbilanz-ts
|
Set-Location leistungsbilanz-ts
|
||||||
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
|
||||||
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -72,15 +68,9 @@ Danach:
|
||||||
- Frontend: <http://localhost:3001>
|
- Frontend: <http://localhost:3001>
|
||||||
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
||||||
|
|
||||||
Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator,
|
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine
|
||||||
solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für
|
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der
|
||||||
einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte
|
Zugriff läuft über den Rewrite im Web-Dienst.
|
||||||
`.env` neben `compose.yaml`; Details stehen unter
|
|
||||||
[Anmeldung und Benutzerverwaltung](docs/authentication.md).
|
|
||||||
|
|
||||||
Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen
|
|
||||||
API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den
|
|
||||||
Rewrite im Web-Dienst.
|
|
||||||
|
|
||||||
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
||||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||||
|
|
@ -126,8 +116,6 @@ Voraussetzungen:
|
||||||
npm ci
|
npm ci
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:verify:circuit-schema
|
npm run db:verify:circuit-schema
|
||||||
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
|
||||||
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
|
||||||
npm run dev:api
|
npm run dev:api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -174,7 +162,6 @@ vor der Baseline werden nicht unterstützt.
|
||||||
- [Gebäudestruktur und Raumflächen](docs/building-structure.md)
|
- [Gebäudestruktur und Raumflächen](docs/building-structure.md)
|
||||||
- [Grobe Leistungsbilanz](docs/estimate-balance.md)
|
- [Grobe Leistungsbilanz](docs/estimate-balance.md)
|
||||||
- [Leitungsberechnung](docs/cable-sizing-module.md)
|
- [Leitungsberechnung](docs/cable-sizing-module.md)
|
||||||
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
|
|
||||||
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
||||||
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
||||||
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,8 @@ services:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
|
||||||
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
# nicht aus dem LAN erreichbar sein.
|
||||||
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:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||||
|
|
|
||||||
12
compose.yaml
12
compose.yaml
|
|
@ -45,16 +45,8 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
|
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
|
||||||
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
|
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
|
||||||
# .env - siehe docs/authentication.md.
|
|
||||||
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}"
|
|
||||||
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
|
|
||||||
# Next.js-Rewrite im Web-Dienst erreicht.
|
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||||
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
||||||
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
||||||
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
|
|
||||||
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
|
|
||||||
|
|
||||||
## Entwicklung und Betrieb
|
## Entwicklung und Betrieb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
# Anmeldung und Benutzerverwaltung
|
|
||||||
|
|
||||||
Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder
|
|
||||||
Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
|
|
||||||
|
|
||||||
## Rollen
|
|
||||||
|
|
||||||
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
|
|
||||||
|
|
||||||
- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer**
|
|
||||||
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Ablauf beim Anlegen eines Zugangs
|
|
||||||
|
|
||||||
1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit
|
|
||||||
Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst
|
|
||||||
eintippt.
|
|
||||||
2. Der Zugang startet mit `mustChangePassword = true`.
|
|
||||||
3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das
|
|
||||||
ein eigenes Passwort verlangt, bevor es weitergeht.
|
|
||||||
4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues
|
|
||||||
Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn
|
|
||||||
jemand sein Passwort vergessen hat.
|
|
||||||
|
|
||||||
## Technischer Aufbau
|
|
||||||
|
|
||||||
- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) –
|
|
||||||
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
|
|
||||||
`role`, `active`, `mustChangePassword`.
|
|
||||||
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
|
|
||||||
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit
|
|
||||||
absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein
|
|
||||||
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
|
|
||||||
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
|
|
||||||
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
|
|
||||||
(außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und
|
|
||||||
erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist.
|
|
||||||
`/api/users/*` verlangt zusätzlich die Rolle `admin`.
|
|
||||||
- **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
|
|
||||||
|
|
||||||
| Angriff | Schutz |
|
|
||||||
|---|---|
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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 |
|
|
||||||
| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) |
|
|
||||||
| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt |
|
|
||||||
|
|
||||||
**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im
|
|
||||||
Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen
|
|
||||||
Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen.
|
|
||||||
|
|
||||||
## Bootstrap-Admin
|
|
||||||
|
|
||||||
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
|
|
||||||
`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die
|
|
||||||
`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen
|
|
||||||
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
|
|
||||||
- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich
|
|
||||||
begrenzte Drosselung, siehe oben)
|
|
||||||
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
|
|
||||||
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle
|
|
||||||
|
|
@ -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
|
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
|
||||||
Datenbank liegt über einen Host-Mount außerhalb des Containers.
|
Datenbank liegt über einen Host-Mount außerhalb des Containers.
|
||||||
|
|
||||||
Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus
|
|
||||||
`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar.
|
|
||||||
Schreibzugriffe verlangen zusätzlich den Anwendungsheader
|
|
||||||
`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN
|
|
||||||
ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren
|
|
||||||
vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu
|
|
||||||
Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher
|
|
||||||
des einzelnen API-Prozesses und enden bei dessen Neustart.
|
|
||||||
|
|
||||||
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
|
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
|
||||||
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
||||||
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
||||||
|
|
@ -66,11 +57,6 @@ vor dem Command und bleiben über Undo/Redo unverändert.
|
||||||
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
||||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||||
- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung
|
|
||||||
und erzwungener Wechsel eines Startpassworts
|
|
||||||
- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz
|
|
||||||
- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` –
|
|
||||||
kontobezogene Regeln und schmale Persistenzgrenze
|
|
||||||
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
||||||
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
||||||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||||
Entwicklungsrechner.
|
Entwicklungsrechner.
|
||||||
|
|
||||||
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
|
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
||||||
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
|
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
||||||
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
|
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
||||||
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
|
deshalb nicht öffentlich erreichbar gemacht werden.
|
||||||
|
|
||||||
## Topologie
|
## 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` |
|
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
|
||||||
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
|
|
||||||
Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht
|
Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht
|
||||||
(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive
|
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
|
||||||
Session; Schreibzugriffe zusätzlich den Anwendungsheader
|
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
|
||||||
`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem
|
|
||||||
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
||||||
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||||
|
|
||||||
|
|
@ -73,26 +72,6 @@ Verwendete Umgebungsvariablen:
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei
|
|
||||||
leerer Benutzertabelle den ersten Administrator; das Startpasswort hat
|
|
||||||
mindestens 12 Zeichen.
|
|
||||||
- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden.
|
|
||||||
- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage.
|
|
||||||
- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN
|
|
||||||
`false`, da Browser das Cookie sonst nicht speichern.
|
|
||||||
|
|
||||||
Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen,
|
|
||||||
nicht versionierten `.env` neben `compose.yaml` gesetzt werden:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
AUTH_BOOTSTRAP_USERNAME=admin
|
|
||||||
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
|
|
||||||
```
|
|
||||||
|
|
||||||
Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange
|
|
||||||
mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in
|
|
||||||
**Config → Benutzer** an. Details stehen in
|
|
||||||
[Anmeldung und Benutzerverwaltung](authentication.md).
|
|
||||||
|
|
||||||
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
||||||
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
||||||
|
|
@ -158,8 +137,7 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
|
|
||||||
- TLS-Termination und Reverse Proxy
|
- TLS-Termination und Reverse Proxy
|
||||||
- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei
|
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
||||||
festen Rollen später nicht ausreichen
|
|
||||||
- definierte Secrets- und Konfigurationsverwaltung
|
- definierte Secrets- und Konfigurationsverwaltung
|
||||||
- persistenter, gesicherter Datenbankbetrieb
|
- persistenter, gesicherter Datenbankbetrieb
|
||||||
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,6 @@ const nextConfig = {
|
||||||
typescript: {
|
typescript: {
|
||||||
tsconfigPath: "./tsconfig.next.json",
|
tsconfigPath: "./tsconfig.next.json",
|
||||||
},
|
},
|
||||||
poweredByHeader: false,
|
|
||||||
// Clickjacking: the Config pages hold admin actions, so no foreign page
|
|
||||||
// may frame the app. Same-origin framing stays allowed.
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/:path*",
|
|
||||||
headers: [
|
|
||||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
|
||||||
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
|
|
||||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
||||||
{ key: "Referrer-Policy", value: "same-origin" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api";
|
|
||||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
|
||||||
import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
|
||||||
|
|
||||||
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "short",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function BenutzerPage() {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
const [users, setUsers] = useState<UserDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
|
||||||
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const rows = await userApi.list();
|
|
||||||
setUsers(rows);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
async function withBusy(userId: string, action: () => Promise<void>) {
|
|
||||||
setBusyUserId(userId);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setBusyUserId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeRole(item: UserDto, role: UserRole) {
|
|
||||||
await withBusy(item.id, async () => {
|
|
||||||
const updated = await userApi.update(item.id, { role });
|
|
||||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleActive(item: UserDto) {
|
|
||||||
await withBusy(item.id, async () => {
|
|
||||||
const updated = await userApi.update(item.id, { active: !item.active });
|
|
||||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: "1.5rem" }}>
|
|
||||||
<div className="page-header" style={{ marginTop: 0 }}>
|
|
||||||
<h2 className="h5" style={{ margin: 0 }}>
|
|
||||||
Benutzer
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
|
|
||||||
Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem
|
|
||||||
Administrator angelegt. Das Startpasswort wird direkt eingetragen; die
|
|
||||||
Person muss es beim ersten Anmelden durch ein eigenes ersetzen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<NeuerBenutzerFormular
|
|
||||||
onCreated={(created) => {
|
|
||||||
setUsers((list) => [...list, created]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
|
||||||
{loading && <p>Lädt…</p>}
|
|
||||||
|
|
||||||
{!loading && users.length > 0 && (
|
|
||||||
<table className="table" style={{ marginTop: "1.5rem" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Anmeldename</th>
|
|
||||||
<th>Rolle</th>
|
|
||||||
<th>Aktiv</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Angelegt</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((item) => {
|
|
||||||
const isSelf = item.id === currentUser?.id;
|
|
||||||
const busy = busyUserId === item.id;
|
|
||||||
return (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{item.username}</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
className="form-select form-select-sm"
|
|
||||||
value={item.role}
|
|
||||||
disabled={busy || isSelf}
|
|
||||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
|
||||||
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
|
|
||||||
>
|
|
||||||
{USER_ROLES.map((role) => (
|
|
||||||
<option key={role} value={role}>
|
|
||||||
{USER_ROLE_LABEL[role]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={item.active}
|
|
||||||
disabled={busy || isSelf}
|
|
||||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
|
||||||
onChange={() => void toggleActive(item)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
|
|
||||||
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-outline-primary btn-sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setResetPasswordFor(item)}
|
|
||||||
>
|
|
||||||
Passwort zurücksetzen
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
|
|
||||||
|
|
||||||
{resetPasswordFor && (
|
|
||||||
<PasswortZuruecksetzenDialog
|
|
||||||
item={resetPasswordFor}
|
|
||||||
onClose={() => setResetPasswordFor(null)}
|
|
||||||
onDone={(updated) => {
|
|
||||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
|
||||||
setResetPasswordFor(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [role, setRole] = useState<UserRole>("user");
|
|
||||||
const [initialPassword, setInitialPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await userApi.create({ username, name, role, initialPassword });
|
|
||||||
onCreated(created);
|
|
||||||
setUsername("");
|
|
||||||
setName("");
|
|
||||||
setRole("user");
|
|
||||||
setInitialPassword("");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="row g-2 align-items-end"
|
|
||||||
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
|
|
||||||
>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<label className="form-label" htmlFor="new-user-username">
|
|
||||||
Anmeldename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="new-user-username"
|
|
||||||
className="form-control form-control-sm"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<label className="form-label" htmlFor="new-user-name">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="new-user-name"
|
|
||||||
className="form-control form-control-sm"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-2">
|
|
||||||
<label className="form-label" htmlFor="new-user-role">
|
|
||||||
Rolle
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="new-user-role"
|
|
||||||
className="form-select form-select-sm"
|
|
||||||
value={role}
|
|
||||||
onChange={(event) => setRole(event.target.value as UserRole)}
|
|
||||||
>
|
|
||||||
{USER_ROLES.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{USER_ROLE_LABEL[r]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<label className="form-label" htmlFor="new-user-password">
|
|
||||||
Startpasswort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="new-user-password"
|
|
||||||
type="text"
|
|
||||||
className="form-control form-control-sm"
|
|
||||||
value={initialPassword}
|
|
||||||
onChange={(event) => setInitialPassword(event.target.value)}
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-1">
|
|
||||||
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
|
|
||||||
{submitting ? "…" : "Anlegen"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="col-12">
|
|
||||||
<p className="text-danger mb-0">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasswortZuruecksetzenDialog({
|
|
||||||
item,
|
|
||||||
onClose,
|
|
||||||
onDone,
|
|
||||||
}: {
|
|
||||||
item: UserDto;
|
|
||||||
onClose: () => void;
|
|
||||||
onDone: (updated: UserDto) => void;
|
|
||||||
}) {
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await userApi.resetPassword(item.id, newPassword);
|
|
||||||
onDone(updated);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-backdrop-inline">
|
|
||||||
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
|
|
||||||
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="reset-password">
|
|
||||||
Neues Startpasswort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="reset-password"
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
minLength={8}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
|
||||||
{submitting ? "Speichert…" : "Zurücksetzen"}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { authApi } from "../../../frontend/utils/auth-api";
|
|
||||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
|
||||||
import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
|
||||||
|
|
||||||
export default function KontoPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (newPassword !== newPasswordRepeat) {
|
|
||||||
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccess(false);
|
|
||||||
try {
|
|
||||||
await authApi.changePassword(currentPassword, newPassword);
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setNewPasswordRepeat("");
|
|
||||||
setSuccess(true);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
|
|
||||||
<h2 className="h5">Konto</h2>
|
|
||||||
{user && (
|
|
||||||
<p className="text-body-secondary">
|
|
||||||
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
|
|
||||||
{USER_ROLE_LABEL[user.role]}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 className="h6 mt-4">Passwort ändern</h3>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="konto-current-password">
|
|
||||||
Aktuelles Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="konto-current-password"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="konto-new-password">
|
|
||||||
Neues Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="konto-new-password"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="konto-new-password-repeat">
|
|
||||||
Neues Passwort wiederholen
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="konto-new-password-repeat"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={newPasswordRepeat}
|
|
||||||
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
|
||||||
{success && <p className="text-success">Passwort wurde geändert.</p>}
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
|
||||||
{submitting ? "Speichert…" : "Passwort ändern"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
status !== "signed-in" ||
|
|
||||||
redirectTarget !== null
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container-fluid py-4">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>Config</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="config-tabs">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href}
|
|
||||||
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function ConfigPage() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -323,24 +323,23 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding-top: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Square and icon-only so it fits next to Abmelden in .sidebar-bottom-row. */
|
|
||||||
.sidebar-theme-toggle {
|
.sidebar-theme-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 10px;
|
||||||
flex: 0 0 auto;
|
padding: 10px 14px;
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #c7d6e0;
|
color: #c7d6e0;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,25 +451,13 @@ a {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-row {
|
.filter-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded: the fields carry a label above their input, so toggle and
|
.filter-panel-header .filter-panel-toggle {
|
||||||
count sit on the input line (bottom) instead of the label line. */
|
|
||||||
.filter-panel-row-expanded {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-panel-row-expanded .filter-panel-toggle,
|
|
||||||
.filter-panel-row-expanded .filter-panel-count {
|
|
||||||
margin-bottom: calc((2.1rem - 24px) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-panel-row .filter-panel-toggle {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -480,7 +467,7 @@ a {
|
||||||
color: var(--color-ink-soft);
|
color: var(--color-ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-row .filter-panel-toggle:hover {
|
.filter-panel-header .filter-panel-toggle:hover {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,11 +502,10 @@ a {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--space-sm) var(--space-md);
|
gap: var(--space-sm) var(--space-md);
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar-field,
|
.filter-bar-field,
|
||||||
|
|
@ -2744,128 +2730,3 @@ button.structure-entry-main:focus-visible {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */
|
|
||||||
|
|
||||||
.sidebar-nav-bottom {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-identity {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
padding: 8px 14px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-identity-label {
|
|
||||||
font-size: 0.66rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(199, 214, 224, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-identity-name {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle (square) and Abmelden (fills the rest) share one row. */
|
|
||||||
.sidebar-bottom-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logout {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #c7d6e0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logout:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed .sidebar-identity {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed .sidebar-bottom-row {
|
|
||||||
justify-content: center;
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-gate-screen {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
var(--color-primary-deep) 0%,
|
|
||||||
var(--color-primary-dark) 45%,
|
|
||||||
var(--color-primary) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-gate-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
padding: 28px 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-tab {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-tab:hover {
|
|
||||||
opacity: 1;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-tab.active {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop-inline {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1050;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import Script from "next/script";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "../frontend/components/AppShell";
|
import { AppShell } from "../frontend/components/AppShell";
|
||||||
import { AuthProvider } from "../frontend/components/AuthProvider";
|
|
||||||
import { LoginGate } from "../frontend/components/LoginGate";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Leistungsbilanz",
|
title: "Leistungsbilanz",
|
||||||
|
|
@ -38,11 +36,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||||
<Script id="theme-init" strategy="beforeInteractive">
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
{THEME_INIT_SCRIPT}
|
{THEME_INIT_SCRIPT}
|
||||||
</Script>
|
</Script>
|
||||||
<AuthProvider>
|
|
||||||
<LoginGate>
|
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
</LoginGate>
|
|
||||||
</AuthProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
CREATE TABLE `users` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`username` text NOT NULL,
|
|
||||||
`password_hash` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`role` text DEFAULT 'user' NOT NULL,
|
|
||||||
`active` integer DEFAULT true NOT NULL,
|
|
||||||
`must_change_password` integer DEFAULT true NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -92,13 +92,6 @@
|
||||||
"when": 1790540142918,
|
"when": 1790540142918,
|
||||||
"tag": "0012_good_gateway",
|
"tag": "0012_good_gateway",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 13,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790624623732,
|
|
||||||
"tag": "0013_users",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
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";
|
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof Error &&
|
|
||||||
"code" in error &&
|
|
||||||
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserRepository implements UserStore {
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
async list() {
|
|
||||||
const db = this.database;
|
|
||||||
return db.select().from(users).orderBy(asc(users.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
async count() {
|
|
||||||
const db = this.database;
|
|
||||||
const rows = await db.select({ id: users.id }).from(users).limit(1);
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(userId: string) {
|
|
||||||
const db = this.database;
|
|
||||||
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByUsername(username: string) {
|
|
||||||
const db = this.database;
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.username, username.trim().toLowerCase()))
|
|
||||||
.limit(1);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CreateUserAccount) {
|
|
||||||
const db = this.database;
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
try {
|
|
||||||
await db.insert(users).values({
|
|
||||||
id,
|
|
||||||
username: input.username.trim().toLowerCase(),
|
|
||||||
name: input.name,
|
|
||||||
role: input.role,
|
|
||||||
passwordHash: input.passwordHash,
|
|
||||||
active: true,
|
|
||||||
mustChangePassword: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return this.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRoleAndActive(
|
|
||||||
userId: string,
|
|
||||||
patch: { role?: UserRole; active?: boolean }
|
|
||||||
) {
|
|
||||||
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))
|
|
||||||
.run();
|
|
||||||
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPassword(
|
|
||||||
userId: string,
|
|
||||||
passwordHash: string,
|
|
||||||
mustChangePassword: boolean
|
|
||||||
) {
|
|
||||||
const db = this.database;
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ passwordHash, mustChangePassword, updatedAt: new Date() })
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
return this.findById(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
username: text("username").notNull(),
|
|
||||||
passwordHash: text("password_hash").notNull(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
role: text("role").$type<UserRole>().notNull().default("user"),
|
|
||||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
|
||||||
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(true),
|
|
||||||
createdAt: integer("created_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
},
|
|
||||||
(table) => [uniqueIndex("users_username_unique").on(table.username)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect;
|
|
||||||
export type NewUser = typeof users.$inferInsert;
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
|
||||||
import { createLogger } from "../../shared/logging/logger.js";
|
|
||||||
import {
|
|
||||||
LastActiveAdminStoreError,
|
|
||||||
UsernameAlreadyExistsStoreError,
|
|
||||||
type UserStore,
|
|
||||||
} from "../ports/user.store.js";
|
|
||||||
|
|
||||||
const logger = createLogger("user-service");
|
|
||||||
|
|
||||||
export class InvalidCredentialsError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Anmeldung fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UsernameTakenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Dieser Anmeldename ist bereits vergeben.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WrongPasswordError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Das aktuelle Passwort ist falsch.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PasswordUnchangedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Das neue Passwort muss sich vom bisherigen unterscheiden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LastAdminError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Es muss mindestens ein aktiver Administrator bleiben.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: UserStore,
|
|
||||||
private readonly options: UserServiceOptions,
|
|
||||||
private readonly passwords: PasswordHasher
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the bootstrap admin from env vars, but only while the users
|
|
||||||
* table is still completely empty. After the first account exists, every
|
|
||||||
* further account is created through Config -> Benutzer, and the bootstrap
|
|
||||||
* env vars are ignored - that's the whole point of them being a one-time
|
|
||||||
* escape hatch instead of a standing backdoor.
|
|
||||||
*/
|
|
||||||
async ensureBootstrapAdmin(): Promise<void> {
|
|
||||||
// Warm the dummy hash at startup so even the first unknown-user login
|
|
||||||
// costs one scrypt run, not two.
|
|
||||||
await this.getDummyHash();
|
|
||||||
const existingCount = await this.repository.count();
|
|
||||||
if (existingCount > 0) return;
|
|
||||||
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 this.passwords.hash(this.options.bootstrap.password);
|
|
||||||
await this.repository.create({
|
|
||||||
username: this.options.bootstrap.username,
|
|
||||||
name: "Administrator",
|
|
||||||
role: "admin",
|
|
||||||
passwordHash,
|
|
||||||
});
|
|
||||||
logger.info("bootstrap admin created", {
|
|
||||||
username: this.options.bootstrap.username,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async authenticate(username: string, password: string) {
|
|
||||||
const user = await this.repository.findByUsername(username);
|
|
||||||
// Unknown users are verified against a fixed dummy hash: exactly one
|
|
||||||
// scrypt run either way, so response time doesn't reveal which usernames
|
|
||||||
// exist.
|
|
||||||
const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
|
|
||||||
const passwordMatches = await this.passwords.verify(password, passwordHash);
|
|
||||||
if (!user || !user.active || !passwordMatches) {
|
|
||||||
throw new InvalidCredentialsError();
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createUser(input: {
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
initialPassword: string;
|
|
||||||
}) {
|
|
||||||
const existing = await this.repository.findByUsername(input.username);
|
|
||||||
if (existing) throw new UsernameTakenError();
|
|
||||||
const passwordHash = await this.passwords.hash(input.initialPassword);
|
|
||||||
try {
|
|
||||||
return await this.repository.create({
|
|
||||||
username: input.username,
|
|
||||||
name: input.name,
|
|
||||||
role: input.role,
|
|
||||||
passwordHash,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Two admins creating the same name at once: the unique index decides.
|
|
||||||
if (error instanceof UsernameAlreadyExistsStoreError) throw new UsernameTakenError();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
|
||||||
try {
|
|
||||||
return await this.repository.updateRoleAndActive(userId, patch);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof LastActiveAdminStoreError) throw new LastAdminError();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Admin-triggered reset with a new start password; forces a change on next login. */
|
|
||||||
async resetPassword(userId: string, newPassword: string) {
|
|
||||||
const passwordHash = await this.passwords.hash(newPassword);
|
|
||||||
return this.repository.setPassword(userId, passwordHash, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Self-service change by the signed-in user; clears mustChangePassword. */
|
|
||||||
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
|
|
||||||
const user = await this.repository.findById(userId);
|
|
||||||
if (!user) throw new WrongPasswordError();
|
|
||||||
const currentMatches = await this.passwords.verify(currentPassword, user.passwordHash);
|
|
||||||
if (!currentMatches) throw new WrongPasswordError();
|
|
||||||
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
|
||||||
const passwordHash = await this.passwords.hash(newPassword);
|
|
||||||
return this.repository.setPassword(userId, passwordHash, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDummyHash(): Promise<string> {
|
|
||||||
this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login");
|
|
||||||
return this.dummyHash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
||||||
import { authApi, type AuthUserDto } from "../utils/auth-api";
|
|
||||||
|
|
||||||
type AuthStatus = "loading" | "signed-in" | "signed-out";
|
|
||||||
|
|
||||||
interface AuthContextValue {
|
|
||||||
status: AuthStatus;
|
|
||||||
user: AuthUserDto | undefined;
|
|
||||||
isAdmin: boolean;
|
|
||||||
reload: () => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
|
||||||
const [user, setUser] = useState<AuthUserDto | undefined>(undefined);
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const current = await authApi.me();
|
|
||||||
setUser(current);
|
|
||||||
setStatus("signed-in");
|
|
||||||
} catch {
|
|
||||||
setUser(undefined);
|
|
||||||
setStatus("signed-out");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void reload();
|
|
||||||
}, [reload]);
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await authApi.logout();
|
|
||||||
} finally {
|
|
||||||
setUser(undefined);
|
|
||||||
setStatus("signed-out");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{ status, user, isAdmin: user?.role === "admin", reload, logout }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(): AuthContextValue {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useAuth must be used within an AuthProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { authApi } from "../utils/auth-api";
|
|
||||||
import { useAuth } from "./AuthProvider";
|
|
||||||
|
|
||||||
export function LoginGate({ children }: { children: React.ReactNode }) {
|
|
||||||
const { status, user, reload } = useAuth();
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="login-gate-screen">
|
|
||||||
<p>Lädt…</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "signed-out") {
|
|
||||||
return (
|
|
||||||
<div className="login-gate-screen">
|
|
||||||
<LoginForm onSignedIn={reload} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.mustChangePassword) {
|
|
||||||
return (
|
|
||||||
<div className="login-gate-screen">
|
|
||||||
<ForcedPasswordChangeForm onChanged={reload} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginForm({ onSignedIn }: { onSignedIn: () => Promise<void> }) {
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await authApi.login(username, password);
|
|
||||||
await onSignedIn();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="login-gate-card card" onSubmit={handleSubmit}>
|
|
||||||
<h1 className="h4 mb-3">Leistungsbilanz</h1>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="login-username">
|
|
||||||
Anmeldename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-username"
|
|
||||||
className="form-control"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoFocus
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="login-password">
|
|
||||||
Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
|
||||||
{submitting ? "Meldet an…" : "Anmelden"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise<void> }) {
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (newPassword !== newPasswordRepeat) {
|
|
||||||
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await authApi.changePassword(currentPassword, newPassword);
|
|
||||||
await onChanged();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="login-gate-card card" onSubmit={handleSubmit}>
|
|
||||||
<h1 className="h4 mb-3">Neues Passwort setzen</h1>
|
|
||||||
<p className="text-body-secondary">
|
|
||||||
Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der
|
|
||||||
weiteren Nutzung ein eigenes Passwort vergeben.
|
|
||||||
</p>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="force-current-password">
|
|
||||||
Aktuelles (Start-)Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="force-current-password"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="force-new-password">
|
|
||||||
Neues Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="force-new-password"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label" htmlFor="force-new-password-repeat">
|
|
||||||
Neues Passwort wiederholen
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="force-new-password-repeat"
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
value={newPasswordRepeat}
|
|
||||||
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
|
||||||
{submitting ? "Speichert…" : "Passwort setzen"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { useEffect, useState } from "react";
|
||||||
import { listProjects } from "../utils/api";
|
import { listProjects } from "../utils/api";
|
||||||
import type { ProjectDto } from "../types";
|
import type { ProjectDto } from "../types";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { useAuth } from "./AuthProvider";
|
|
||||||
import { getConfigEntry } from "../utils/config-access";
|
|
||||||
|
|
||||||
// Anchors on the project overview page (/projects/[id]).
|
// Anchors on the project overview page (/projects/[id]).
|
||||||
const PROJECT_SECTIONS = [
|
const PROJECT_SECTIONS = [
|
||||||
|
|
@ -32,8 +30,6 @@ interface SidebarProps {
|
||||||
|
|
||||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
|
||||||
const configEntry = getConfigEntry(isAdmin);
|
|
||||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
||||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||||
const projectSubPath = projectMatch?.[2] ?? "";
|
const projectSubPath = projectMatch?.[2] ?? "";
|
||||||
|
|
@ -183,34 +179,8 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<nav className="sidebar-nav sidebar-nav-bottom">
|
|
||||||
<Link
|
|
||||||
href={configEntry.href}
|
|
||||||
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
|
|
||||||
title={configEntry.label}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">⚙</span>
|
|
||||||
<span className="sidebar-label">{configEntry.label}</span>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
{user && !isCollapsed && (
|
|
||||||
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
|
|
||||||
<span className="sidebar-identity-label">Angemeldet als</span>
|
|
||||||
<span className="sidebar-identity-name">{user.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Toggle and Abmelden share one row: the toggle stays a fixed-size
|
|
||||||
square, Abmelden takes the rest of the width. Collapsed, only
|
|
||||||
the square toggle remains (centered). */}
|
|
||||||
<div className="sidebar-bottom-row">
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{user && !isCollapsed && (
|
|
||||||
<button type="button" className="sidebar-logout" onClick={() => void logout()}>
|
|
||||||
Abmelden
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,16 @@ export function ThemeToggle() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Square, icon-only: it shares a row with the Abmelden button, so there
|
|
||||||
// is no room for a text label. The label moves to title/aria-label.
|
|
||||||
<button
|
<button
|
||||||
aria-pressed={theme === "dark"}
|
aria-pressed={theme === "dark"}
|
||||||
className="sidebar-theme-toggle"
|
className="sidebar-theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
|
|
||||||
aria-label={theme === "dark" ? "Hellmodus einschalten" : "Dunkelmodus einschalten"}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span>
|
<span className="sidebar-icon" aria-hidden="true">
|
||||||
|
{theme === "dark" ? "☀" : "☾"}
|
||||||
|
</span>
|
||||||
|
{theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@ import type {
|
||||||
CableSizingInput,
|
CableSizingInput,
|
||||||
CableSizingResult,
|
CableSizingResult,
|
||||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||||
import {
|
|
||||||
WRITE_REQUEST_HEADER_NAME,
|
|
||||||
WRITE_REQUEST_HEADER_VALUE,
|
|
||||||
} from "../../shared/constants/request-security";
|
|
||||||
|
|
||||||
export interface CalculateCableSizingResponse {
|
export interface CalculateCableSizingResponse {
|
||||||
result: CableSizingResult;
|
result: CableSizingResult;
|
||||||
|
|
@ -21,10 +17,7 @@ export async function calculateCableSizing(
|
||||||
): Promise<CalculateCableSizingResponse> {
|
): Promise<CalculateCableSizingResponse> {
|
||||||
const response = await fetch("/api/cable-sizing/calculate", {
|
const response = await fetch("/api/cable-sizing/calculate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,9 @@ export function CollapsibleFilterPanel({
|
||||||
writeCollapsed(storageKey, next);
|
writeCollapsed(storageKey, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row: toggle, then either the "Filter" label (collapsed) or the filter
|
|
||||||
// fields themselves (expanded), and the count at the right edge.
|
|
||||||
return (
|
return (
|
||||||
<div className="filter-panel">
|
<div className="filter-panel">
|
||||||
<div
|
<div className="filter-panel-header">
|
||||||
className={
|
|
||||||
isCollapsed ? "filter-panel-row" : "filter-panel-row filter-panel-row-expanded"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaControls={barId}
|
ariaControls={barId}
|
||||||
ariaExpanded={!isCollapsed}
|
ariaExpanded={!isCollapsed}
|
||||||
|
|
@ -72,12 +66,15 @@ export function CollapsibleFilterPanel({
|
||||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||||
onClick={toggleCollapsed}
|
onClick={toggleCollapsed}
|
||||||
/>
|
/>
|
||||||
{isCollapsed ? (
|
|
||||||
<>
|
|
||||||
<span className="filter-panel-label">Filter</span>
|
<span className="filter-panel-label">Filter</span>
|
||||||
{isFilterActive ? <span className="filter-panel-badge">Filter aktiv</span> : null}
|
{isCollapsed && isFilterActive ? (
|
||||||
</>
|
<span className="filter-panel-badge">Filter aktiv</span>
|
||||||
) : (
|
) : null}
|
||||||
|
<span aria-live="polite" className="filter-panel-count">
|
||||||
|
{visibleCount} von {totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? null : (
|
||||||
<div className="filter-bar" id={barId}>
|
<div className="filter-bar" id={barId}>
|
||||||
{children}
|
{children}
|
||||||
{isFilterActive ? (
|
{isFilterActive ? (
|
||||||
|
|
@ -92,10 +89,6 @@ export function CollapsibleFilterPanel({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span aria-live="polite" className="filter-panel-count">
|
|
||||||
{visibleCount} von {totalCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import {
|
|
||||||
WRITE_REQUEST_HEADER_NAME,
|
|
||||||
WRITE_REQUEST_HEADER_VALUE,
|
|
||||||
} from "../../shared/constants/request-security";
|
|
||||||
import type {
|
import type {
|
||||||
BuildingDto,
|
BuildingDto,
|
||||||
CircuitListDto,
|
CircuitListDto,
|
||||||
|
|
@ -126,7 +122,6 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
||||||
export type UserRole = "admin" | "user";
|
|
||||||
|
|
||||||
export interface AuthStatusDto {
|
|
||||||
signedIn: boolean;
|
|
||||||
mustChangePassword: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthUserDto {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
mustChangePassword: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserDto {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
active: boolean;
|
|
||||||
mustChangePassword: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly status: number
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
let message = `Anfrage fehlgeschlagen (Status ${response.status})`;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { error?: unknown };
|
|
||||||
if (typeof body.error === "string") message = body.error;
|
|
||||||
} catch {
|
|
||||||
// Keep the generic message for non-JSON error responses.
|
|
||||||
}
|
|
||||||
throw new AuthApiError(message, response.status);
|
|
||||||
}
|
|
||||||
if (response.status === 204) return undefined as T;
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authApi = {
|
|
||||||
status: () => request<AuthStatusDto>("/api/auth/status"),
|
|
||||||
login: (username: string, password: string) =>
|
|
||||||
request<AuthStatusDto>("/api/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
}),
|
|
||||||
me: () => request<AuthUserDto>("/api/auth/me"),
|
|
||||||
changePassword: (currentPassword: string, newPassword: string) =>
|
|
||||||
request<{ changed: true }>("/api/auth/change-password", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
|
||||||
}),
|
|
||||||
logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userApi = {
|
|
||||||
list: () => request<UserDto[]>("/api/users"),
|
|
||||||
create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) =>
|
|
||||||
request<UserDto>("/api/users", { method: "POST", body: JSON.stringify(input) }),
|
|
||||||
update: (userId: string, patch: { role?: UserRole; active?: boolean }) =>
|
|
||||||
request<UserDto>(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
||||||
resetPassword: (userId: string, newPassword: string) =>
|
|
||||||
request<UserDto>(`/api/users/${userId}/reset-password`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ newPassword }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
export const SESSION_COOKIE = "lb_session";
|
|
||||||
|
|
||||||
const DEFAULT_SESSION_HOURS = 12;
|
|
||||||
const DEFAULT_SESSION_MAX_DAYS = 7;
|
|
||||||
|
|
||||||
export interface AuthConfig {
|
|
||||||
/** Idle timeout; slides forward on activity. */
|
|
||||||
sessionTtlMs: number;
|
|
||||||
/** Hard upper bound for a session, regardless of activity. */
|
|
||||||
sessionMaxAgeMs: number;
|
|
||||||
cookieSecure: boolean;
|
|
||||||
/**
|
|
||||||
* Only used to create the very first admin when the users table is still
|
|
||||||
* empty - after that, every account is created through Config -> Benutzer.
|
|
||||||
* Unset means: no bootstrap account, the table must already have an admin.
|
|
||||||
*/
|
|
||||||
bootstrap: { username: string; password: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthConfigError extends Error {}
|
|
||||||
|
|
||||||
function positiveNumber(raw: string | undefined, fallback: number, name: string): number {
|
|
||||||
const value = Number(raw ?? fallback);
|
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
|
||||||
throw new AuthConfigError(`${name} must be a positive number.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
|
||||||
const hours = positiveNumber(env.AUTH_SESSION_HOURS, DEFAULT_SESSION_HOURS, "AUTH_SESSION_HOURS");
|
|
||||||
const maxDays = positiveNumber(
|
|
||||||
env.AUTH_SESSION_MAX_DAYS,
|
|
||||||
DEFAULT_SESSION_MAX_DAYS,
|
|
||||||
"AUTH_SESSION_MAX_DAYS"
|
|
||||||
);
|
|
||||||
|
|
||||||
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
|
|
||||||
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
|
|
||||||
if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) {
|
|
||||||
throw new AuthConfigError(
|
|
||||||
"AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (bootstrapPassword && bootstrapPassword.length < 12) {
|
|
||||||
throw new AuthConfigError(
|
|
||||||
"AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionTtlMs: hours * 60 * 60 * 1000,
|
|
||||||
sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000,
|
|
||||||
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
|
|
||||||
bootstrap: bootstrapUsername
|
|
||||||
? { username: bootstrapUsername, password: bootstrapPassword }
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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 { 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;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
mustChangePassword: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
authUser?: AuthUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionIdFromRequest(req: {
|
|
||||||
headers: { cookie?: string | undefined };
|
|
||||||
}): string | undefined {
|
|
||||||
return readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function identityFromRequest(
|
|
||||||
req: { headers: { cookie?: string | undefined } },
|
|
||||||
sessions: SessionStore,
|
|
||||||
users: Pick<UserStore, "findById">,
|
|
||||||
slideExpiry = true
|
|
||||||
): Promise<AuthUser | undefined> {
|
|
||||||
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
|
||||||
if (!session) return undefined;
|
|
||||||
const user = await users.findById(session.userId);
|
|
||||||
if (!user || !user.active) return undefined;
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role as UserRole,
|
|
||||||
mustChangePassword: user.mustChangePassword,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) {
|
|
||||||
return {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
secure: authConfig.cookieSecure,
|
|
||||||
path: "/",
|
|
||||||
maxAge: maxAgeMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the session cookie with the same remaining lifetime the server uses. */
|
|
||||||
export function setSessionCookie(
|
|
||||||
res: Response,
|
|
||||||
authConfig: AuthConfig,
|
|
||||||
sessions: SessionStore,
|
|
||||||
sessionId: string
|
|
||||||
): void {
|
|
||||||
const session = sessions.get(sessionId, false);
|
|
||||||
if (!session) return;
|
|
||||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session)));
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
export function requireSameOriginForWrites(): RequestHandler {
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (SAFE_METHODS.has(req.method)) {
|
|
||||||
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();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Protects /api. Only login, status and logout stay reachable without a session. */
|
|
||||||
export function createAuthGuard(
|
|
||||||
authConfig: AuthConfig,
|
|
||||||
sessions: SessionStore,
|
|
||||||
users: Pick<UserStore, "findById">,
|
|
||||||
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
|
||||||
): RequestHandler {
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (openPaths.includes(req.path)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
identityFromRequest(req, sessions, users)
|
|
||||||
.then((identity) => {
|
|
||||||
if (!identity) {
|
|
||||||
res.status(401).json({ error: "Nicht angemeldet." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
req.authUser = identity;
|
|
||||||
const sessionId = sessionIdFromRequest(req);
|
|
||||||
if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId);
|
|
||||||
next();
|
|
||||||
})
|
|
||||||
.catch(next);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Blocks everything except logout and the password change while a start password is still in use. */
|
|
||||||
export function requirePasswordChanged(): RequestHandler {
|
|
||||||
const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]);
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (!req.authUser?.mustChangePassword || allowed.has(req.path)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(403).json({
|
|
||||||
error: "Bitte zuerst ein eigenes Passwort setzen.",
|
|
||||||
mustChangePassword: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireRole(role: UserRole): RequestHandler {
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
if (req.authUser?.role !== role) {
|
|
||||||
res.status(403).json({ error: "Keine Berechtigung für diese Aktion." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */
|
|
||||||
export function readCookie(
|
|
||||||
header: string | undefined,
|
|
||||||
name: string
|
|
||||||
): string | undefined {
|
|
||||||
if (!header) return undefined;
|
|
||||||
for (const part of header.split(";")) {
|
|
||||||
const separatorIndex = part.indexOf("=");
|
|
||||||
if (separatorIndex === -1) continue;
|
|
||||||
const key = part.slice(0, separatorIndex).trim();
|
|
||||||
if (key !== name) continue;
|
|
||||||
// A malformed value must read as "no session", not throw into a 500.
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/**
|
|
||||||
* Slows down online password guessing per username.
|
|
||||||
*
|
|
||||||
* Keyed by username, not by client IP: every request reaches the API through
|
|
||||||
* the Next.js rewrite, so the socket address is always the web container.
|
|
||||||
* The trade-off is that someone who knows a username can keep that account
|
|
||||||
* temporarily throttled; the block lifts by itself after the window, so it is
|
|
||||||
* a nuisance, not a lockout.
|
|
||||||
*/
|
|
||||||
export class LoginThrottle {
|
|
||||||
private readonly failures = new Map<string, number[]>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly maxFailures = 5,
|
|
||||||
private readonly windowMs = 15 * 60 * 1000,
|
|
||||||
private readonly maxTrackedKeys = 10_000,
|
|
||||||
private readonly now: () => number = Date.now
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Milliseconds until another attempt is allowed; 0 = allowed now. */
|
|
||||||
retryAfterMs(username: string): number {
|
|
||||||
const recent = this.recentFailures(this.key(username));
|
|
||||||
if (recent.length < this.maxFailures) return 0;
|
|
||||||
return Math.max(0, recent[0] + this.windowMs - this.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
recordFailure(username: string): void {
|
|
||||||
const key = this.key(username);
|
|
||||||
const recent = this.recentFailures(key);
|
|
||||||
recent.push(this.now());
|
|
||||||
if (!this.failures.has(key) && this.failures.size >= this.maxTrackedKeys) {
|
|
||||||
// Bound memory under a flood of random usernames: drop the oldest key.
|
|
||||||
const oldestKey = this.failures.keys().next().value;
|
|
||||||
if (oldestKey !== undefined) this.failures.delete(oldestKey);
|
|
||||||
}
|
|
||||||
this.failures.set(key, recent);
|
|
||||||
}
|
|
||||||
|
|
||||||
recordSuccess(username: string): void {
|
|
||||||
this.failures.delete(this.key(username));
|
|
||||||
}
|
|
||||||
|
|
||||||
private recentFailures(key: string): number[] {
|
|
||||||
const cutoff = this.now() - this.windowMs;
|
|
||||||
const recent = (this.failures.get(key) ?? []).filter((time) => time > cutoff);
|
|
||||||
if (recent.length === 0) this.failures.delete(key);
|
|
||||||
return recent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(username: string): string {
|
|
||||||
return username.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto";
|
|
||||||
|
|
||||||
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
|
|
||||||
// verification (with explicit N/r/p) doesn't type-check through it.
|
|
||||||
function scryptAsync(
|
|
||||||
password: string,
|
|
||||||
salt: Buffer,
|
|
||||||
keyLength: number,
|
|
||||||
options?: ScryptOptions
|
|
||||||
): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const callback = (error: Error | null, derivedKey: Buffer) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve(derivedKey);
|
|
||||||
};
|
|
||||||
if (options) {
|
|
||||||
scrypt(password, salt, keyLength, options, callback);
|
|
||||||
} else {
|
|
||||||
scrypt(password, salt, keyLength, callback);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(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,
|
|
||||||
SCRYPT_R,
|
|
||||||
SCRYPT_P,
|
|
||||||
salt.toString("hex"),
|
|
||||||
derivedKey.toString("hex"),
|
|
||||||
].join("$");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(
|
|
||||||
password: string,
|
|
||||||
storedHash: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const parts = storedHash.split("$");
|
|
||||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
|
||||||
const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts;
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_SESSIONS_PER_USER = 20;
|
|
||||||
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory session store. The API runs as a single container/process, so a
|
|
||||||
* restart clearing every session is an acceptable trade-off for the
|
|
||||||
* simplicity of not needing a persisted session table.
|
|
||||||
*
|
|
||||||
* Sessions slide forward on activity (`idleTtlMs`) but never outlive
|
|
||||||
* `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever.
|
|
||||||
*/
|
|
||||||
export class SessionStore {
|
|
||||||
private readonly sessions = new Map<string, Session>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly idleTtlMs: number,
|
|
||||||
private readonly absoluteTtlMs: number,
|
|
||||||
private readonly now: () => number = Date.now
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Removes expired sessions; unreferenced ones would otherwise pile up. */
|
|
||||||
startSweeping(): void {
|
|
||||||
setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
sweep(): void {
|
|
||||||
const now = this.now();
|
|
||||||
for (const [id, session] of this.sessions) {
|
|
||||||
if (this.isExpired(session, now)) this.sessions.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
create(userId: string): Session {
|
|
||||||
this.enforcePerUserLimit(userId);
|
|
||||||
const now = this.now();
|
|
||||||
const session: Session = {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs),
|
|
||||||
};
|
|
||||||
this.sessions.set(session.id, session);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the session and slides its expiry forward, or undefined if gone/expired. */
|
|
||||||
get(sessionId: string | undefined, slideExpiry = true): Session | undefined {
|
|
||||||
if (!sessionId) return undefined;
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) return undefined;
|
|
||||||
const now = this.now();
|
|
||||||
if (this.isExpired(session, now)) {
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (slideExpiry) {
|
|
||||||
session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs);
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remaining lifetime, used as cookie max-age so browser and server agree. */
|
|
||||||
remainingMs(session: Session): number {
|
|
||||||
return Math.max(0, session.expiresAt - this.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(sessionId: string | undefined): void {
|
|
||||||
if (!sessionId) return;
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ends every session of a user, optionally except one (e.g. the caller's own). */
|
|
||||||
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
|
||||||
for (const [id, session] of this.sessions) {
|
|
||||||
if (session.userId === userId && id !== exceptSessionId) {
|
|
||||||
this.sessions.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isExpired(session: Session, now: number): boolean {
|
|
||||||
return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest sessions go first, so a legitimate user logging in on a new
|
|
||||||
// device is never refused - only memory growth is bounded.
|
|
||||||
private enforcePerUserLimit(userId: string): void {
|
|
||||||
const own = [...this.sessions.values()]
|
|
||||||
.filter((session) => session.userId === userId)
|
|
||||||
.sort((a, b) => a.createdAt - b.createdAt);
|
|
||||||
while (own.length >= MAX_SESSIONS_PER_USER) {
|
|
||||||
const oldest = own.shift();
|
|
||||||
if (oldest) this.sessions.delete(oldest.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||||
import { UserRepository } from "../../db/repositories/user.repository.js";
|
|
||||||
|
|
||||||
export const circuitDeviceRowRepository =
|
export const circuitDeviceRowRepository =
|
||||||
new CircuitDeviceRowRepository(db);
|
new CircuitDeviceRowRepository(db);
|
||||||
|
|
@ -40,4 +39,3 @@ export const externalCsvConfigurationRepository =
|
||||||
new ExternalCsvConfigurationRepository(db);
|
new ExternalCsvConfigurationRepository(db);
|
||||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||||
export const userRepository = new UserRepository(db);
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { readAuthConfig } from "../auth/auth-config.js";
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js";
|
|
||||||
import {
|
|
||||||
InvalidCredentialsError,
|
|
||||||
PasswordUnchangedError,
|
|
||||||
WrongPasswordError,
|
|
||||||
} from "../../domain/services/user.service.js";
|
|
||||||
import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js";
|
|
||||||
import {
|
|
||||||
cookieOptions,
|
|
||||||
identityFromRequest,
|
|
||||||
sessionIdFromRequest,
|
|
||||||
setSessionCookie,
|
|
||||||
} from "../auth/auth.middleware.js";
|
|
||||||
import { SESSION_COOKIE } from "../auth/auth-config.js";
|
|
||||||
import { userRepository } from "../composition/application-repositories.js";
|
|
||||||
|
|
||||||
export async function getAuthStatus(req: Request, res: Response) {
|
|
||||||
const identity = await identityFromRequest(req, sessionStore, userRepository);
|
|
||||||
const sessionId = sessionIdFromRequest(req);
|
|
||||||
if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId);
|
|
||||||
res.json({
|
|
||||||
signedIn: Boolean(identity),
|
|
||||||
mustChangePassword: identity?.mustChangePassword ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(req: Request, res: Response) {
|
|
||||||
const parsed = loginSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { username, password } = parsed.data;
|
|
||||||
|
|
||||||
const retryAfterMs = loginThrottle.retryAfterMs(username);
|
|
||||||
if (retryAfterMs > 0) {
|
|
||||||
res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString());
|
|
||||||
res.status(429).json({
|
|
||||||
error: "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await userService.authenticate(username, password);
|
|
||||||
loginThrottle.recordSuccess(username);
|
|
||||||
// A fresh session id on every login: an id planted before login
|
|
||||||
// (session fixation) never becomes authenticated.
|
|
||||||
sessionStore.destroy(sessionIdFromRequest(req));
|
|
||||||
const session = sessionStore.create(user.id);
|
|
||||||
setSessionCookie(res, authConfig, sessionStore, session.id);
|
|
||||||
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof InvalidCredentialsError) {
|
|
||||||
loginThrottle.recordFailure(username);
|
|
||||||
res.status(401).json({ error: error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMe(req: Request, res: Response) {
|
|
||||||
const identity = req.authUser;
|
|
||||||
if (!identity) {
|
|
||||||
res.status(401).json({ error: "Nicht angemeldet." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function changePassword(req: Request, res: Response) {
|
|
||||||
const identity = req.authUser;
|
|
||||||
if (!identity) {
|
|
||||||
res.status(401).json({ error: "Nicht angemeldet." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = changePasswordSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await userService.changeOwnPassword(
|
|
||||||
identity.id,
|
|
||||||
parsed.data.currentPassword,
|
|
||||||
parsed.data.newPassword
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) {
|
|
||||||
res.status(400).json({ error: error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Every session of this account ends - including the current one, which is
|
|
||||||
// replaced by a fresh id so the user stays signed in on this device only.
|
|
||||||
sessionStore.destroyAllForUser(identity.id);
|
|
||||||
const session = sessionStore.create(identity.id);
|
|
||||||
setSessionCookie(res, authConfig, sessionStore, session.id);
|
|
||||||
res.json({ changed: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout(req: Request, res: Response) {
|
|
||||||
sessionStore.destroy(sessionIdFromRequest(req));
|
|
||||||
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig, 0), maxAge: undefined });
|
|
||||||
res.json({ signedOut: true });
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import type { ZodError } from "zod";
|
|
||||||
import {
|
|
||||||
createUserSchema,
|
|
||||||
resetPasswordSchema,
|
|
||||||
updateUserSchema,
|
|
||||||
} from "../../shared/validation/user.schemas.js";
|
|
||||||
import { LastAdminError, UsernameTakenError } from "../../domain/services/user.service.js";
|
|
||||||
import { sessionStore, userService } from "../composition/auth.js";
|
|
||||||
import { userRepository } from "../composition/application-repositories.js";
|
|
||||||
|
|
||||||
// The password hash never leaves this layer.
|
|
||||||
function toDto(user: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
active: boolean;
|
|
||||||
mustChangePassword: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
active: user.active,
|
|
||||||
mustChangePassword: user.mustChangePassword,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstIssue(error: ZodError): string {
|
|
||||||
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listUsers(_req: Request, res: Response) {
|
|
||||||
const rows = await userRepository.list();
|
|
||||||
res.json(rows.map(toDto));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createUser(req: Request, res: Response) {
|
|
||||||
const parsed = createUserSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const created = await userService.createUser(parsed.data);
|
|
||||||
if (!created) {
|
|
||||||
res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(201).json(toDto(created));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UsernameTakenError) {
|
|
||||||
res.status(409).json({ error: error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUser(req: Request, res: Response) {
|
|
||||||
const { userId } = req.params;
|
|
||||||
if (typeof userId !== "string") {
|
|
||||||
res.status(400).json({ error: "Invalid userId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = updateUserSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The signed-in admin can't lock themselves out by deactivating or
|
|
||||||
// demoting their own account through this endpoint.
|
|
||||||
if (userId === req.authUser?.id) {
|
|
||||||
res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = await userRepository.findById(userId);
|
|
||||||
if (!existing) {
|
|
||||||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
|
||||||
// Deactivation takes effect immediately, not just at the next login.
|
|
||||||
if (parsed.data.active === false) sessionStore.destroyAllForUser(userId);
|
|
||||||
res.json(updated ? toDto(updated) : null);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof LastAdminError) {
|
|
||||||
res.status(400).json({ error: error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetUserPassword(req: Request, res: Response) {
|
|
||||||
const { userId } = req.params;
|
|
||||||
if (typeof userId !== "string") {
|
|
||||||
res.status(400).json({ error: "Invalid userId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = resetPasswordSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = await userRepository.findById(userId);
|
|
||||||
if (!existing) {
|
|
||||||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
|
|
||||||
// A reset is usually a response to a suspected compromise: every open
|
|
||||||
// session of that account ends now.
|
|
||||||
sessionStore.destroyAllForUser(userId);
|
|
||||||
res.json(updated ? toDto(updated) : null);
|
|
||||||
}
|
|
||||||
|
|
@ -4,16 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||||
import { projectRouter } from "./routes/project.routes.js";
|
import { projectRouter } from "./routes/project.routes.js";
|
||||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||||
import { authRouter } from "./routes/auth.routes.js";
|
|
||||||
import { userRouter } from "./routes/user.routes.js";
|
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import {
|
|
||||||
createAuthGuard,
|
|
||||||
requirePasswordChanged,
|
|
||||||
requireSameOriginForWrites,
|
|
||||||
} from "./auth/auth.middleware.js";
|
|
||||||
import { authConfig, sessionStore, userService } from "./composition/auth.js";
|
|
||||||
import { userRepository } from "./composition/application-repositories.js";
|
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||||
import { closeDefaultDatabase } from "../db/client.js";
|
import { closeDefaultDatabase } from "../db/client.js";
|
||||||
|
|
||||||
|
|
@ -23,7 +14,6 @@ const port = Number(process.env.PORT || 3000);
|
||||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||||
const shutdownTimeoutMs = 15 * 1000;
|
const shutdownTimeoutMs = 15 * 1000;
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
app.use(express.json({ limit: "25mb" }));
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
@ -60,16 +50,6 @@ app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Everything below /api requires a signed-in, active user, except
|
|
||||||
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
|
||||||
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
|
||||||
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
|
||||||
app.use("/api", requireSameOriginForWrites());
|
|
||||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
|
||||||
app.use("/api", requirePasswordChanged());
|
|
||||||
|
|
||||||
app.use("/api/auth", authRouter);
|
|
||||||
app.use("/api/users", userRouter);
|
|
||||||
app.use("/api/cable-sizing", cableSizingRouter);
|
app.use("/api/cable-sizing", cableSizingRouter);
|
||||||
app.use("/api/projects", projectRouter);
|
app.use("/api/projects", projectRouter);
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
|
|
@ -97,20 +77,13 @@ setInterval(() => {
|
||||||
});
|
});
|
||||||
}, heartbeatIntervalMs).unref();
|
}, heartbeatIntervalMs).unref();
|
||||||
|
|
||||||
userService
|
const server = app.listen(port, () => {
|
||||||
.ensureBootstrapAdmin()
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error("bootstrap admin setup failed", toErrorMeta(error));
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
logger.info("server started", { port });
|
logger.info("server started", { port });
|
||||||
});
|
});
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
function shutdown(signal: NodeJS.Signals): void {
|
function shutdown(signal: NodeJS.Signals): void {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
logger.info("shutting down", { signal });
|
logger.info("shutting down", { signal });
|
||||||
|
|
@ -137,8 +110,7 @@ userService
|
||||||
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
|
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
|
||||||
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
|
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
|
||||||
server.closeIdleConnections();
|
server.closeIdleConnections();
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
changePassword,
|
|
||||||
getAuthStatus,
|
|
||||||
getMe,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
} from "../controllers/auth.controller.js";
|
|
||||||
|
|
||||||
export const authRouter = Router();
|
|
||||||
|
|
||||||
authRouter.get("/status", getAuthStatus);
|
|
||||||
authRouter.post("/login", login);
|
|
||||||
authRouter.get("/me", getMe);
|
|
||||||
authRouter.post("/change-password", changePassword);
|
|
||||||
authRouter.post("/logout", logout);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { requireRole } from "../auth/auth.middleware.js";
|
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
listUsers,
|
|
||||||
resetUserPassword,
|
|
||||||
updateUser,
|
|
||||||
} from "../controllers/user.controller.js";
|
|
||||||
|
|
||||||
export const userRouter = Router();
|
|
||||||
|
|
||||||
userRouter.use(requireRole("admin"));
|
|
||||||
userRouter.get("/", listUsers);
|
|
||||||
userRouter.post("/", createUser);
|
|
||||||
userRouter.patch("/:userId", updateUser);
|
|
||||||
userRouter.post("/:userId/reset-password", resetUserPassword);
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request";
|
|
||||||
export const WRITE_REQUEST_HEADER_VALUE = "1";
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export const USER_ROLES = ["admin", "user"] as const;
|
|
||||||
export type UserRole = (typeof USER_ROLES)[number];
|
|
||||||
|
|
||||||
export const USER_ROLE_LABEL: Record<UserRole, string> = {
|
|
||||||
admin: "Administrator",
|
|
||||||
user: "Benutzer",
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// Upper bounds keep a huge request body from being fed into scrypt.
|
|
||||||
export const MAX_USERNAME_LENGTH = 100;
|
|
||||||
export const MAX_PASSWORD_LENGTH = 200;
|
|
||||||
export const MIN_PASSWORD_LENGTH = 8;
|
|
||||||
|
|
||||||
export const loginSchema = z
|
|
||||||
.object({
|
|
||||||
username: z.string().trim().min(1).max(MAX_USERNAME_LENGTH),
|
|
||||||
password: z.string().min(1).max(MAX_PASSWORD_LENGTH),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const changePasswordSchema = z
|
|
||||||
.object({
|
|
||||||
currentPassword: z.string().min(1).max(MAX_PASSWORD_LENGTH),
|
|
||||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { USER_ROLES } from "../constants/user-roles.js";
|
|
||||||
import {
|
|
||||||
MAX_PASSWORD_LENGTH,
|
|
||||||
MAX_USERNAME_LENGTH,
|
|
||||||
MIN_PASSWORD_LENGTH,
|
|
||||||
} from "./auth.schemas.js";
|
|
||||||
|
|
||||||
// ASCII only: rules out look-alike Unicode names ("admіn" with a Cyrillic i)
|
|
||||||
// and invisible whitespace. E-mail addresses fit this pattern.
|
|
||||||
const USERNAME_PATTERN = /^[a-z0-9._@-]+$/;
|
|
||||||
|
|
||||||
export const createUserSchema = z
|
|
||||||
.object({
|
|
||||||
username: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1)
|
|
||||||
.max(MAX_USERNAME_LENGTH)
|
|
||||||
.regex(USERNAME_PATTERN, {
|
|
||||||
message: "Anmeldename: nur a-z, 0-9 sowie . _ @ - erlaubt.",
|
|
||||||
}),
|
|
||||||
name: z.string().trim().min(1).max(200),
|
|
||||||
role: z.enum(USER_ROLES),
|
|
||||||
initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const updateUserSchema = z
|
|
||||||
.object({
|
|
||||||
role: z.enum(USER_ROLES).optional(),
|
|
||||||
active: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.refine((value) => value.role !== undefined || value.active !== undefined, {
|
|
||||||
message: "Nothing to update.",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resetPasswordSchema = z
|
|
||||||
.object({
|
|
||||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
|
||||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
|
||||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
|
||||||
import { UserRepository } from "../src/db/repositories/user.repository.js";
|
|
||||||
import {
|
|
||||||
InvalidCredentialsError,
|
|
||||||
LastAdminError,
|
|
||||||
PasswordUnchangedError,
|
|
||||||
UserService,
|
|
||||||
UsernameTakenError,
|
|
||||||
WrongPasswordError,
|
|
||||||
} from "../src/domain/services/user.service.js";
|
|
||||||
import { hashPassword, verifyPassword } from "../src/server/auth/password.js";
|
|
||||||
import { SessionStore } from "../src/server/auth/sessions.js";
|
|
||||||
import { LoginThrottle } from "../src/server/auth/login-throttle.js";
|
|
||||||
import { readCookie } from "../src/server/auth/cookie.js";
|
|
||||||
import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js";
|
|
||||||
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;
|
|
||||||
|
|
||||||
function createUserService() {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
const repository = new UserRepository(context.db);
|
|
||||||
const config: AuthConfig = {
|
|
||||||
sessionTtlMs: 12 * HOUR,
|
|
||||||
sessionMaxAgeMs: 7 * 24 * HOUR,
|
|
||||||
cookieSecure: false,
|
|
||||||
bootstrap: { username: "admin", password: "bootstrap-password-123" },
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
repository,
|
|
||||||
service: new UserService(
|
|
||||||
repository,
|
|
||||||
{ bootstrap: config.bootstrap },
|
|
||||||
{ hash: hashPassword, verify: verifyPassword }
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("password hashing", () => {
|
|
||||||
it("verifies the right password and rejects a wrong one", async () => {
|
|
||||||
const hash = await hashPassword("richtiges-passwort");
|
|
||||||
assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
|
|
||||||
assert.equal(await verifyPassword("richtiges-passwort", hash), true);
|
|
||||||
assert.equal(await verifyPassword("falsches-passwort", hash), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a fresh salt per hash", async () => {
|
|
||||||
assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich"));
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("session store", () => {
|
|
||||||
it("expires idle sessions and slides on activity", () => {
|
|
||||||
let now = 0;
|
|
||||||
const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now);
|
|
||||||
const session = store.create("user-1");
|
|
||||||
now = 50 * 60 * 1000;
|
|
||||||
assert.ok(store.get(session.id));
|
|
||||||
now += 50 * 60 * 1000;
|
|
||||||
assert.ok(store.get(session.id), "activity must have extended the idle window");
|
|
||||||
now += 2 * HOUR;
|
|
||||||
assert.equal(store.get(session.id), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never outlives the absolute lifetime, even when constantly used", () => {
|
|
||||||
let now = 0;
|
|
||||||
const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now);
|
|
||||||
const session = store.create("user-1");
|
|
||||||
for (let step = 0; step < 5; step += 1) {
|
|
||||||
now += 30 * 60 * 1000;
|
|
||||||
store.get(session.id);
|
|
||||||
}
|
|
||||||
now = 3 * HOUR;
|
|
||||||
assert.equal(store.get(session.id), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ends all sessions of a user except an optional one", () => {
|
|
||||||
const store = new SessionStore(HOUR, 24 * HOUR);
|
|
||||||
const first = store.create("user-1");
|
|
||||||
const second = store.create("user-1");
|
|
||||||
const other = store.create("user-2");
|
|
||||||
store.destroyAllForUser("user-1", second.id);
|
|
||||||
assert.equal(store.get(first.id), undefined);
|
|
||||||
assert.ok(store.get(second.id));
|
|
||||||
assert.ok(store.get(other.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bounds the number of sessions per user by dropping the oldest", () => {
|
|
||||||
let now = 0;
|
|
||||||
const store = new SessionStore(HOUR, 24 * HOUR, () => now);
|
|
||||||
const oldest = store.create("user-1");
|
|
||||||
for (let index = 0; index < 20; index += 1) {
|
|
||||||
now += 1;
|
|
||||||
store.create("user-1");
|
|
||||||
}
|
|
||||||
assert.equal(store.get(oldest.id), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("login throttle", () => {
|
|
||||||
it("blocks a username after repeated failures and releases it after the window", () => {
|
|
||||||
let now = 0;
|
|
||||||
const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now);
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
assert.equal(throttle.retryAfterMs("Admin"), 0);
|
|
||||||
throttle.recordFailure("admin ");
|
|
||||||
}
|
|
||||||
assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter");
|
|
||||||
assert.equal(throttle.retryAfterMs("someone-else"), 0);
|
|
||||||
now = 15 * 60 * 1000 + 1;
|
|
||||||
assert.equal(throttle.retryAfterMs("admin"), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the counter after a successful login", () => {
|
|
||||||
const throttle = new LoginThrottle(2);
|
|
||||||
throttle.recordFailure("admin");
|
|
||||||
throttle.recordSuccess("admin");
|
|
||||||
throttle.recordFailure("admin");
|
|
||||||
assert.equal(throttle.retryAfterMs("admin"), 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cookie parsing", () => {
|
|
||||||
it("reads the session cookie and ignores malformed encodings", () => {
|
|
||||||
assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc");
|
|
||||||
assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined);
|
|
||||||
assert.equal(readCookie(undefined, "lb_session"), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("same-origin guard for writes", () => {
|
|
||||||
function run(method: string, fetchSite?: string, includeApplicationHeader = true) {
|
|
||||||
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];
|
|
||||||
},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = {
|
|
||||||
status(code: number) {
|
|
||||||
status = code;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
json() {
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
} as unknown as Response;
|
|
||||||
requireSameOriginForWrites()(req, res, () => {
|
|
||||||
passed = true;
|
|
||||||
});
|
|
||||||
return { passed, status };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("lets reads and same-origin writes through", () => {
|
|
||||||
assert.equal(run("GET", "cross-site", false).passed, true);
|
|
||||||
assert.equal(run("POST", "same-origin").passed, true);
|
|
||||||
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
|
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("input validation", () => {
|
|
||||||
it("rejects look-alike and whitespace usernames and normalizes case", () => {
|
|
||||||
const base = { name: "Test", role: "user", initialPassword: "start-passwort" };
|
|
||||||
assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false);
|
|
||||||
assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false);
|
|
||||||
const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " });
|
|
||||||
assert.equal(parsed.username, "max.muster@firma.de");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps login input length so huge bodies never reach scrypt", () => {
|
|
||||||
assert.equal(
|
|
||||||
loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("user service", () => {
|
|
||||||
it("creates the bootstrap admin only while the table is empty", async () => {
|
|
||||||
const { repository, service } = createUserService();
|
|
||||||
await service.ensureBootstrapAdmin();
|
|
||||||
await service.ensureBootstrapAdmin();
|
|
||||||
const users = await repository.list();
|
|
||||||
assert.equal(users.length, 1);
|
|
||||||
assert.equal(users[0].role, "admin");
|
|
||||||
assert.equal(users[0].mustChangePassword, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => {
|
|
||||||
const { service } = createUserService();
|
|
||||||
const user = await service.createUser({
|
|
||||||
username: "max",
|
|
||||||
name: "Max",
|
|
||||||
role: "user",
|
|
||||||
initialPassword: "start-passwort",
|
|
||||||
});
|
|
||||||
assert.ok(user);
|
|
||||||
await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError);
|
|
||||||
await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError);
|
|
||||||
assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id);
|
|
||||||
await service.updateRoleAndActive(user.id, { active: false });
|
|
||||||
await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses duplicate usernames", async () => {
|
|
||||||
const { service } = createUserService();
|
|
||||||
const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" };
|
|
||||||
await service.createUser(input);
|
|
||||||
await assert.rejects(service.createUser(input), UsernameTakenError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never removes the last active admin", async () => {
|
|
||||||
const { repository, service } = createUserService();
|
|
||||||
await service.ensureBootstrapAdmin();
|
|
||||||
const [admin] = await repository.list();
|
|
||||||
await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError);
|
|
||||||
await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError);
|
|
||||||
|
|
||||||
const second = await service.createUser({
|
|
||||||
username: "zweiter",
|
|
||||||
name: "Zweiter",
|
|
||||||
role: "admin",
|
|
||||||
initialPassword: "start-passwort",
|
|
||||||
});
|
|
||||||
assert.ok(second);
|
|
||||||
await service.updateRoleAndActive(admin.id, { role: "user" });
|
|
||||||
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({
|
|
||||||
username: "max",
|
|
||||||
name: "Max",
|
|
||||||
role: "user",
|
|
||||||
initialPassword: "start-passwort",
|
|
||||||
});
|
|
||||||
assert.ok(user);
|
|
||||||
await assert.rejects(
|
|
||||||
service.changeOwnPassword(user.id, "falsch", "neues-passwort"),
|
|
||||||
WrongPasswordError
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
service.changeOwnPassword(user.id, "start-passwort", "start-passwort"),
|
|
||||||
PasswordUnchangedError
|
|
||||||
);
|
|
||||||
const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort");
|
|
||||||
assert.equal(changed?.mustChangePassword, false);
|
|
||||||
|
|
||||||
const reset = await service.resetPassword(user.id, "admin-vergeben");
|
|
||||||
assert.equal(reset?.mustChangePassword, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue