diff --git a/CHANGELOG.md b/CHANGELOG.md
index fefff13..2a45430 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
+## [0.18.0] – 2026-09-19
+
+### Hinzugefügt
+- Drei Rollen: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Hosts und Rechnungen – ohne Einkaufspreise und Marge – und dürfen Notizen schreiben sowie Warnungen quittieren (#027)
+- Benutzerverwaltung unter *Einstellungen → Benutzer*: anlegen, Rolle ändern, sperren, Passwort setzen, löschen (#027)
+- Anmeldung mit Passkeys (WebAuthn), gleichwertig neben Passwort und OIDC; eigene Seite *Meine Passkeys* (#028)
+- Einstellung, welche Rolle automatisch angelegte OIDC-Konten bekommen (#027)
+
+### Behoben
+- Neu über OIDC angelegte Konten hätten die Vorgabe des Datenmodells geerbt und wären Administratoren geworden; Vorgabe ist jetzt *Techniker* (#027)
+- Das Screenshot-Skript meldete sich mit einem erfundenen Konto an – seit der Sitzungsprüfung gegen die Datenbank landete es auf der Anmeldeseite (#027)
+
## [0.17.0] – 2026-09-19
### Hinzugefügt
diff --git a/DEVLOG.md b/DEVLOG.md
index c4b6301..0475c27 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2,7 +2,7 @@
## Zeitübersicht
-**Gesamt:** 12 h 18 min
+**Gesamt:** 14 h 23 min
| Issue | Zeit |
|---|---|
@@ -32,6 +32,8 @@
| #024 OWASP-Härtung | 0 h 45 min |
| #025 Einstellungen | 1 h 10 min |
| #026 Auslieferung | 0 h 40 min |
+| #027 Rollen und Benutzerverwaltung | 1 h 20 min |
+| #028 Passkeys | 0 h 45 min |
| Meilenstein | Zeit |
|---|---|
@@ -54,7 +56,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
-**Version:** 0.17.0
+**Version:** 0.18.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -152,6 +154,10 @@
- Recherche dazu: Das Gerüst der community-scripts liegt inzwischen in einem eigenen Repository, und neue Skripte gehören zuerst nach ProxmoxVED. Eine Einreichung setzt öffentlichen Quelltext voraus – deshalb vorerst intern
- Roadmap geschrieben: Bis 1.0.0 fehlt kein Code mehr, sondern Erprobung (PMG, PBS mit Lizenz, ein Monat Dauerbetrieb, einmal eine Sicherung zurückspielen)
+- Rollen und Benutzerverwaltung (#027): Die Einschränkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben. Sechs Tests prüfen, dass ein Kundenkonto weder fremde Hosts noch Einkaufspreise sieht
+- Dabei aufgefallen: Neu angelegte OIDC-Konten hätten die Modellvorgabe ADMIN geerbt – behoben, mit eigener Einstellung und Test
+- Passkeys (#028): passwortlos, mehrere je Konto, Aufgabe in einem kurzlebigen Cookie. Nur über HTTPS nutzbar – die Seite sagt es, statt still zu scheitern
+
### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md
index 238eabc..d5b6137 100644
--- a/docs/anleitung/README.md
+++ b/docs/anleitung/README.md
@@ -18,6 +18,7 @@ auch mit Hosts hinter NAT.
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
9. [Exporte](#9-exporte)
9a. [Einstellungen](#9a-einstellungen)
+9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche)
@@ -341,6 +342,46 @@ Zeitzone gilt für PDF, CSV und Benachrichtigungen.
---
+## 9b. Benutzer, Rollen und Passkeys
+
+Unter *Einstellungen → Benutzer* legen Administratoren Konten an und vergeben eine von drei Rollen:
+
+
+
+| Rolle | Darf |
+|---|---|
+| **Administrator** | alles, einschließlich Benutzer und Systemeinstellungen |
+| **Techniker** | alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer |
+| **Kunde** | nur die Hosts und Rechnungen des eigenen Kunden, **ohne Einkaufspreise und Marge**; darf Notizen schreiben und Warnungen quittieren |
+
+Ein Kundenkonto ist an genau einen Kunden gebunden – das Feld erscheint, sobald die Rolle „Kunde“
+gewählt ist. Die Einschränkung sitzt in den Abfragen selbst, nicht in der Anzeige: Was ein Kundenkonto
+nicht sehen darf, steht auch nicht im ausgelieferten HTML.
+
+Ein Passwort ist beim Anlegen freiwillig. Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder
+einen Passkey anmeldet. **Ein neues Passwort beendet alle laufenden Sitzungen dieses Kontos**, ebenso
+das Sperren.
+
+### Passkeys
+
+Ein Passkey ersetzt das Passwort: Der Browser bestätigt die Anmeldung mit Fingerabdruck, Gesicht, PIN
+oder einem Sicherheitsschlüssel. Jeder Benutzer verwaltet seine eigenen unter *Meine Passkeys*:
+
+
+
+Zwei Dinge sind wichtig:
+
+1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://`
+ im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt.
+2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf
+ `https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy
+ einrichten, dann Passkeys anlegen.
+
+Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als
+Rückweg immer nutzbar.
+
+---
+
## 10. Sicherung und Aktualisierung
**Sichern:**
diff --git a/docs/anleitung/bilder/audit.png b/docs/anleitung/bilder/audit.png
index 206ebae..4d07f1f 100644
Binary files a/docs/anleitung/bilder/audit.png and b/docs/anleitung/bilder/audit.png differ
diff --git a/docs/anleitung/bilder/benachrichtigungen.png b/docs/anleitung/bilder/benachrichtigungen.png
index b020bd6..20df2c1 100644
Binary files a/docs/anleitung/bilder/benachrichtigungen.png and b/docs/anleitung/bilder/benachrichtigungen.png differ
diff --git a/docs/anleitung/bilder/benutzer.png b/docs/anleitung/bilder/benutzer.png
new file mode 100644
index 0000000..12fd127
Binary files /dev/null and b/docs/anleitung/bilder/benutzer.png differ
diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png
index 214201f..94b55a1 100644
Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ
diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png
index 11d27dc..dc17b75 100644
Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ
diff --git a/docs/anleitung/bilder/hosts.png b/docs/anleitung/bilder/hosts.png
index 866afd8..5061f71 100644
Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ
diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png
index 3a43f82..d937539 100644
Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ
diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png
index c8eb81e..1ff119d 100644
Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ
diff --git a/docs/anleitung/bilder/login.png b/docs/anleitung/bilder/login.png
index c82c561..40f4441 100644
Binary files a/docs/anleitung/bilder/login.png and b/docs/anleitung/bilder/login.png differ
diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png
index 636ffd1..0a54ca8 100644
Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ
diff --git a/docs/anleitung/bilder/passkeys.png b/docs/anleitung/bilder/passkeys.png
new file mode 100644
index 0000000..a147cf6
Binary files /dev/null and b/docs/anleitung/bilder/passkeys.png differ
diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png
index b410355..c9b1831 100644
Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ
diff --git a/docs/anleitung/bilder/system.png b/docs/anleitung/bilder/system.png
index a30d3ff..cfe41ab 100644
Binary files a/docs/anleitung/bilder/system.png and b/docs/anleitung/bilder/system.png differ
diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png
index bb0e3f2..301b12a 100644
Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ
diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png
index 5314533..b8cb1c1 100644
Binary files a/docs/anleitung/bilder/warnregeln.png and b/docs/anleitung/bilder/warnregeln.png differ
diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png
index 18520c4..6ce2938 100644
Binary files a/docs/anleitung/bilder/warnungen.png and b/docs/anleitung/bilder/warnungen.png differ
diff --git a/docs/issues/027-rollen-benutzerverwaltung.md b/docs/issues/027-rollen-benutzerverwaltung.md
new file mode 100644
index 0000000..e665571
--- /dev/null
+++ b/docs/issues/027-rollen-benutzerverwaltung.md
@@ -0,0 +1,36 @@
+# #027 Rollen und Benutzerverwaltung
+**Status:** erledigt
+**Meilenstein:** v0.18.0
+**Labels:** security, backend, ui
+**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min
+
+## Ziel
+Bisher durfte jeder Angemeldete alles – in `docs/sicherheit.md` als offener Punkt vermerkt. Es soll
+drei Rollen geben: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Daten.
+
+## Aufgaben
+- [x] `Rolle` im Datenmodell, Kundenbindung am Benutzer
+- [x] Rechte an **einer** Stelle (`src/server/auth/rollen.ts`), nicht verteilt über Seiten
+- [x] `sitzungErforderlich(recht)` – jede der 28 Aktionen nennt ihr Recht
+- [x] Sicht einschränken: Hosts, Kunden, Rechnungen, Kennzahlen, Suche, Jahresbahn, Dashboard
+- [x] Einkaufspreise und Marge für Kundenkonten entfernen – in der Abfrage, nicht in der Anzeige
+- [x] Schreibrechte für Kunden: Notizen am eigenen Host, Warnungen quittieren
+- [x] Benutzerverwaltung unter *Einstellungen → Benutzer*
+- [x] Menü nach Rolle
+- [x] Tests
+
+## Entscheidungen
+- **Rolle und Kundenbindung kommen aus der Datenbank, nicht aus dem Token.** Eine geänderte Rolle
+ wirkt damit sofort statt erst nach Ablauf der Sitzung.
+- **Die Abfragen holen sich die Einschränkung selbst** (`sichtbarerKunde()`), statt sie von der Seite
+ gereicht zu bekommen. Eine Seite kann so nicht vergessen, sie weiterzugeben – der häufigste Weg,
+ wie solche Sichten lecken.
+- **Ein Kundenkonto ohne Kundenbindung sieht nichts** statt alles. Der Rückfall zeigt bewusst ins
+ Leere, nicht auf den vollen Bestand.
+- **Ausgeblendete Menüpunkte sind keine Zugriffskontrolle.** Sie sind Bequemlichkeit; geprüft wird in
+ Seite und Aktion.
+
+## Beim Bauen aufgefallen
+Neu angelegte OIDC-Konten hätten die Vorgabe des Datenmodells geerbt – **ADMIN**. Wer sich beim
+Identitätsanbieter anmelden darf, wäre damit nebenbei Administrator geworden. Jetzt gibt es dafür eine
+eigene Einstellung mit der Vorgabe *Techniker*, samt Test.
diff --git a/docs/issues/028-passkeys.md b/docs/issues/028-passkeys.md
new file mode 100644
index 0000000..63ffa33
--- /dev/null
+++ b/docs/issues/028-passkeys.md
@@ -0,0 +1,35 @@
+# #028 Anmeldung mit Passkeys
+**Status:** erledigt
+**Meilenstein:** v0.18.0
+**Labels:** security, backend, ui
+**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
+
+## Ziel
+Passkeys (WebAuthn) als **gleichwertiger** Anmeldeweg neben Passwort und OIDC – passwortlos, mit
+Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel.
+
+## Aufgaben
+- [x] `@simplewebauthn/server` und `@simplewebauthn/browser`
+- [x] Datenmodell `Passkey` (mehrere je Konto)
+- [x] Registrierung und Anmeldung als Endpunkte, Aufgabe („challenge“) in einem kurzlebigen Cookie
+- [x] Seite *Meine Passkeys* mit Anlegen und Entfernen
+- [x] Knopf „Mit Passkey anmelden“ auf der Anmeldeseite
+- [x] Hinweis, wenn die Adresse kein HTTPS hat
+
+## Entscheidungen
+- **Keine Liste erlaubter Schlüssel bei der Anmeldung.** Der Browser bietet selbst an, was zu dieser
+ Seite passt. Sonst müsste die Seite vorher verraten, welche Passkeys zu einem Konto gehören – das
+ wäre ein Weg, Konten auszuspähen.
+- **Fehlermeldung bei der Anmeldung bewusst unscharf** („Anmeldung mit diesem Passkey nicht
+ möglich“). Ob es den Schlüssel gibt, geht einen Unbekannten nichts an.
+- **Die Aufgabe liegt in einem `SameSite=strict`-Cookie mit fünf Minuten Gültigkeit**, nicht in der
+ Datenbank. Sie gilt nur für diesen Browser und diesen Versuch.
+- **`rpID` und `origin` kommen aus der öffentlichen Adresse** (Einstellung, sonst `APP_URL`, sonst
+ Proxy-Kopfzeilen). Ein Passkey ist an die Domäne gebunden; steht dort die falsche Adresse, lehnt
+ der Browser wortlos ab.
+- Passwort und OIDC bleiben bestehen. Ein Passkey, der im kaputten Telefon liegt, darf niemanden
+ aussperren.
+
+## Grenze
+Ein Passkey gilt nur für die Adresse, unter der er angelegt wurde. Beim Umzug von `http://server:3000`
+auf `https://proxledger.firma.de` müssen alle Passkeys neu registriert werden – im Handbuch vermerkt.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 8915993..49d2316 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -30,4 +30,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min |
-| | **Summe** | | | **133 h** | **12 h 18 min** |
+| [#027](027-rollen-benutzerverwaltung.md) | Rollen und Benutzerverwaltung | erledigt | v0.18.0 | 6 h | 1 h 20 min |
+| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min |
+| | **Summe** | | | **143 h** | **14 h 23 min** |
diff --git a/docs/sicherheit.md b/docs/sicherheit.md
index 4d93fdc..bcc59dd 100644
--- a/docs/sicherheit.md
+++ b/docs/sicherheit.md
@@ -15,8 +15,13 @@ Die Gliederung folgt den OWASP Top 10 (2021).
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
-**Nicht vorhanden:** Rollen. Wer angemeldet ist, darf alles. Für ein Werkzeug, das ein Systemhaus intern
-betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht.
+**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
+die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden
+gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge
+werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML.
+
+**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer
+Installation wären ein Umbau, kein Anbau.
## A02 – Kryptografische Fehler
@@ -70,8 +75,13 @@ gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
-**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die
-Passwortanmeldung ab.
+- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
+ mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`).
+- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht
+ Administrator.
+
+**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides
+in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor.
## A08 – Datenintegrität
diff --git a/package-lock.json b/package-lock.json
index 0c6c373..392247d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,20 @@
{
"name": "proxledger",
- "version": "0.14.1",
+ "version": "0.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxledger",
- "version": "0.14.1",
+ "version": "0.17.0",
"hasInstallScript": true,
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
+ "@simplewebauthn/browser": "^14.0.0",
+ "@simplewebauthn/server": "^14.0.2",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
@@ -944,6 +946,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+ "license": "MIT"
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1609,6 +1617,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
@@ -2147,6 +2161,239 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
+ "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-asym-key": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz",
+ "integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-pkcs8": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
+ "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "@peculiar/asn1-x509-attr": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
+ "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
+ "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
+ "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.4",
+ "@peculiar/asn1-pkcs8": "^2.9.4",
+ "@peculiar/asn1-rsa": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
+ "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
+ "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.4",
+ "@peculiar/asn1-pfx": "^2.9.4",
+ "@peculiar/asn1-pkcs8": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "@peculiar/asn1-x509-attr": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
+ "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
+ "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
+ "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
+ "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-post-quantum": {
+ "version": "2.9.4",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz",
+ "integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-asym-key": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
+ "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.4",
+ "@peculiar/asn1-csr": "^2.9.4",
+ "@peculiar/asn1-ecc": "^2.9.4",
+ "@peculiar/asn1-pkcs9": "^2.9.4",
+ "@peculiar/asn1-rsa": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3031,6 +3278,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
+ "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
+ "license": "MIT"
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz",
+ "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.1",
+ "@peculiar/asn1-rsa": "^2.6.1",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.1",
+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",
+ "@peculiar/x509": "^2.1.0",
+ "reflect-metadata": "^0.2.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4339,6 +4613,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -8667,6 +8955,24 @@
],
"license": "MIT"
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
+ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -8749,6 +9055,12 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9703,6 +10015,24 @@
"fsevents": "~2.3.3"
}
},
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index e8083af..4fc44aa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "proxledger",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -34,6 +34,8 @@
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
+ "@simplewebauthn/browser": "^14.0.0",
+ "@simplewebauthn/server": "^14.0.2",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
diff --git a/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql b/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql
new file mode 100644
index 0000000..809bf27
--- /dev/null
+++ b/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql
@@ -0,0 +1,36 @@
+-- CreateEnum
+CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'TECHNIKER', 'KUNDE');
+
+-- AlterTable
+ALTER TABLE "Benutzer" ADD COLUMN "kundeId" TEXT,
+ADD COLUMN "rolle" "Rolle" NOT NULL DEFAULT 'ADMIN';
+
+-- CreateTable
+CREATE TABLE "Passkey" (
+ "id" TEXT NOT NULL,
+ "benutzerId" TEXT NOT NULL,
+ "credentialId" TEXT NOT NULL,
+ "publicKey" BYTEA NOT NULL,
+ "zaehler" BIGINT NOT NULL DEFAULT 0,
+ "transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "bezeichnung" TEXT NOT NULL,
+ "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "zuletztAm" TIMESTAMP(3),
+
+ CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
+
+-- CreateIndex
+CREATE INDEX "Passkey_benutzerId_idx" ON "Passkey"("benutzerId");
+
+-- CreateIndex
+CREATE INDEX "Benutzer_kundeId_idx" ON "Benutzer"("kundeId");
+
+-- AddForeignKey
+ALTER TABLE "Benutzer" ADD CONSTRAINT "Benutzer_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5604a12..f5ebc13 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -72,6 +72,7 @@ model Kunde {
alerts Alert[]
@@index([name])
+ benutzer Benutzer[]
}
model Standort {
@@ -448,12 +449,23 @@ model Benachrichtigungskanal {
// ---------------------------------------------------------------- Benutzer und Protokoll
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
+/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027).
+enum Rolle {
+ ADMIN
+ TECHNIKER
+ KUNDE
+}
+
model Benutzer {
id String @id @default(cuid())
email String @unique
name String
passwortHash String?
oidcSubject String? @unique
+ /// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN.
+ rolle Rolle @default(ADMIN)
+ /// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist.
+ kundeId String?
aktiv Boolean @default(true)
letzterLoginAm DateTime?
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
@@ -462,6 +474,28 @@ model Benutzer {
geaendertAm DateTime @updatedAt
auditEintraege AuditEintrag[]
+ kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
+ passkeys Passkey[]
+
+ @@index([kundeId])
+}
+
+/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028).
+model Passkey {
+ id String @id @default(cuid())
+ benutzerId String
+ /// Kennung des Schlüssels, base64url wie vom Browser geliefert.
+ credentialId String @unique
+ publicKey Bytes
+ zaehler BigInt @default(0)
+ transports String[] @default([])
+ bezeichnung String
+ erstelltAm DateTime @default(now())
+ zuletztAm DateTime?
+
+ benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
+
+ @@index([benutzerId])
}
model AuditEintrag {
diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs
index 9d2d5f5..4f7c15f 100644
--- a/scripts/screenshots.mjs
+++ b/scripts/screenshots.mjs
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose";
+import pg from "pg";
import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
@@ -49,6 +50,8 @@ const SEITEN = [
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
+ { name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
+ { name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
];
async function sitzungsToken() {
@@ -57,14 +60,29 @@ async function sitzungsToken() {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
}
+ // Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
+ // Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
+ // Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
+ const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
+ await datenbank.connect();
+ const { rows } = await datenbank.query(
+ `SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
+ );
+ await datenbank.end();
+ const benutzer = rows[0];
+
+ if (!benutzer) {
+ throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
+ }
+
return new SignJWT({
- benutzerId: "screenshots",
- email: process.env.ADMIN_EMAIL ?? "admin@example.de",
- name: process.env.ADMIN_NAME ?? "Florian",
+ benutzerId: benutzer.id,
+ email: benutzer.email,
+ name: benutzer.name,
herkunft: "passwort",
})
.setProtectedHeader({ alg: "HS256" })
- .setSubject("screenshots")
+ .setSubject(benutzer.id)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime("1h")
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index c2c9274..23a1272 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
+import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
import { Logo } from "@/components/Logo";
import { oidcEinstellungen } from "@/server/auth/oidc";
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
Mit {oidc.bezeichnung} anmelden
-
oder mit Passwort
+ oder
>
) : null}
+
+
+ oder mit Passwort
+
diff --git a/src/app/(dashboard)/einstellungen/benutzer/page.tsx b/src/app/(dashboard)/einstellungen/benutzer/page.tsx
new file mode 100644
index 0000000..6d50024
--- /dev/null
+++ b/src/app/(dashboard)/einstellungen/benutzer/page.tsx
@@ -0,0 +1,55 @@
+import { BenutzerVerwaltung } from "@/components/einstellungen/BenutzerVerwaltung";
+import { ContentHeader } from "@/components/layout/ContentHeader";
+import { Callout } from "@/components/ui/Callout";
+import { prisma } from "@/lib/db";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+
+export const metadata = { title: "Benutzer" };
+export const dynamic = "force-dynamic";
+
+export default async function BenutzerSeite() {
+ const ich = await sitzungErforderlich("verwaltung");
+
+ const [benutzer, kunden] = await Promise.all([
+ prisma.benutzer.findMany({
+ orderBy: [{ rolle: "asc" }, { name: "asc" }],
+ include: { kunde: { select: { name: true } }, _count: { select: { passkeys: true } } },
+ }),
+ prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
+ ]);
+
+ const zeilen = benutzer.map((eintrag) => ({
+ id: eintrag.id,
+ email: eintrag.email,
+ name: eintrag.name,
+ rolle: eintrag.rolle,
+ kundeId: eintrag.kundeId,
+ kundeName: eintrag.kunde?.name ?? null,
+ aktiv: eintrag.aktiv,
+ hatPasswort: eintrag.passwortHash !== null,
+ hatOidc: eintrag.oidcSubject !== null,
+ passkeys: eintrag._count.passkeys,
+ letzterLoginAm: eintrag.letzterLoginAm,
+ }));
+
+ return (
+ <>
+
+
+
+
+ Administrator darf alles, auch Benutzer und Systemeinstellungen.{" "}
+ Techniker darf alles Fachliche, aber keine Benutzer anlegen.{" "}
+ Kunde sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
+ Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/einstellungen/passkeys/page.tsx b/src/app/(dashboard)/einstellungen/passkeys/page.tsx
new file mode 100644
index 0000000..bdfcd4f
--- /dev/null
+++ b/src/app/(dashboard)/einstellungen/passkeys/page.tsx
@@ -0,0 +1,47 @@
+import { headers } from "next/headers";
+import { PasskeyVerwaltung } from "@/components/auth/PasskeyVerwaltung";
+import { ContentHeader } from "@/components/layout/ContentHeader";
+import { oeffentlicheBasis } from "@/lib/adresse";
+import { prisma } from "@/lib/db";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { passkeysMoeglich } from "@/server/auth/passkeys";
+import { einstellungen } from "@/server/einstellungen";
+
+export const metadata = { title: "Meine Passkeys" };
+export const dynamic = "force-dynamic";
+
+export default async function PasskeySeite() {
+ const ich = await sitzungErforderlich();
+ const { dashboardUrl } = await einstellungen();
+
+ const [passkeys, kopfzeilen] = await Promise.all([
+ prisma.passkey.findMany({
+ where: { benutzerId: ich.benutzerId },
+ orderBy: { erstelltAm: "desc" },
+ select: { id: true, bezeichnung: true, erstelltAm: true, zuletztAm: true },
+ }),
+ headers(),
+ ]);
+
+ // Die Seite kennt die Anfrage nur über die Kopfzeilen; daraus entsteht dieselbe Adresse,
+ // die auch die Passkey-Endpunkte verwenden.
+ const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), dashboardUrl);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index 59b657a..3aa2f0e 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -1,9 +1,7 @@
-import { cookies } from "next/headers";
-import { redirect } from "next/navigation";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar";
-import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
/**
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
export const dynamic = "force-dynamic";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
- // Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
- const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
- if (!sitzung) redirect("/login");
+ // Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
+ // entsteht – und weil hier Rolle und Kundenbindung herkommen.
+ const sitzung = await sitzungErforderlich();
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
}))}
suchEintraege={suche}
benutzer={sitzung.name}
+ rolle={sitzung.rolle}
/>
{children}
diff --git a/src/app/api/auth/passkey/anmelden/route.ts b/src/app/api/auth/passkey/anmelden/route.ts
new file mode 100644
index 0000000..d89ef0b
--- /dev/null
+++ b/src/app/api/auth/passkey/anmelden/route.ts
@@ -0,0 +1,64 @@
+import { cookies } from "next/headers";
+import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen, ueberHttps } from "@/lib/sitzung";
+import { prisma } from "@/lib/db";
+import { protokolliere } from "@/server/audit";
+import { anmeldungPruefen, anmeldungVorbereiten, herkunft } from "@/server/auth/passkeys";
+import { einstellungen } from "@/server/einstellungen";
+
+export const dynamic = "force-dynamic";
+
+const CHALLENGE_COOKIE = "pl_passkey_anmeldung";
+
+function challengeOptionen() {
+ return {
+ httpOnly: true,
+ sameSite: "strict" as const,
+ secure: ueberHttps(),
+ path: "/",
+ maxAge: 300,
+ };
+}
+
+/** Schritt 1: Aufgabe für die Anmeldung erzeugen – ohne Benutzernamen, der Browser wählt den Schlüssel. */
+export async function GET(request: Request) {
+ const optionen = await anmeldungVorbereiten(await herkunft(request));
+ (await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
+ return Response.json(optionen);
+}
+
+/** Schritt 2: Antwort prüfen und die Sitzung anlegen. */
+export async function POST(request: Request) {
+ const speicher = await cookies();
+ const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
+ if (!challenge) {
+ return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
+ }
+
+ try {
+ const antwort = await request.json();
+ const benutzer = await anmeldungPruefen(antwort, challenge, await herkunft(request));
+
+ const { sitzungsdauerStunden } = await einstellungen();
+ const token = await sitzungErzeugen(
+ {
+ benutzerId: benutzer.id,
+ email: benutzer.email,
+ name: benutzer.name,
+ herkunft: "passkey",
+ },
+ sitzungsdauerStunden,
+ );
+ speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
+
+ await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
+ await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passkey" });
+
+ return Response.json({ erfolg: true });
+ } catch (fehler) {
+ console.error("Passkey-Anmeldung fehlgeschlagen:", fehler);
+ // Bewusst unscharf: Ob es den Passkey gibt, geht einen Unbekannten nichts an.
+ return Response.json({ fehler: "Anmeldung mit diesem Passkey nicht möglich." }, { status: 400 });
+ } finally {
+ speicher.delete(CHALLENGE_COOKIE);
+ }
+}
diff --git a/src/app/api/auth/passkey/registrieren/route.ts b/src/app/api/auth/passkey/registrieren/route.ts
new file mode 100644
index 0000000..2b6868d
--- /dev/null
+++ b/src/app/api/auth/passkey/registrieren/route.ts
@@ -0,0 +1,63 @@
+import { cookies } from "next/headers";
+import { ueberHttps } from "@/lib/sitzung";
+import { protokolliere } from "@/server/audit";
+import { angemeldet } from "@/server/auth/sitzungspflicht";
+import { herkunft, registrierungAbschliessen, registrierungVorbereiten } from "@/server/auth/passkeys";
+
+export const dynamic = "force-dynamic";
+
+/** Die Aufgabe („challenge“) darf nur für diesen Browser und nur kurz gelten. */
+const CHALLENGE_COOKIE = "pl_passkey_registrierung";
+
+function challengeOptionen() {
+ return {
+ httpOnly: true,
+ sameSite: "strict" as const,
+ secure: ueberHttps(),
+ path: "/",
+ maxAge: 300,
+ };
+}
+
+/** Schritt 1: Aufgabe erzeugen. */
+export async function GET(request: Request) {
+ const benutzer = await angemeldet();
+ if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
+
+ const optionen = await registrierungVorbereiten(benutzer.benutzerId, await herkunft(request));
+ (await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
+
+ return Response.json(optionen);
+}
+
+/** Schritt 2: Antwort des Browsers prüfen und den Passkey hinterlegen. */
+export async function POST(request: Request) {
+ const benutzer = await angemeldet();
+ if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
+
+ const speicher = await cookies();
+ const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
+ if (!challenge) return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
+
+ try {
+ const { antwort, bezeichnung } = await request.json();
+ const passkey = await registrierungAbschliessen(
+ benutzer.benutzerId,
+ antwort,
+ challenge,
+ await herkunft(request),
+ typeof bezeichnung === "string" ? bezeichnung : "Passkey",
+ );
+
+ await protokolliere("passkey.angelegt", "Passkey", passkey.id, { bezeichnung: passkey.bezeichnung });
+ return Response.json({ erfolg: true, bezeichnung: passkey.bezeichnung });
+ } catch (fehler) {
+ console.error("Passkey-Registrierung fehlgeschlagen:", fehler);
+ return Response.json(
+ { fehler: fehler instanceof Error ? fehler.message : "Der Passkey ließ sich nicht hinterlegen." },
+ { status: 400 },
+ );
+ } finally {
+ speicher.delete(CHALLENGE_COOKIE);
+ }
+}
diff --git a/src/components/auth/PasskeyAnmeldung.tsx b/src/components/auth/PasskeyAnmeldung.tsx
new file mode 100644
index 0000000..af2ff3f
--- /dev/null
+++ b/src/components/auth/PasskeyAnmeldung.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { startAuthentication } from "@simplewebauthn/browser";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+/**
+ * Anmeldung per Passkey. Der Knopf erscheint immer; ob ein Passkey vorliegt, weiß erst der Browser –
+ * und das darf die Seite auch nicht vorher verraten (sonst ließen sich Konten ausspähen).
+ */
+export function PasskeyAnmeldung({ ziel }: { ziel: string }) {
+ const router = useRouter();
+ const [laeuft, setLaeuft] = useState(false);
+ const [fehler, setFehler] = useState(null);
+
+ async function anmelden() {
+ setLaeuft(true);
+ setFehler(null);
+
+ try {
+ const optionen = await fetch("/api/auth/passkey/anmelden").then((antwort) => antwort.json());
+ const antwort = await startAuthentication({ optionsJSON: optionen });
+
+ const ergebnis = await fetch("/api/auth/passkey/anmelden", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(antwort),
+ }).then((a) => a.json());
+
+ if (ergebnis.erfolg) {
+ router.push(ziel.startsWith("/") ? ziel : "/");
+ router.refresh();
+ } else {
+ setFehler(ergebnis.fehler ?? "Anmeldung nicht möglich.");
+ }
+ } catch (fehler) {
+ setFehler(
+ fehler instanceof Error && fehler.name === "NotAllowedError"
+ ? "Abgebrochen."
+ : "Mit diesem Gerät geht es nicht. Passkeys brauchen HTTPS.",
+ );
+ } finally {
+ setLaeuft(false);
+ }
+ }
+
+ return (
+ <>
+ {fehler ? (
+
+ {fehler}
+
+ ) : null}
+
+
+
+ {laeuft ? "Warte auf den Browser …" : "Mit Passkey anmelden"}
+
+ >
+ );
+}
diff --git a/src/components/auth/PasskeyVerwaltung.tsx b/src/components/auth/PasskeyVerwaltung.tsx
new file mode 100644
index 0000000..e11f3e7
--- /dev/null
+++ b/src/components/auth/PasskeyVerwaltung.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { startRegistration } from "@simplewebauthn/browser";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { Card } from "@/components/ui/Card";
+import { formatDatumZeit } from "@/lib/format";
+import { passkeyLoeschen } from "@/server/actions/passkeys";
+
+export interface PasskeyZeile {
+ id: string;
+ bezeichnung: string;
+ erstelltAm: Date;
+ zuletztAm: Date | null;
+}
+
+export function PasskeyVerwaltung({
+ passkeys,
+ moeglich,
+ adresse,
+}: {
+ passkeys: PasskeyZeile[];
+ moeglich: boolean;
+ adresse: string;
+}) {
+ const router = useRouter();
+ const [bezeichnung, setBezeichnung] = useState("");
+ const [meldung, setMeldung] = useState<{ art: "fehler" | "erfolg"; text: string } | null>(null);
+ const [laeuft, setLaeuft] = useState(false);
+
+ async function registrieren() {
+ setLaeuft(true);
+ setMeldung(null);
+
+ try {
+ const optionen = await fetch("/api/auth/passkey/registrieren").then((antwort) => antwort.json());
+ // Hier öffnet der Browser seine eigene Abfrage (Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel).
+ const antwort = await startRegistration({ optionsJSON: optionen });
+
+ const ergebnis = await fetch("/api/auth/passkey/registrieren", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ antwort, bezeichnung }),
+ }).then((a) => a.json());
+
+ if (ergebnis.fehler) {
+ setMeldung({ art: "fehler", text: ergebnis.fehler });
+ } else {
+ setMeldung({ art: "erfolg", text: `„${ergebnis.bezeichnung}“ ist hinterlegt.` });
+ setBezeichnung("");
+ router.refresh();
+ }
+ } catch (fehler) {
+ // Abbruch durch den Benutzer ist kein Fehler, sieht aber genauso aus – deshalb neutral formulieren.
+ setMeldung({
+ art: "fehler",
+ text:
+ fehler instanceof Error && fehler.name === "NotAllowedError"
+ ? "Abgebrochen oder abgelehnt."
+ : "Der Passkey konnte nicht angelegt werden.",
+ });
+ } finally {
+ setLaeuft(false);
+ }
+ }
+
+ return (
+ <>
+
+ {!moeglich ? (
+
+ Über diese Adresse geht es nicht. Passkeys verlangen HTTPS; nur{" "}
+ localhost ist ausgenommen. Aufgerufen wurde {adresse}. Richten Sie
+ einen Reverse Proxy mit Zertifikat ein und tragen Sie die Adresse unter{" "}
+ Einstellungen → System ein.
+
+ ) : null}
+
+ {meldung ? (
+
+ {meldung.text}
+
+ ) : null}
+
+
+
+
+ Bezeichnung
+
+
setBezeichnung(e.target.value)}
+ placeholder="z. B. Arbeitsrechner oder Telefon"
+ disabled={!moeglich}
+ />
+
Hilft später beim Wiedererkennen, wenn mehrere hinterlegt sind.
+
+
+
+
+ {laeuft ? "Warte auf den Browser …" : "Passkey anlegen"}
+
+
+
+
+
+
+ {passkeys.length === 0 ? (
+
+ Noch keiner. Ohne Passkey bleibt die Anmeldung per Passwort oder OIDC – beides funktioniert
+ weiterhin.
+
+ ) : (
+
+
+
+
+ Bezeichnung
+ Angelegt
+ Zuletzt benutzt
+
+ Aktion
+
+
+
+
+ {passkeys.map((passkey) => (
+
+ {passkey.bezeichnung}
+ {formatDatumZeit(passkey.erstelltAm)}
+
+ {passkey.zuletztAm ? formatDatumZeit(passkey.zuletztAm) : "noch nie"}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/components/einstellungen/BenutzerVerwaltung.tsx b/src/components/einstellungen/BenutzerVerwaltung.tsx
new file mode 100644
index 0000000..b4c983d
--- /dev/null
+++ b/src/components/einstellungen/BenutzerVerwaltung.tsx
@@ -0,0 +1,316 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { Card } from "@/components/ui/Card";
+import type { Rolle } from "@/generated/prisma/enums";
+import { formatDatumZeit } from "@/lib/format";
+import { PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
+import {
+ type AktionsErgebnis,
+ benutzerAnlegen,
+ benutzerLoeschen,
+ benutzerSpeichern,
+ benutzerSperreUmschalten,
+} from "@/server/actions/benutzer";
+import { ROLLEN_BESCHREIBUNG, ROLLEN_TEXT } from "@/server/auth/rollen";
+
+export interface BenutzerZeile {
+ id: string;
+ email: string;
+ name: string;
+ rolle: Rolle;
+ kundeId: string | null;
+ kundeName: string | null;
+ aktiv: boolean;
+ hatPasswort: boolean;
+ hatOidc: boolean;
+ passkeys: number;
+ letzterLoginAm: Date | null;
+}
+
+const LEER: AktionsErgebnis = {};
+const ROLLEN: Rolle[] = ["ADMIN", "TECHNIKER", "KUNDE"];
+
+function RollenFelder({
+ eintrag,
+ kunden,
+ fehler,
+}: {
+ eintrag?: BenutzerZeile;
+ kunden: { id: string; name: string }[];
+ fehler: Record;
+}) {
+ const [rolle, setRolle] = useState(eintrag?.rolle ?? "TECHNIKER");
+
+ return (
+ <>
+
+
+ Rolle
+
+
setRolle(e.target.value as Rolle)}
+ >
+ {ROLLEN.map((wert) => (
+
+ {ROLLEN_TEXT[wert]}
+
+ ))}
+
+
{ROLLEN_BESCHREIBUNG[rolle]}
+
+
+ {/* Der Kunde zählt nur für ein Kundenkonto – deshalb erscheint das Feld auch nur dann. */}
+ {rolle === "KUNDE" ? (
+
+
+ Kunde
+
+
+ – bitte wählen –
+ {kunden.map((kunde) => (
+
+ {kunde.name}
+
+ ))}
+
+ {fehler.kundeId ?
{fehler.kundeId}
: null}
+
+ ) : null}
+ >
+ );
+}
+
+function NeuFormular({ kunden }: { kunden: { id: string; name: string }[] }) {
+ const [ergebnis, aktion, laeuft] = useActionState(benutzerAnlegen, LEER);
+ const fehler = ergebnis.felder ?? {};
+
+ return (
+
+
+
+ );
+}
+
+function Zeile({
+ eintrag,
+ kunden,
+ ichSelbst,
+}: {
+ eintrag: BenutzerZeile;
+ kunden: { id: string; name: string }[];
+ ichSelbst: boolean;
+}) {
+ const [ergebnis, aktion, laeuft] = useActionState(benutzerSpeichern, LEER);
+ const [offen, setOffen] = useState(false);
+ const fehler = ergebnis.felder ?? {};
+
+ const anmeldewege = [
+ eintrag.hatPasswort ? "Passwort" : null,
+ eintrag.hatOidc ? "OIDC" : null,
+ eintrag.passkeys > 0 ? `${eintrag.passkeys} Passkey${eintrag.passkeys === 1 ? "" : "s"}` : null,
+ ].filter(Boolean);
+
+ return (
+ <>
+
+
+ setOffen(!offen)}>
+ {eintrag.name}
+
+ {ichSelbst ? Sie : null}
+ {eintrag.email}
+
+
+ {ROLLEN_TEXT[eintrag.rolle]}
+ {eintrag.kundeName ? {eintrag.kundeName}
: null}
+
+
+ {anmeldewege.length > 0 ? anmeldewege.join(" · ") : kein Zugang }
+
+
+ {eintrag.letzterLoginAm ? formatDatumZeit(eintrag.letzterLoginAm) : "noch nie"}
+
+
+ {eintrag.aktiv ? (
+ aktiv
+ ) : (
+ gesperrt
+ )}
+
+
+
+ setOffen(!offen)}>
+ {offen ? "Schließen" : "Bearbeiten"}
+
+ {!ichSelbst ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+ {offen ? (
+
+
+
+
+
+ ) : null}
+ >
+ );
+}
+
+export function BenutzerVerwaltung({
+ benutzer,
+ kunden,
+ ichId,
+}: {
+ benutzer: BenutzerZeile[];
+ kunden: { id: string; name: string }[];
+ ichId: string;
+}) {
+ return (
+ <>
+
+
+
+
+
+
+
+ Name
+ Rolle
+ Anmeldung
+ Zuletzt angemeldet
+ Zustand
+
+ Aktionen
+
+
+
+
+ {benutzer.map((eintrag) => (
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/einstellungen/SystemFormular.tsx b/src/components/einstellungen/SystemFormular.tsx
index 2d8b3b6..9e3eb66 100644
--- a/src/components/einstellungen/SystemFormular.tsx
+++ b/src/components/einstellungen/SystemFormular.tsx
@@ -240,6 +240,23 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
+
+
+ Techniker
+ Kunde
+ Administrator
+
+
+
{benutzer}
-
Angemeldet als {benutzer}
+
+ Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
+
-
- Einstellungen
+
+ Meine Passkeys
Abmelden
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 654b548..d91fb85 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -4,6 +4,8 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo";
+import type { Rolle } from "@/generated/prisma/enums";
+import { darf } from "@/server/auth/rollen";
interface MenuEintrag {
href: string;
@@ -17,11 +19,13 @@ export function Sidebar({
offeneRechnungspunkte,
offeneWarnungen,
benutzer,
+ rolle,
}: {
hostsAnzahl: number;
offeneRechnungspunkte: number;
offeneWarnungen: number;
benutzer: string;
+ rolle: Rolle;
}) {
const pathname = usePathname();
const router = useRouter();
@@ -35,7 +39,7 @@ export function Sidebar({
icon: "bi-hdd-stack",
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
},
- { href: "/kunden", titel: "Kunden", icon: "bi-people" },
+ ...(rolle === "KUNDE" ? [] : [{ href: "/kunden", titel: "Kunden", icon: "bi-people" }]),
{
href: "/rechnungen",
titel: "Rechnungen",
@@ -54,12 +58,24 @@ export function Sidebar({
},
];
+ // Das Menü zeigt nur, was die Rolle auch darf. Der Schutz liegt trotzdem in den Seiten
+ // und Aktionen selbst – ein ausgeblendeter Eintrag ist keine Zugriffskontrolle (#027).
const einstellungen: MenuEintrag[] = [
- { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
- { href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
- { href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
- { href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
- { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
+ ...(darf(rolle, "betrieb")
+ ? [
+ { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
+ { href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
+ { href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
+ ]
+ : []),
+ ...(darf(rolle, "verwaltung")
+ ? [
+ { href: "/einstellungen/benutzer", titel: "Benutzer", icon: "bi-person-gear" },
+ { href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
+ { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
+ ]
+ : []),
+ { href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
];
function istAktiv(href: string) {
diff --git a/src/lib/passwort.ts b/src/lib/passwort.ts
new file mode 100644
index 0000000..9a1f058
--- /dev/null
+++ b/src/lib/passwort.ts
@@ -0,0 +1,2 @@
+/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
+export const PASSWORT_MINDESTLAENGE = 12;
diff --git a/src/lib/sitzung.ts b/src/lib/sitzung.ts
index 0c560c1..f084e58 100644
--- a/src/lib/sitzung.ts
+++ b/src/lib/sitzung.ts
@@ -19,7 +19,7 @@ export interface Sitzung {
email: string;
name: string;
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
- herkunft: "passwort" | "oidc";
+ herkunft: "passwort" | "oidc" | "passkey";
}
function schluessel(): Uint8Array {
@@ -55,7 +55,8 @@ export async function sitzungLesen(token: string | undefined): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
@@ -51,7 +59,7 @@ export async function warnregelnSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record = {};
diff --git a/src/server/actions/benachrichtigungen.ts b/src/server/actions/benachrichtigungen.ts
index c1af1b3..e24fe43 100644
--- a/src/server/actions/benachrichtigungen.ts
+++ b/src/server/actions/benachrichtigungen.ts
@@ -23,7 +23,7 @@ export async function emailKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587);
@@ -80,7 +80,7 @@ export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? "";
@@ -119,7 +119,7 @@ export async function webhookKanalSpeichern(
}
export async function kanalLoeschen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString();
if (!id) return;
@@ -130,7 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
/** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString();
if (!id) return;
@@ -145,7 +145,7 @@ export async function testSenden(formular: FormData): Promise {
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({
@@ -160,7 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum);
neuLaden();
diff --git a/src/server/actions/benutzer.ts b/src/server/actions/benutzer.ts
new file mode 100644
index 0000000..743d9cd
--- /dev/null
+++ b/src/server/actions/benutzer.ts
@@ -0,0 +1,165 @@
+"use server";
+
+import { hash } from "@node-rs/argon2";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
+import { protokolliere } from "@/server/audit";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+
+export interface AktionsErgebnis {
+ fehler?: string;
+ felder?: Record;
+ erfolg?: string;
+}
+
+const benutzerSchema = z
+ .object({
+ email: z.string().email("Keine gültige E-Mail-Adresse."),
+ name: z.string().min(2, "Bitte einen Namen angeben."),
+ rolle: z.enum(["ADMIN", "TECHNIKER", "KUNDE"]),
+ kundeId: z.string().nullable(),
+ passwort: z
+ .union([z.string().min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`), z.literal("")])
+ .default(""),
+ })
+ .refine((werte) => werte.rolle !== "KUNDE" || werte.kundeId !== null, {
+ message: "Ein Kundenkonto braucht einen Kunden.",
+ path: ["kundeId"],
+ });
+
+function felderAus(fehler: z.ZodError): Record {
+ const felder: Record = {};
+ for (const eintrag of fehler.issues) {
+ const feld = eintrag.path[0];
+ if (typeof feld === "string" && !felder[feld]) felder[feld] = eintrag.message;
+ }
+ return felder;
+}
+
+function lesen(formular: FormData) {
+ const rolle = formular.get("rolle")?.toString() ?? "TECHNIKER";
+ return {
+ email: formular.get("email")?.toString().trim().toLowerCase() ?? "",
+ name: formular.get("name")?.toString().trim() ?? "",
+ rolle,
+ // Der Kunde zählt nur bei einem Kundenkonto; sonst wird er bewusst verworfen.
+ kundeId: rolle === "KUNDE" ? formular.get("kundeId")?.toString() || null : null,
+ passwort: formular.get("passwort")?.toString() ?? "",
+ };
+}
+
+export async function benutzerAnlegen(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ await sitzungErforderlich("verwaltung");
+
+ const ergebnis = benutzerSchema.safeParse(lesen(formular));
+ if (!ergebnis.success) return { fehler: "Bitte die Angaben prüfen.", felder: felderAus(ergebnis.error) };
+
+ const { email, name, rolle, kundeId, passwort } = ergebnis.data;
+
+ if (await prisma.benutzer.findUnique({ where: { email } })) {
+ return { fehler: "Diese E-Mail-Adresse ist schon vergeben.", felder: { email: "Schon vergeben." } };
+ }
+
+ // Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder einen Passkey anmelden kann.
+ await prisma.benutzer.create({
+ data: {
+ email,
+ name,
+ rolle,
+ kundeId,
+ passwortHash: passwort ? await hash(passwort) : null,
+ },
+ });
+
+ await protokolliere("benutzer.angelegt", "Benutzer", email, { rolle, kundeId, mitPasswort: Boolean(passwort) });
+ revalidatePath("/einstellungen/benutzer");
+
+ return { erfolg: `${name} wurde angelegt.` };
+}
+
+export async function benutzerSpeichern(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const ich = await sitzungErforderlich("verwaltung");
+
+ const id = formular.get("id")?.toString();
+ if (!id) return { fehler: "Benutzer nicht gefunden." };
+
+ const ergebnis = benutzerSchema.safeParse(lesen(formular));
+ if (!ergebnis.success) return { fehler: "Bitte die Angaben prüfen.", felder: felderAus(ergebnis.error) };
+
+ const { email, name, rolle, kundeId, passwort } = ergebnis.data;
+
+ // Wer sich selbst die Administratorrolle nimmt, sperrt sich aus der Verwaltung aus.
+ if (id === ich.benutzerId && rolle !== "ADMIN") {
+ return { fehler: "Die eigene Administratorrolle lässt sich nicht entziehen." };
+ }
+
+ const andere = await prisma.benutzer.findUnique({ where: { email } });
+ if (andere && andere.id !== id) {
+ return { fehler: "Diese E-Mail-Adresse gehört schon zu einem anderen Konto." };
+ }
+
+ await prisma.benutzer.update({
+ where: { id },
+ data: {
+ email,
+ name,
+ rolle,
+ kundeId,
+ // Ein neues Passwort beendet bestehende Sitzungen – sonst liefe ein geklautes Token weiter.
+ ...(passwort ? { passwortHash: await hash(passwort), sitzungenUngueltigAb: new Date() } : {}),
+ },
+ });
+
+ await protokolliere("benutzer.geaendert", "Benutzer", id, { rolle, kundeId, passwortGesetzt: Boolean(passwort) });
+ revalidatePath("/einstellungen/benutzer");
+
+ return { erfolg: "Gespeichert." };
+}
+
+export async function benutzerSperreUmschalten(formular: FormData) {
+ const ich = await sitzungErforderlich("verwaltung");
+
+ const id = formular.get("id")?.toString();
+ if (!id) return;
+ if (id === ich.benutzerId) return; // sich selbst auszusperren hilft niemandem
+
+ const benutzer = await prisma.benutzer.findUnique({ where: { id }, select: { aktiv: true } });
+ if (!benutzer) return;
+
+ await prisma.benutzer.update({
+ where: { id },
+ data: {
+ aktiv: !benutzer.aktiv,
+ // Beim Sperren laufende Sitzungen sofort beenden.
+ ...(benutzer.aktiv ? { sitzungenUngueltigAb: new Date() } : {}),
+ },
+ });
+
+ await protokolliere(benutzer.aktiv ? "benutzer.gesperrt" : "benutzer.freigegeben", "Benutzer", id, {});
+ revalidatePath("/einstellungen/benutzer");
+}
+
+export async function benutzerLoeschen(formular: FormData) {
+ const ich = await sitzungErforderlich("verwaltung");
+
+ const id = formular.get("id")?.toString();
+ if (!id || id === ich.benutzerId) return;
+
+ // Der letzte Administrator bleibt stehen – sonst kommt niemand mehr in die Verwaltung.
+ const admins = await prisma.benutzer.count({ where: { rolle: "ADMIN", aktiv: true } });
+ const betroffen = await prisma.benutzer.findUnique({ where: { id }, select: { rolle: true, email: true } });
+ if (!betroffen) return;
+ if (betroffen.rolle === "ADMIN" && admins <= 1) return;
+
+ await prisma.benutzer.delete({ where: { id } });
+ await protokolliere("benutzer.geloescht", "Benutzer", id, { email: betroffen.email });
+ revalidatePath("/einstellungen/benutzer");
+}
diff --git a/src/server/actions/einstellungen.ts b/src/server/actions/einstellungen.ts
index 2790a8d..9e14b7a 100644
--- a/src/server/actions/einstellungen.ts
+++ b/src/server/actions/einstellungen.ts
@@ -23,7 +23,7 @@ export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("verwaltung");
const vorher = await einstellungen();
@@ -42,6 +42,7 @@ export async function einstellungenSpeichernAktion(
anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
+ oidcStandardRolle: text(formular, "oidcStandardRolle") || vorher.oidcStandardRolle,
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
diff --git a/src/server/actions/host-detail.ts b/src/server/actions/host-detail.ts
index c3bf5c0..f1a1b87 100644
--- a/src/server/actions/host-detail.ts
+++ b/src/server/actions/host-detail.ts
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
-import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden";
/** Speichert Notizen und Tags eines Hosts. */
@@ -11,10 +11,20 @@ export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("mitarbeiten");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return { fehler: "Host nicht gefunden." };
+ // Kundenkonten dürfen Notizen pflegen – aber nur an den eigenen Hosts.
+ const nurKunde = await sichtbarerKunde();
+ if (nurKunde) {
+ const eigener = await prisma.host.findFirst({
+ where: { id: hostId, kundeId: nurKunde },
+ select: { id: true },
+ });
+ if (!eigener) return { fehler: "Dieser Host gehört nicht zu Ihrem Zugang." };
+ }
+
const notizen = formular.get("notizen")?.toString().trim() || null;
const tags = (formular.get("tags")?.toString() ?? "")
.split(",")
@@ -41,7 +51,7 @@ export async function hostNotizenSpeichern(
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
export async function hostKundeSetzen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
diff --git a/src/server/actions/hosts.ts b/src/server/actions/hosts.ts
index c9fc62c..d9b5b02 100644
--- a/src/server/actions/hosts.ts
+++ b/src/server/actions/hosts.ts
@@ -9,7 +9,7 @@ import type { AktionsErgebnis } from "./kunden";
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
@@ -32,7 +32,7 @@ export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
export async function hostZuordnungAufheben(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
if (!hostId) return;
diff --git a/src/server/actions/kunden.ts b/src/server/actions/kunden.ts
index 4a3808d..d5bdc00 100644
--- a/src/server/actions/kunden.ts
+++ b/src/server/actions/kunden.ts
@@ -29,7 +29,7 @@ const kundeSchema = z.object({
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const id = leerZuNull(formular.get("id"));
const eingabe = kundeSchema.safeParse({
name: formular.get("name")?.toString().trim() ?? "",
@@ -68,7 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
}
export async function kundeLoeschen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString();
if (!id) return;
@@ -94,7 +94,7 @@ export async function standortSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const id = leerZuNull(formular.get("id"));
const eingabe = standortSchema.safeParse({
kundeId: formular.get("kundeId")?.toString() ?? "",
@@ -120,7 +120,7 @@ export async function standortSpeichern(
}
export async function standortLoeschen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString();
const kundeId = formular.get("kundeId")?.toString();
if (!id) return;
diff --git a/src/server/actions/passkeys.ts b/src/server/actions/passkeys.ts
new file mode 100644
index 0000000..935e83d
--- /dev/null
+++ b/src/server/actions/passkeys.ts
@@ -0,0 +1,20 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { prisma } from "@/lib/db";
+import { protokolliere } from "@/server/audit";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+
+/** Entfernt einen eigenen Passkey. Fremde bleiben unberührt – auch für Administratoren. */
+export async function passkeyLoeschen(formular: FormData) {
+ const ich = await sitzungErforderlich();
+
+ const id = formular.get("id")?.toString();
+ if (!id) return;
+
+ const ergebnis = await prisma.passkey.deleteMany({ where: { id, benutzerId: ich.benutzerId } });
+ if (ergebnis.count === 0) return;
+
+ await protokolliere("passkey.entfernt", "Passkey", id, {});
+ revalidatePath("/einstellungen/passkeys");
+}
diff --git a/src/server/actions/rechnungen.ts b/src/server/actions/rechnungen.ts
index a77cbae..2f6553c 100644
--- a/src/server/actions/rechnungen.ts
+++ b/src/server/actions/rechnungen.ts
@@ -49,7 +49,7 @@ async function ausfuehren(arbeit: () => Promise): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true";
@@ -88,7 +88,7 @@ export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -126,7 +126,7 @@ export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -157,7 +157,7 @@ export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
diff --git a/src/server/actions/tokens.ts b/src/server/actions/tokens.ts
index 2712b4c..888b6bc 100644
--- a/src/server/actions/tokens.ts
+++ b/src/server/actions/tokens.ts
@@ -17,7 +17,7 @@ export async function enrollmentTokenErzeugen(
_vorher: TokenErgebnis,
formular: FormData,
): Promise {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null;
@@ -60,7 +60,7 @@ export async function enrollmentTokenErzeugen(
}
export async function enrollmentTokenWiderrufen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString();
if (!id) return;
@@ -70,7 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
}
export async function hostTokenWiderrufen(formular: FormData) {
- await sitzungErforderlich();
+ await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString();
const hostId = formular.get("hostId")?.toString();
if (!id) return;
diff --git a/src/server/auth/benutzer.ts b/src/server/auth/benutzer.ts
index 883c1e9..ae40c45 100644
--- a/src/server/auth/benutzer.ts
+++ b/src/server/auth/benutzer.ts
@@ -64,7 +64,7 @@ export async function mitPasswortAnmelden(
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise {
const normalisiert = email.toLowerCase().trim();
- const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
+ const { oidcDomaenen, oidcKontenAnlegen, oidcStandardRolle } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
@@ -94,6 +94,7 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
email: normalisiert,
name: name || normalisiert,
oidcSubject: subject,
+ rolle: oidcStandardRolle,
letzterLoginAm: new Date(),
},
});
@@ -101,6 +102,7 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
weg: "oidc",
neuAngelegt: vorhanden === null,
+ ...(vorhanden === null ? { rolle: oidcStandardRolle } : {}),
});
return benutzer;
diff --git a/src/server/auth/passkeys.ts b/src/server/auth/passkeys.ts
new file mode 100644
index 0000000..3f79131
--- /dev/null
+++ b/src/server/auth/passkeys.ts
@@ -0,0 +1,143 @@
+import {
+ generateAuthenticationOptions,
+ generateRegistrationOptions,
+ verifyAuthenticationResponse,
+ verifyRegistrationResponse,
+} from "@simplewebauthn/server";
+import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
+import { prisma } from "@/lib/db";
+import { oeffentlicheBasis } from "@/lib/adresse";
+import { einstellungen } from "@/server/einstellungen";
+
+/**
+ * Passkeys (WebAuthn) als gleichwertiger Anmeldeweg neben Passwort und OIDC (#028).
+ *
+ * Ein Passkey ist an eine Domäne gebunden – deshalb entscheidet die **öffentliche** Adresse über
+ * `rpID` und `origin`. Steht dort die falsche Adresse, lehnt der Browser die Anmeldung wortlos ab.
+ *
+ * Voraussetzung ist ein sicherer Kontext: HTTPS, oder `localhost` zum Ausprobieren.
+ */
+
+export interface Herkunft {
+ rpID: string;
+ origin: string;
+}
+
+export async function herkunft(request: Request): Promise {
+ const { dashboardUrl } = await einstellungen();
+ const basis = oeffentlicheBasis(request, dashboardUrl);
+ return { rpID: basis.hostname, origin: basis.origin };
+}
+
+/** Ist die Adresse für Passkeys geeignet? Ohne HTTPS geht es nur auf localhost. */
+export function passkeysMoeglich(origin: string): boolean {
+ try {
+ const adresse = new URL(origin);
+ return (
+ adresse.protocol === "https:" || adresse.hostname === "localhost" || adresse.hostname === "127.0.0.1"
+ );
+ } catch {
+ return false;
+ }
+}
+
+export async function registrierungVorbereiten(benutzerId: string, herkunft: Herkunft) {
+ const benutzer = await prisma.benutzer.findUniqueOrThrow({
+ where: { id: benutzerId },
+ include: { passkeys: { select: { credentialId: true, transports: true } } },
+ });
+
+ return generateRegistrationOptions({
+ rpName: "ProxLedger",
+ rpID: herkunft.rpID,
+ userName: benutzer.email,
+ userDisplayName: benutzer.name,
+ // Der Schlüssel soll im Gerät bleiben und ohne Eingabe des Benutzernamens funktionieren.
+ authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
+ // Bereits registrierte Schlüssel ausschließen, damit niemand denselben zweimal anlegt.
+ excludeCredentials: benutzer.passkeys.map((passkey) => ({
+ id: passkey.credentialId,
+ transports: passkey.transports as never,
+ })),
+ });
+}
+
+export async function registrierungAbschliessen(
+ benutzerId: string,
+ antwort: RegistrationResponseJSON,
+ erwarteteChallenge: string,
+ herkunft: Herkunft,
+ bezeichnung: string,
+) {
+ const ergebnis = await verifyRegistrationResponse({
+ response: antwort,
+ expectedChallenge: erwarteteChallenge,
+ expectedOrigin: herkunft.origin,
+ expectedRPID: herkunft.rpID,
+ });
+
+ if (!ergebnis.verified || !ergebnis.registrationInfo) {
+ throw new Error("Der Passkey ließ sich nicht überprüfen.");
+ }
+
+ const { credential } = ergebnis.registrationInfo;
+
+ return prisma.passkey.create({
+ data: {
+ benutzerId,
+ credentialId: credential.id,
+ publicKey: Buffer.from(credential.publicKey),
+ zaehler: BigInt(credential.counter),
+ transports: credential.transports ?? [],
+ bezeichnung: bezeichnung.trim() || "Passkey",
+ },
+ });
+}
+
+export async function anmeldungVorbereiten(herkunft: Herkunft) {
+ return generateAuthenticationOptions({
+ rpID: herkunft.rpID,
+ // Keine Liste erlaubter Schlüssel: Der Browser bietet selbst an, was zu dieser Seite passt.
+ userVerification: "preferred",
+ });
+}
+
+export async function anmeldungPruefen(
+ antwort: AuthenticationResponseJSON,
+ erwarteteChallenge: string,
+ herkunft: Herkunft,
+) {
+ const passkey = await prisma.passkey.findUnique({
+ where: { credentialId: antwort.id },
+ include: { benutzer: true },
+ });
+ if (!passkey) throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
+ if (!passkey.benutzer.aktiv) throw new Error("Dieses Konto ist gesperrt.");
+
+ const ergebnis = await verifyAuthenticationResponse({
+ response: antwort,
+ expectedChallenge: erwarteteChallenge,
+ expectedOrigin: herkunft.origin,
+ expectedRPID: herkunft.rpID,
+ credential: {
+ id: passkey.credentialId,
+ publicKey: new Uint8Array(passkey.publicKey),
+ counter: Number(passkey.zaehler),
+ transports: passkey.transports as never,
+ },
+ });
+
+ if (!ergebnis.verified) throw new Error("Die Anmeldung ließ sich nicht überprüfen.");
+
+ // Der Zähler steigt bei jeder Nutzung; ein Rückschritt wäre ein Hinweis auf einen kopierten
+ // Schlüssel. simplewebauthn prüft das bereits – hier wird der neue Stand nur festgehalten.
+ await prisma.passkey.update({
+ where: { id: passkey.id },
+ data: {
+ zaehler: BigInt(ergebnis.authenticationInfo.newCounter),
+ zuletztAm: new Date(),
+ },
+ });
+
+ return passkey.benutzer;
+}
diff --git a/src/server/auth/rollen.ts b/src/server/auth/rollen.ts
new file mode 100644
index 0000000..2f18973
--- /dev/null
+++ b/src/server/auth/rollen.ts
@@ -0,0 +1,37 @@
+import type { Rolle } from "@/generated/prisma/enums";
+
+/**
+ * Wer darf was (#027).
+ *
+ * Die Rechte stehen bewusst als Liste an einer Stelle – nicht verteilt über Seiten und Aktionen.
+ * Wer eine neue Aktion baut, sucht sich hier das passende Recht und prüft es, statt Rollen
+ * einzeln abzufragen. Ein Recht kommt so nie versehentlich doppelt und unterschiedlich vor.
+ */
+export const RECHTE = {
+ /** Hosts, Kunden, Laufzeiten, Rechnungen anlegen und ändern. */
+ fachlichSchreiben: ["ADMIN", "TECHNIKER"],
+ /** Notizen an einem Host und Quittieren einer Warnung – auch für Kunden. */
+ mitarbeiten: ["ADMIN", "TECHNIKER", "KUNDE"],
+ /** Agent-Tokens, Benachrichtigungen, Warnregeln. */
+ betrieb: ["ADMIN", "TECHNIKER"],
+ /** Systemeinstellungen, Benutzerverwaltung, Protokoll. */
+ verwaltung: ["ADMIN"],
+} as const satisfies Record;
+
+export type Recht = keyof typeof RECHTE;
+
+export function darf(rolle: Rolle, recht: Recht): boolean {
+ return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
+}
+
+export const ROLLEN_TEXT: Record = {
+ ADMIN: "Administrator",
+ TECHNIKER: "Techniker",
+ KUNDE: "Kunde",
+};
+
+export const ROLLEN_BESCHREIBUNG: Record = {
+ ADMIN: "Darf alles, einschließlich Benutzerverwaltung und Systemeinstellungen.",
+ TECHNIKER: "Darf alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer anlegen.",
+ KUNDE: "Sieht nur die eigenen Hosts und Rechnungen, ohne Einkaufspreise. Darf Notizen schreiben und Warnungen quittieren.",
+};
diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts
index d5f45c4..3cb0e26 100644
--- a/src/server/auth/sitzungspflicht.ts
+++ b/src/server/auth/sitzungspflicht.ts
@@ -1,7 +1,10 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
+import { cache } from "react";
+import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
+import { darf, type Recht } from "./rollen";
/**
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
@@ -10,9 +13,16 @@ import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzu
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control).
*
- * Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes
- * Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten.
+ * Rolle und Kundenbindung kommen **aus der Datenbank**, nicht aus dem Token: Eine geänderte
+ * Rolle wirkt damit sofort und nicht erst nach Ablauf der Sitzung.
*/
+
+export interface AngemeldeterBenutzer extends GeleseneSitzung {
+ rolle: Rolle;
+ /** Nur bei der Rolle KUNDE gesetzt: der Kunde, dessen Daten sichtbar sind. */
+ kundeId: string | null;
+}
+
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten(
sitzung: GeleseneSitzung | null,
@@ -27,18 +37,62 @@ export function sitzungBewerten(
return { gueltig: true };
}
-export async function sitzungErforderlich(): Promise {
- const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
+/**
+ * Liest die aktuelle Anmeldung, ohne weiterzuleiten – für Stellen, die auch ohne Sitzung
+ * funktionieren müssen (Worker, Skripte, Audit-Log). `cache` sorgt dafür, dass eine Anfrage
+ * die Datenbank nur einmal dafür befragt.
+ */
+export const angemeldet = cache(async (): Promise => {
+ let sitzung: GeleseneSitzung | null = null;
+ try {
+ sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
+ } catch {
+ // Außerhalb einer Anfrage gibt es keine Cookies – das ist kein Fehler.
+ return null;
+ }
+ if (!sitzung) return null;
- const benutzer = sitzung
- ? await prisma.benutzer.findUnique({
- where: { id: sitzung.benutzerId },
- select: { aktiv: true, sitzungenUngueltigAb: true },
- })
- : null;
+ const benutzer = await prisma.benutzer.findUnique({
+ where: { id: sitzung.benutzerId },
+ select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
+ });
- const urteil = sitzungBewerten(sitzung, benutzer);
- if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
+ if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
- return sitzung as GeleseneSitzung;
+ return { ...sitzung, rolle: benutzer.rolle, kundeId: benutzer.kundeId };
+});
+
+/**
+ * Verlangt eine Anmeldung und – falls angegeben – ein bestimmtes Recht.
+ * Ohne Anmeldung geht es zur Anmeldeseite, bei fehlendem Recht auf die Startseite.
+ */
+export async function sitzungErforderlich(recht?: Recht): Promise {
+ const benutzer = await angemeldet();
+ if (!benutzer) redirect("/login?fehler=Bitte+anmelden.");
+
+ if (recht && !darf(benutzer.rolle, recht)) {
+ redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung.");
+ }
+
+ return benutzer;
+}
+
+/**
+ * Auf welchen Kunden die Sicht eingeschränkt ist – `null` heißt: alles sichtbar.
+ *
+ * Die Abfragen rufen das selbst auf, statt sich eine Einschränkung von der Seite reichen zu lassen.
+ * So kann keine Seite vergessen, sie weiterzugeben – der häufigste Weg, wie solche Sichten lecken.
+ */
+export async function sichtbarerKunde(): Promise {
+ const benutzer = await angemeldet();
+ if (!benutzer || benutzer.rolle !== "KUNDE") return null;
+
+ // Ein Kundenkonto ohne Kundenbindung darf nichts sehen – nicht alles.
+ return benutzer.kundeId ?? "kein-kunde-zugeordnet";
+}
+
+/** Dürfen Einkaufspreise und Marge angezeigt werden? */
+export async function darfEinkaufSehen(): Promise {
+ const benutzer = await angemeldet();
+ return benutzer?.rolle !== "KUNDE";
}
diff --git a/src/server/einstellungen.ts b/src/server/einstellungen.ts
index 5a6bff2..3c0e9cf 100644
--- a/src/server/einstellungen.ts
+++ b/src/server/einstellungen.ts
@@ -36,6 +36,11 @@ export const einstellungenSchema = z.object({
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
+ /**
+ * Rolle für automatisch angelegte OIDC-Konten. Vorgabe ist bewusst TECHNIKER und nicht ADMIN:
+ * Wer sich beim Identitätsanbieter anmelden kann, soll nicht nebenbei Administrator werden (#027).
+ */
+ oidcStandardRolle: z.enum(["ADMIN", "TECHNIKER", "KUNDE"]).default("TECHNIKER"),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
diff --git a/src/server/queries/dashboard.ts b/src/server/queries/dashboard.ts
index 1276229..3a591d4 100644
--- a/src/server/queries/dashboard.ts
+++ b/src/server/queries/dashboard.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
import { meldungsGrenze } from "./hosts";
/**
@@ -7,8 +8,11 @@ import { meldungsGrenze } from "./hosts";
* denn deren Rechnungen sind offen – nicht die der laufenden Periode.
*/
export async function hostsNachAblauf(jetzt: Date = new Date()) {
+ const nurKunde = await sichtbarerKunde();
+ const einkaufSichtbar = await darfEinkaufSehen();
+
const hosts = await prisma.host.findMany({
- where: { subNextduedate: { not: null } },
+ where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: { subNextduedate: "asc" },
include: {
kunde: { select: { id: true, name: true } },
@@ -40,7 +44,8 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
subKey: host.subKey,
nextduedate: ablauf,
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze,
- einkaufBezahlt: laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
+ einkaufBezahlt:
+ einkaufSichtbar && laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
verkaufErstellt: laufzeit?.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false,
};
@@ -53,6 +58,9 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
* Liste steht unter „Rechnungen“.
*/
export async function offeneAufgaben(grenze = 12) {
+ // Die Aufgabenliste ist der Arbeitsvorrat des Systemhauses – für Kundenkonten bleibt sie leer.
+ if (await sichtbarerKunde()) return { aufgaben: [], gesamt: 0 };
+
const laufzeiten = await prisma.laufzeit.findMany({
where: {
status: "BESTAETIGT",
diff --git a/src/server/queries/hostliste.ts b/src/server/queries/hostliste.ts
index 0663bdc..9f25780 100644
--- a/src/server/queries/hostliste.ts
+++ b/src/server/queries/hostliste.ts
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { meldungsGrenze } from "./hosts";
+import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter {
q?: string;
@@ -98,7 +99,7 @@ const SORTIERUNGEN: Record = {
};
export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
- const where = bedingung(filter, jetzt);
+ const where = await eingeschraenkt(bedingung(filter, jetzt));
const seite = Math.max(1, filter.seite ?? 1);
const orderBy = SORTIERUNGEN[filter.sortierung ?? "hostname"] ?? SORTIERUNGEN.hostname;
@@ -133,10 +134,23 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
};
}
+/**
+ * Schränkt eine Host-Bedingung auf den Kunden ein, dessen Konto angemeldet ist.
+ * Für Admin und Techniker bleibt sie unverändert.
+ */
+export async function eingeschraenkt(where: Prisma.HostWhereInput): Promise {
+ const kundeId = await sichtbarerKunde();
+ return kundeId ? { AND: [where, { kundeId }] } : where;
+}
+
/** Alle Daten der Host-Detailansicht in einem Rutsch. */
export async function hostMitDetails(id: string) {
- return prisma.host.findUnique({
- where: { id },
+ const kundeId = await sichtbarerKunde();
+
+ return prisma.host.findFirst({
+ // findFirst statt findUnique: So lässt sich die Kundenbindung in dieselbe Abfrage legen,
+ // statt den Host erst zu laden und danach wegzuwerfen.
+ where: kundeId ? { id, kundeId } : { id },
include: {
kunde: { select: { id: true, name: true } },
standort: { select: { id: true, name: true, ort: true } },
@@ -161,7 +175,9 @@ export type HostDetail = NonNullable>>
/** Auswahllisten für die Filterleiste. */
export async function filterOptionen() {
+ const kundeId = await sichtbarerKunde();
const kunden = await prisma.kunde.findMany({
+ where: kundeId ? { id: kundeId } : {},
orderBy: { name: "asc" },
select: { id: true, name: true },
});
diff --git a/src/server/queries/hosts.ts b/src/server/queries/hosts.ts
index 4e7d46c..adf80f1 100644
--- a/src/server/queries/hosts.ts
+++ b/src/server/queries/hosts.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
/** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */
export const MELDUNG_FRIST_STUNDEN = 48;
@@ -8,6 +9,10 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
}
export async function hostsOhneKunde() {
+ // Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
+ // noch niemandem, dürfen also auch niemandem gezeigt werden.
+ if (await sichtbarerKunde()) return [];
+
return prisma.host.findMany({
where: { kundeId: null },
orderBy: { erstmalsGesehenAm: "desc" },
@@ -15,35 +20,48 @@ export async function hostsOhneKunde() {
}
export async function hostAnzahl() {
- return prisma.host.count();
+ const kundeId = await sichtbarerKunde();
+ return prisma.host.count({ where: kundeId ? { kundeId } : {} });
}
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
+ const kundeId = await sichtbarerKunde();
+ const hostSicht = kundeId ? { kundeId } : {};
+ const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
- prisma.host.count(),
- prisma.host.count({ where: { subStatus: "ACTIVE" } }),
- prisma.host.count({ where: { kundeId: null } }),
- prisma.host.count({ where: { subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
+ prisma.host.count({ where: hostSicht }),
+ prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
+ kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }),
+ prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
prisma.host.count({
- where: { OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }] },
+ where: {
+ ...hostSicht,
+ OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
+ },
}),
- prisma.alert.count({ where: { erledigtAm: null } }),
+ prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }),
]);
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
- prisma.laufzeit.count({
- where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
- }),
- prisma.laufzeit.count({
- where: {
- status: "BESTAETIGT",
- proxmoxPosition: { isNot: null },
- OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
- },
- }),
+ // Einkaufsrechnungen gehen ein Kundenkonto nichts an.
+ kundeId
+ ? Promise.resolve(0)
+ : prisma.laufzeit.count({
+ where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
+ }),
+ kundeId
+ ? Promise.resolve(0)
+ : prisma.laufzeit.count({
+ where: {
+ ...laufzeitSicht,
+ status: "BESTAETIGT",
+ proxmoxPosition: { isNot: null },
+ OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
+ },
+ }),
]);
return {
@@ -61,8 +79,10 @@ export async function kennzahlen(jetzt: Date = new Date()) {
}
export async function offeneAlerts(anzahl = 20) {
+ const kundeId = await sichtbarerKunde();
+
return prisma.alert.findMany({
- where: { erledigtAm: null },
+ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: anzahl,
});
@@ -70,8 +90,11 @@ export async function offeneAlerts(anzahl = 20) {
/** Einträge für die globale Suche (Strg+K). */
export async function suchIndex() {
+ const kundeId = await sichtbarerKunde();
+
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({
+ where: kundeId ? { kundeId } : {},
select: {
id: true,
hostname: true,
@@ -84,13 +107,17 @@ export async function suchIndex() {
},
}),
prisma.kunde.findMany({
+ where: kundeId ? { id: kundeId } : {},
select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true },
}),
- prisma.proxmoxRechnung.findMany({
- select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
- }),
+ // Proxmox-Rechnungen sind Einkauf – für ein Kundenkonto gibt es sie schlicht nicht.
+ kundeId
+ ? Promise.resolve([])
+ : prisma.proxmoxRechnung.findMany({
+ select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
+ }),
prisma.kundenrechnung.findMany({
- where: { nummer: { not: null } },
+ where: { nummer: { not: null }, ...(kundeId ? { kundeId } : {}) },
select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } },
}),
]);
diff --git a/src/server/queries/jahresbahn.ts b/src/server/queries/jahresbahn.ts
index 040d157..007e5c7 100644
--- a/src/server/queries/jahresbahn.ts
+++ b/src/server/queries/jahresbahn.ts
@@ -1,9 +1,12 @@
import { prisma } from "@/lib/db";
+import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */
export async function ablaufDaten(jetzt: Date = new Date()) {
+ const nurKunde = await sichtbarerKunde();
+
const hosts = await prisma.host.findMany({
- where: { subNextduedate: { not: null } },
+ where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: [{ subNextduedate: "asc" }],
select: {
id: true,
diff --git a/src/server/queries/kunden.ts b/src/server/queries/kunden.ts
index 98a0ca4..06d24b6 100644
--- a/src/server/queries/kunden.ts
+++ b/src/server/queries/kunden.ts
@@ -1,9 +1,13 @@
import { prisma } from "@/lib/db";
+import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
/** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
export async function kundenListe() {
+ const nurKunde = await sichtbarerKunde();
+
const kunden = await prisma.kunde.findMany({
+ where: nurKunde ? { id: nurKunde } : {},
orderBy: { name: "asc" },
include: {
standorte: true,
@@ -42,6 +46,9 @@ export async function kundenListe() {
}
export async function kundeMitDetails(id: string) {
+ const nurKunde = await sichtbarerKunde();
+ if (nurKunde && nurKunde !== id) return null;
+
return prisma.kunde.findUnique({
where: { id },
include: {
@@ -54,6 +61,10 @@ export async function kundeMitDetails(id: string) {
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
export async function laufzeitenVonKunde(kundeId: string) {
+ const nurKunde = await sichtbarerKunde();
+ if (nurKunde && nurKunde !== kundeId) return [];
+ const einkaufSichtbar = await darfEinkaufSehen();
+
const laufzeiten = await prisma.laufzeit.findMany({
where: { host: { kundeId } },
orderBy: { start: "desc" },
@@ -72,7 +83,8 @@ export async function laufzeitenVonKunde(kundeId: string) {
start: laufzeit.start,
ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG",
- einkauf: laufzeit.proxmoxPosition
+ einkauf:
+ einkaufSichtbar && laufzeit.proxmoxPosition
? {
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
diff --git a/src/server/queries/rechnungen.ts b/src/server/queries/rechnungen.ts
index 9a98a39..88a3b70 100644
--- a/src/server/queries/rechnungen.ts
+++ b/src/server/queries/rechnungen.ts
@@ -1,5 +1,6 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
+import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface RechnungsFilter {
status?: "offen" | "erledigt" | "alle" | "vorschlaege";
@@ -58,8 +59,12 @@ function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
}
export async function rechnungsListe(filter: RechnungsFilter) {
+ const nurKunde = await sichtbarerKunde();
+ const einkaufSichtbar = await darfEinkaufSehen();
+ const where = nurKunde ? { AND: [bedingung(filter), { host: { kundeId: nurKunde } }] } : bedingung(filter);
+
const laufzeiten = await prisma.laufzeit.findMany({
- where: bedingung(filter),
+ where,
orderBy: [{ start: "desc" }],
include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
@@ -69,7 +74,10 @@ export async function rechnungsListe(filter: RechnungsFilter) {
});
const zeilen = laufzeiten.map((laufzeit) => {
- const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
+ // Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
+ // nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
+ const einkauf =
+ einkaufSichtbar && laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return {
@@ -81,10 +89,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
start: laufzeit.start,
ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG",
- einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
+ einkaufNummer: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.nummer ?? null) : null,
einkaufBetrag: einkauf,
- einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
- einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
+ einkaufBezahlt:
+ einkaufSichtbar && laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
+ einkaufFaelligAm: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null) : null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
@@ -111,15 +120,22 @@ export type RechnungsZeile = Awaited>["zeilen"
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
export async function laufzeitJahre(): Promise {
- const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } });
+ const nurKunde = await sichtbarerKunde();
+ const laufzeiten = await prisma.laufzeit.findMany({
+ where: nurKunde ? { host: { kundeId: nurKunde } } : {},
+ select: { start: true },
+ });
return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
b.localeCompare(a),
);
}
export async function laufzeitDetail(id: string) {
- return prisma.laufzeit.findUnique({
- where: { id },
+ const nurKunde = await sichtbarerKunde();
+ const einkaufSichtbar = await darfEinkaufSehen();
+
+ const laufzeit = await prisma.laufzeit.findFirst({
+ where: nurKunde ? { id, host: { kundeId: nurKunde } } : { id },
include: {
host: {
select: {
@@ -145,8 +161,14 @@ export async function laufzeitDetail(id: string) {
},
},
});
+
+ if (!laufzeit) return null;
+ // Für Kundenkonten fällt der gesamte Einkaufsteil weg.
+ return einkaufSichtbar ? laufzeit : { ...laufzeit, proxmoxPosition: null };
}
export async function anzahlVorschlaege() {
+ // Vorschläge sind Arbeitsvorrat für das Systemhaus, kein Thema für Kundenkonten.
+ if (await sichtbarerKunde()) return 0;
return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
}
diff --git a/tests/einstellungen.test.ts b/tests/einstellungen.test.ts
index 94491b3..e34c4ad 100644
--- a/tests/einstellungen.test.ts
+++ b/tests/einstellungen.test.ts
@@ -24,6 +24,12 @@ describe("Einstellungen", () => {
expect(einstellungenSchema.safeParse({ anmeldeversuche: 1 }).success).toBe(false);
});
+ it("legt neue OIDC-Konten nicht als Administrator an", () => {
+ // Die Vorgabe ist sicherheitsrelevant: Wer sich beim Anbieter anmelden kann, soll nicht
+ // automatisch alles dürfen.
+ expect(einstellungenSchema.parse({}).oidcStandardRolle).toBe("TECHNIKER");
+ });
+
it("lässt eine leere Dashboard-Adresse zu (dann gilt die Umgebung)", () => {
expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe("");
});
diff --git a/tests/kundensicht.test.ts b/tests/kundensicht.test.ts
new file mode 100644
index 0000000..d890e31
--- /dev/null
+++ b/tests/kundensicht.test.ts
@@ -0,0 +1,145 @@
+import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+
+/**
+ * Prüft, dass ein Kundenkonto wirklich nur die eigenen Daten sieht – und keine Einkaufspreise.
+ * Die Anmeldung wird ersetzt, weil in einem Test keine Cookies existieren.
+ */
+let sichtbar: string | null = null;
+
+vi.mock("@/server/auth/sitzungspflicht", () => ({
+ sichtbarerKunde: async () => sichtbar,
+ darfEinkaufSehen: async () => sichtbar === null,
+ angemeldet: async () => null,
+ sitzungErforderlich: async () => {
+ throw new Error("in diesem Test nicht vorgesehen");
+ },
+}));
+
+const { hostListe } = await import("@/server/queries/hostliste");
+const { rechnungsListe } = await import("@/server/queries/rechnungen");
+const { kennzahlen } = await import("@/server/queries/hosts");
+const { kundenListe } = await import("@/server/queries/kunden");
+
+async function daten() {
+ const [eigener, fremder] = await Promise.all([
+ testPrisma.kunde.create({ data: { name: "Eigen GmbH", kundennummer: "K-1" } }),
+ testPrisma.kunde.create({ data: { name: "Fremd AG", kundennummer: "K-2" } }),
+ ]);
+
+ const [hostA, hostB] = await Promise.all([
+ testPrisma.host.create({
+ data: { agentId: "a-1", hostname: "pve-eigen", produkt: "PVE", kundeId: eigener.id, subStatus: "ACTIVE" },
+ }),
+ testPrisma.host.create({
+ data: { agentId: "a-2", hostname: "pve-fremd", produkt: "PVE", kundeId: fremder.id, subStatus: "ACTIVE" },
+ }),
+ ]);
+
+ // Je eine Laufzeit mit Einkauf (Proxmox) und Verkauf (Kunde).
+ for (const [host, kunde, nummer] of [
+ [hostA, eigener, "PX-1"],
+ [hostB, fremder, "PX-2"],
+ ] as const) {
+ const laufzeit = await testPrisma.laufzeit.create({
+ data: {
+ hostId: host.id,
+ key: `pve1c-${host.hostname}`,
+ start: new Date("2026-01-01"),
+ ende: new Date("2026-12-31"),
+ status: "BESTAETIGT",
+ },
+ });
+ const rechnung = await testPrisma.proxmoxRechnung.create({
+ data: { nummer, datum: new Date("2026-01-02"), nettoBetrag: 100, ustSatz: 19, bezahlt: true },
+ });
+ // Die Verbindung liegt auf der Laufzeit, nicht auf der Position.
+ const position = await testPrisma.proxmoxRechnungPosition.create({
+ data: { rechnungId: rechnung.id, bezeichnung: "Position", nettoBetrag: 100 },
+ });
+ await testPrisma.laufzeit.update({
+ where: { id: laufzeit.id },
+ data: { proxmoxPosition: { connect: { id: position.id } } },
+ });
+ const kundenrechnung = await testPrisma.kundenrechnung.create({
+ data: { kundeId: kunde.id, nummer: `KR-${nummer}`, ustSatz: 19, erstellt: true, bezahlt: false },
+ });
+ const kundenPosition = await testPrisma.kundenrechnungPosition.create({
+ data: { rechnungId: kundenrechnung.id, bezeichnung: "Position", nettoBetrag: 130 },
+ });
+ await testPrisma.laufzeit.update({
+ where: { id: laufzeit.id },
+ data: { kundenPosition: { connect: { id: kundenPosition.id } } },
+ });
+ }
+
+ return { eigener, fremder };
+}
+
+describe.runIf(datenbankVerfuegbar)("Sicht eines Kundenkontos", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ sichtbar = null;
+ });
+
+ afterAll(async () => {
+ await testPrisma.$disconnect();
+ });
+
+ it("zeigt ohne Einschränkung alle Hosts", async () => {
+ await daten();
+ const liste = await hostListe({});
+ expect(liste.gesamt).toBe(2);
+ });
+
+ it("zeigt einem Kundenkonto nur die eigenen Hosts", async () => {
+ const { eigener } = await daten();
+ sichtbar = eigener.id;
+
+ const liste = await hostListe({});
+ expect(liste.gesamt).toBe(1);
+ expect(liste.hosts[0].hostname).toBe("pve-eigen");
+ });
+
+ it("verbirgt Einkaufspreis und Marge vor dem Kundenkonto", async () => {
+ const { eigener } = await daten();
+
+ const alles = await rechnungsListe({});
+ expect(alles.zeilen.every((z) => z.einkaufBetrag === 100)).toBe(true);
+ expect(alles.summen.marge).toBe(60);
+
+ sichtbar = eigener.id;
+ const eingeschraenkt = await rechnungsListe({});
+ expect(eingeschraenkt.zeilen).toHaveLength(1);
+ expect(eingeschraenkt.zeilen[0].einkaufBetrag).toBeNull();
+ expect(eingeschraenkt.zeilen[0].einkaufNummer).toBeNull();
+ expect(eingeschraenkt.zeilen[0].marge).toBeNull();
+ expect(eingeschraenkt.zeilen[0].verkaufBetrag).toBe(130);
+ });
+
+ it("zählt für das Kundenkonto nur die eigenen Hosts", async () => {
+ const { eigener } = await daten();
+ sichtbar = eigener.id;
+
+ const zahlen = await kennzahlen();
+ expect(zahlen.hostsGesamt).toBe(1);
+ expect(zahlen.offeneEinkaufsrechnungen).toBe(0);
+ });
+
+ it("zeigt in der Kundenliste nur den eigenen Kunden", async () => {
+ const { eigener } = await daten();
+ sichtbar = eigener.id;
+
+ const liste = await kundenListe();
+ expect(liste).toHaveLength(1);
+ expect(liste[0].name).toBe("Eigen GmbH");
+ });
+
+ it("sperrt ein Kundenkonto ohne Kundenbindung vollständig aus", async () => {
+ await daten();
+ sichtbar = "kein-kunde-zugeordnet";
+
+ const liste = await hostListe({});
+ expect(liste.gesamt).toBe(0);
+ });
+});
diff --git a/tests/rollen.test.ts b/tests/rollen.test.ts
new file mode 100644
index 0000000..c468295
--- /dev/null
+++ b/tests/rollen.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { darf, RECHTE, ROLLEN_TEXT } from "@/server/auth/rollen";
+
+describe("Rollen und Rechte", () => {
+ it("lässt den Administrator alles", () => {
+ for (const recht of Object.keys(RECHTE) as (keyof typeof RECHTE)[]) {
+ expect(darf("ADMIN", recht)).toBe(true);
+ }
+ });
+
+ it("lässt den Techniker alles Fachliche, aber keine Verwaltung", () => {
+ expect(darf("TECHNIKER", "fachlichSchreiben")).toBe(true);
+ expect(darf("TECHNIKER", "betrieb")).toBe(true);
+ expect(darf("TECHNIKER", "mitarbeiten")).toBe(true);
+ expect(darf("TECHNIKER", "verwaltung")).toBe(false);
+ });
+
+ it("lässt den Kunden nur mitarbeiten", () => {
+ expect(darf("KUNDE", "mitarbeiten")).toBe(true);
+ expect(darf("KUNDE", "fachlichSchreiben")).toBe(false);
+ expect(darf("KUNDE", "betrieb")).toBe(false);
+ expect(darf("KUNDE", "verwaltung")).toBe(false);
+ });
+
+ it("kennt für jede Rolle einen Anzeigetext", () => {
+ expect(ROLLEN_TEXT.ADMIN).toBe("Administrator");
+ expect(ROLLEN_TEXT.TECHNIKER).toBe("Techniker");
+ expect(ROLLEN_TEXT.KUNDE).toBe("Kunde");
+ });
+});