Compare commits

...
2 Commits
Author SHA1 Message Date
florian.wilde 2d886ac062 Merge branch 'phase/027-rollen'
Prüfung / pruefung (push) Canceled after 0s
2026-09-19 00:46:29 +02:00
florian.wildeandClaude Opus 5 2f45e33e20 feat(auth): Rollen, Benutzerverwaltung und Passkeys (#027, #028)
Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das
Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten
haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen -
ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen
selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests
pruefen das.

Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des
Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der
Vorgabe Techniker.

Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort
und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der
oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:46:29 +02:00
66 changed files with 2201 additions and 114 deletions
+12
View File
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### 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 ## [0.17.0] – 2026-09-19
### Hinzugefügt ### Hinzugefügt
+8 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 12 h 18 min **Gesamt:** 14 h 23 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -32,6 +32,8 @@
| #024 OWASP-Härtung | 0 h 45 min | | #024 OWASP-Härtung | 0 h 45 min |
| #025 Einstellungen | 1 h 10 min | | #025 Einstellungen | 1 h 10 min |
| #026 Auslieferung | 0 h 40 min | | #026 Auslieferung | 0 h 40 min |
| #027 Rollen und Benutzerverwaltung | 1 h 20 min |
| #028 Passkeys | 0 h 45 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -54,7 +56,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.17.0 **Version:** 0.18.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - 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 - 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) - 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 ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #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) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+41
View File
@@ -18,6 +18,7 @@ auch mit Hosts hinter NAT.
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten) 8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
9. [Exporte](#9-exporte) 9. [Exporte](#9-exporte)
9a. [Einstellungen](#9a-einstellungen) 9a. [Einstellungen](#9a-einstellungen)
9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung) 10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche) 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:
![Benutzerverwaltung](bilder/benutzer.png)
| 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*:
![Passkeys](bilder/passkeys.png)
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 ## 10. Sicherung und Aktualisierung
**Sichern:** **Sichern:**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 253 KiB

@@ -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.
+35
View File
@@ -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.
+3 -1
View File
@@ -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 | | [#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 | | [#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 | | [#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** |
+14 -4
View File
@@ -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 - 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. 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 **Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht. 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 ## 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`). - Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log. - Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die - **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
Passwortanmeldung ab. 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 ## A08 – Datenintegrität
+332 -2
View File
@@ -1,18 +1,20 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.14.1", "version": "0.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.14.1", "version": "0.17.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.2",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
@@ -944,6 +946,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1609,6 +1617,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", "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" "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": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3031,6 +3278,33 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4339,6 +4613,20 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -8667,6 +8955,24 @@
], ],
"license": "MIT" "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": { "node_modules/queue": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -8749,6 +9055,12 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9703,6 +10015,24 @@
"fsevents": "~2.3.3" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.17.0", "version": "0.18.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -34,6 +34,8 @@
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.2",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
@@ -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;
+34
View File
@@ -72,6 +72,7 @@ model Kunde {
alerts Alert[] alerts Alert[]
@@index([name]) @@index([name])
benutzer Benutzer[]
} }
model Standort { model Standort {
@@ -448,12 +449,23 @@ model Benachrichtigungskanal {
// ---------------------------------------------------------------- Benutzer und Protokoll // ---------------------------------------------------------------- Benutzer und Protokoll
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen. /// 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 { model Benutzer {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
name String name String
passwortHash String? passwortHash String?
oidcSubject String? @unique 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) aktiv Boolean @default(true)
letzterLoginAm DateTime? letzterLoginAm DateTime?
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024). /// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
@@ -462,6 +474,28 @@ model Benutzer {
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
auditEintraege AuditEintrag[] 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 { model AuditEintrag {
+22 -4
View File
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import { request as httpAnfrage } from "node:http"; import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import pg from "pg";
import "dotenv/config"; import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
@@ -49,6 +50,8 @@ const SEITEN = [
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" }, { name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" }, { name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" }, { name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
]; ];
async function sitzungsToken() { async function sitzungsToken() {
@@ -57,14 +60,29 @@ async function sitzungsToken() {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder."); 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({ return new SignJWT({
benutzerId: "screenshots", benutzerId: benutzer.id,
email: process.env.ADMIN_EMAIL ?? "admin@example.de", email: benutzer.email,
name: process.env.ADMIN_NAME ?? "Florian", name: benutzer.name,
herkunft: "passwort", herkunft: "passwort",
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setSubject("screenshots") .setSubject(benutzer.id)
.setIssuedAt() .setIssuedAt()
.setIssuer("proxledger") .setIssuer("proxledger")
.setExpirationTime("1h") .setExpirationTime("1h")
+6 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular"; import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { oidcEinstellungen } from "@/server/auth/oidc"; import { oidcEinstellungen } from "@/server/auth/oidc";
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" /> <i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
Mit {oidc.bezeichnung} anmelden Mit {oidc.bezeichnung} anmelden
</Link> </Link>
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div> <div className="text-center text-body-secondary small mb-3">oder</div>
</> </>
) : null} ) : null}
<PasskeyAnmeldung ziel={ziel} />
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
<AnmeldeFormular ziel={ziel} /> <AnmeldeFormular ziel={ziel} />
</div> </div>
</div> </div>
@@ -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 (
<>
<ContentHeader
titel="Benutzer"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benutzer" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Callout farbe="info" titel="Rollen">
<strong>Administrator</strong> darf alles, auch Benutzer und Systemeinstellungen.{" "}
<strong>Techniker</strong> darf alles Fachliche, aber keine Benutzer anlegen.{" "}
<strong>Kunde</strong> sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
</Callout>
<BenutzerVerwaltung benutzer={zeilen} kunden={kunden} ichId={ich.benutzerId} />
</div>
</div>
</>
);
}
@@ -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 (
<>
<ContentHeader
titel="Meine Passkeys"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Passkeys" }]}
/>
<div className="app-content">
<div className="container-fluid">
<PasskeyVerwaltung
passkeys={passkeys}
moeglich={passkeysMoeglich(basis.origin)}
adresse={basis.origin}
/>
</div>
</div>
</>
);
}
+6 -6
View File
@@ -1,9 +1,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar"; 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"; 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 const dynamic = "force-dynamic";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { 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. // Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value); // entsteht – und weil hier Rolle und Kundenbindung herkommen.
if (!sitzung) redirect("/login"); const sitzung = await sitzungErforderlich();
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]); 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} suchEintraege={suche}
benutzer={sitzung.name} benutzer={sitzung.name}
rolle={sitzung.rolle}
/> />
<Sidebar <Sidebar
hostsAnzahl={zahlen.hostsGesamt} hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte} offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts} offeneWarnungen={zahlen.offeneAlerts}
benutzer={sitzung.name} benutzer={sitzung.name}
rolle={sitzung.rolle}
/> />
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
<Footer /> <Footer />
@@ -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);
}
}
@@ -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);
}
}
+66
View File
@@ -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<string | null>(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 ? (
<div className="alert alert-danger py-2" role="alert">
{fehler}
</div>
) : null}
<button
type="button"
className="btn btn-outline-primary w-100 pl-touch-target mb-3"
onClick={anmelden}
disabled={laeuft}
>
<i className="bi bi-fingerprint me-1" aria-hidden="true" />
{laeuft ? "Warte auf den Browser …" : "Mit Passkey anmelden"}
</button>
</>
);
}
+153
View File
@@ -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 (
<>
<Card titel="Neuen Passkey anlegen" rahmen="primary">
{!moeglich ? (
<div className="alert alert-warning mb-3">
<strong>Über diese Adresse geht es nicht.</strong> Passkeys verlangen HTTPS; nur{" "}
<code>localhost</code> ist ausgenommen. Aufgerufen wurde <code>{adresse}</code>. Richten Sie
einen Reverse Proxy mit Zertifikat ein und tragen Sie die Adresse unter{" "}
<em>Einstellungen → System</em> ein.
</div>
) : null}
{meldung ? (
<div className={`alert ${meldung.art === "fehler" ? "alert-danger" : "alert-success"}`}>
{meldung.text}
</div>
) : null}
<div className="row g-3 align-items-end">
<div className="col-12 col-md-5">
<label className="form-label" htmlFor="passkey-name">
Bezeichnung
</label>
<input
className="form-control"
id="passkey-name"
value={bezeichnung}
onChange={(e) => setBezeichnung(e.target.value)}
placeholder="z. B. Arbeitsrechner oder Telefon"
disabled={!moeglich}
/>
<div className="form-text">Hilft später beim Wiedererkennen, wenn mehrere hinterlegt sind.</div>
</div>
<div className="col-12 col-md-4">
<button type="button" className="btn btn-primary" onClick={registrieren} disabled={!moeglich || laeuft}>
<i className="bi bi-fingerprint me-2" aria-hidden="true" />
{laeuft ? "Warte auf den Browser …" : "Passkey anlegen"}
</button>
</div>
</div>
</Card>
<Card titel={`Hinterlegte Passkeys (${passkeys.length})`} rahmen="keiner">
{passkeys.length === 0 ? (
<p className="text-body-secondary mb-0">
Noch keiner. Ohne Passkey bleibt die Anmeldung per Passwort oder OIDC – beides funktioniert
weiterhin.
</p>
) : (
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th scope="col">Bezeichnung</th>
<th scope="col">Angelegt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{passkeys.map((passkey) => (
<tr key={passkey.id}>
<td className="fw-semibold">{passkey.bezeichnung}</td>
<td className="small">{formatDatumZeit(passkey.erstelltAm)}</td>
<td className="small">
{passkey.zuletztAm ? formatDatumZeit(passkey.zuletztAm) : "noch nie"}
</td>
<td className="text-end">
<form action={passkeyLoeschen}>
<input type="hidden" name="id" value={passkey.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</>
);
}
@@ -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<string, string>;
}) {
const [rolle, setRolle] = useState<Rolle>(eintrag?.rolle ?? "TECHNIKER");
return (
<>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor={`rolle-${eintrag?.id ?? "neu"}`}>
Rolle
</label>
<select
className="form-select"
id={`rolle-${eintrag?.id ?? "neu"}`}
name="rolle"
defaultValue={rolle}
onChange={(e) => setRolle(e.target.value as Rolle)}
>
{ROLLEN.map((wert) => (
<option key={wert} value={wert}>
{ROLLEN_TEXT[wert]}
</option>
))}
</select>
<div className="form-text">{ROLLEN_BESCHREIBUNG[rolle]}</div>
</div>
{/* Der Kunde zählt nur für ein Kundenkonto – deshalb erscheint das Feld auch nur dann. */}
{rolle === "KUNDE" ? (
<div className="col-12 col-md-3">
<label className="form-label" htmlFor={`kunde-${eintrag?.id ?? "neu"}`}>
Kunde
</label>
<select
className="form-select"
id={`kunde-${eintrag?.id ?? "neu"}`}
name="kundeId"
defaultValue={eintrag?.kundeId ?? ""}
>
<option value="">– bitte wählen –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
{fehler.kundeId ? <div className="invalid-feedback d-block">{fehler.kundeId}</div> : null}
</div>
) : null}
</>
);
}
function NeuFormular({ kunden }: { kunden: { id: string; name: string }[] }) {
const [ergebnis, aktion, laeuft] = useActionState(benutzerAnlegen, LEER);
const fehler = ergebnis.felder ?? {};
return (
<Card titel="Benutzer anlegen" rahmen="primary">
<form action={aktion} className="row g-3 align-items-start">
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="neu-name">
Name
</label>
<input className="form-control" id="neu-name" name="name" required />
{fehler.name ? <div className="invalid-feedback d-block">{fehler.name}</div> : null}
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="neu-email">
E-Mail
</label>
<input type="email" className="form-control" id="neu-email" name="email" required />
{fehler.email ? <div className="invalid-feedback d-block">{fehler.email}</div> : null}
</div>
<RollenFelder kunden={kunden} fehler={fehler} />
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="neu-passwort">
Passwort
</label>
<input
type="password"
className="form-control"
id="neu-passwort"
name="passwort"
minLength={PASSWORT_MINDESTLAENGE}
autoComplete="new-password"
/>
<div className="form-text">
Leer lassen, wenn sich das Konto nur über OIDC oder einen Passkey anmelden soll.
</div>
{fehler.passwort ? <div className="invalid-feedback d-block">{fehler.passwort}</div> : null}
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird angelegt …" : "Benutzer anlegen"}
</button>
</div>
</form>
</Card>
);
}
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 (
<>
<tr>
<td>
<button type="button" className="btn btn-link p-0 text-start" onClick={() => setOffen(!offen)}>
<span className="fw-semibold">{eintrag.name}</span>
</button>
{ichSelbst ? <span className="badge text-bg-secondary ms-2">Sie</span> : null}
<div className="small text-body-secondary">{eintrag.email}</div>
</td>
<td>
{ROLLEN_TEXT[eintrag.rolle]}
{eintrag.kundeName ? <div className="small text-body-secondary">{eintrag.kundeName}</div> : null}
</td>
<td className="small">
{anmeldewege.length > 0 ? anmeldewege.join(" · ") : <span className="text-danger">kein Zugang</span>}
</td>
<td className="small">
{eintrag.letzterLoginAm ? formatDatumZeit(eintrag.letzterLoginAm) : "noch nie"}
</td>
<td>
{eintrag.aktiv ? (
<span className="badge text-bg-success">aktiv</span>
) : (
<span className="badge text-bg-danger">gesperrt</span>
)}
</td>
<td className="text-end">
<div className="d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => setOffen(!offen)}>
{offen ? "Schließen" : "Bearbeiten"}
</button>
{!ichSelbst ? (
<>
<form action={benutzerSperreUmschalten}>
<input type="hidden" name="id" value={eintrag.id} />
<button type="submit" className="btn btn-sm btn-outline-warning">
{eintrag.aktiv ? "Sperren" : "Freigeben"}
</button>
</form>
<form action={benutzerLoeschen}>
<input type="hidden" name="id" value={eintrag.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Löschen
</button>
</form>
</>
) : null}
</div>
</td>
</tr>
{offen ? (
<tr>
<td colSpan={6} className="bg-body-tertiary">
<form action={aktion} className="row g-3 align-items-start">
<input type="hidden" name="id" value={eintrag.id} />
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor={`name-${eintrag.id}`}>
Name
</label>
<input className="form-control" id={`name-${eintrag.id}`} name="name" defaultValue={eintrag.name} />
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor={`email-${eintrag.id}`}>
E-Mail
</label>
<input
type="email"
className="form-control"
id={`email-${eintrag.id}`}
name="email"
defaultValue={eintrag.email}
/>
</div>
<RollenFelder eintrag={eintrag} kunden={kunden} fehler={fehler} />
<div className="col-12 col-md-3">
<label className="form-label" htmlFor={`passwort-${eintrag.id}`}>
Neues Passwort
</label>
<input
type="password"
className="form-control"
id={`passwort-${eintrag.id}`}
name="passwort"
minLength={PASSWORT_MINDESTLAENGE}
autoComplete="new-password"
/>
<div className="form-text">Leer lassen heißt: unverändert. Ein neues beendet alle Sitzungen.</div>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</button>
</div>
</form>
</td>
</tr>
) : null}
</>
);
}
export function BenutzerVerwaltung({
benutzer,
kunden,
ichId,
}: {
benutzer: BenutzerZeile[];
kunden: { id: string; name: string }[];
ichId: string;
}) {
return (
<>
<NeuFormular kunden={kunden} />
<Card titel={`Benutzer (${benutzer.length})`} rahmen="keiner">
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Rolle</th>
<th scope="col">Anmeldung</th>
<th scope="col">Zuletzt angemeldet</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktionen
</th>
</tr>
</thead>
<tbody>
{benutzer.map((eintrag) => (
<Zeile key={eintrag.id} eintrag={eintrag} kunden={kunden} ichSelbst={eintrag.id === ichId} />
))}
</tbody>
</table>
</div>
</Card>
</>
);
}
@@ -240,6 +240,23 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
</div> </div>
</div> </div>
<Feld
name="oidcStandardRolle"
beschriftung="Rolle für neue OIDC-Konten"
hinweis="Gilt nur für Konten, die bei der ersten Anmeldung automatisch entstehen."
>
<select
className="form-select"
id="oidcStandardRolle"
name="oidcStandardRolle"
defaultValue={werte.oidcStandardRolle}
>
<option value="TECHNIKER">Techniker</option>
<option value="KUNDE">Kunde</option>
<option value="ADMIN">Administrator</option>
</select>
</Feld>
<Feld <Feld
name="aufbewahrungReportsTage" name="aufbewahrungReportsTage"
beschriftung="Reports aufbewahren (Tage)" beschriftung="Reports aufbewahren (Tage)"
+9 -3
View File
@@ -3,6 +3,8 @@
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { SearchDialog, type SuchEintrag } from "./SearchDialog"; import { SearchDialog, type SuchEintrag } from "./SearchDialog";
import type { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_TEXT } from "@/server/auth/rollen";
export interface KopfleistenAlert { export interface KopfleistenAlert {
id: string; id: string;
@@ -33,10 +35,12 @@ export function Header({
alerts, alerts,
suchEintraege, suchEintraege,
benutzer, benutzer,
rolle,
}: { }: {
alerts: KopfleistenAlert[]; alerts: KopfleistenAlert[];
suchEintraege: SuchEintrag[]; suchEintraege: SuchEintrag[];
benutzer: string; benutzer: string;
rolle: Rolle;
}) { }) {
const [sucheOffen, setSucheOffen] = useState(false); const [sucheOffen, setSucheOffen] = useState(false);
const [glockeOffen, setGlockeOffen] = useState(false); const [glockeOffen, setGlockeOffen] = useState(false);
@@ -209,10 +213,12 @@ export function Header({
<span className="d-none d-sm-inline">{benutzer}</span> <span className="d-none d-sm-inline">{benutzer}</span>
</button> </button>
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}> <div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
<span className="dropdown-header">Angemeldet als {benutzer}</span> <span className="dropdown-header">
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
</span>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<Link href="/einstellungen/system" className="dropdown-item"> <Link href="/einstellungen/passkeys" className="dropdown-item">
Einstellungen Meine Passkeys
</Link> </Link>
<a className="dropdown-item" href="/api/auth/abmelden"> <a className="dropdown-item" href="/api/auth/abmelden">
Abmelden Abmelden
+17 -1
View File
@@ -4,6 +4,8 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo"; import { LogoMitSchriftzug } from "@/components/Logo";
import type { Rolle } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
interface MenuEintrag { interface MenuEintrag {
href: string; href: string;
@@ -17,11 +19,13 @@ export function Sidebar({
offeneRechnungspunkte, offeneRechnungspunkte,
offeneWarnungen, offeneWarnungen,
benutzer, benutzer,
rolle,
}: { }: {
hostsAnzahl: number; hostsAnzahl: number;
offeneRechnungspunkte: number; offeneRechnungspunkte: number;
offeneWarnungen: number; offeneWarnungen: number;
benutzer: string; benutzer: string;
rolle: Rolle;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -35,7 +39,7 @@ export function Sidebar({
icon: "bi-hdd-stack", icon: "bi-hdd-stack",
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" }, 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", href: "/rechnungen",
titel: "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[] = [ const einstellungen: MenuEintrag[] = [
...(darf(rolle, "betrieb")
? [
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" }, { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" }, { href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" }, { 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/audit", titel: "Protokoll", icon: "bi-list-check" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" }, { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
]
: []),
{ href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
]; ];
function istAktiv(href: string) { function istAktiv(href: string) {
+2
View File
@@ -0,0 +1,2 @@
/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
export const PASSWORT_MINDESTLAENGE = 12;
+3 -2
View File
@@ -19,7 +19,7 @@ export interface Sitzung {
email: string; email: string;
name: string; name: string;
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */ /** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
herkunft: "passwort" | "oidc"; herkunft: "passwort" | "oidc" | "passkey";
} }
function schluessel(): Uint8Array { function schluessel(): Uint8Array {
@@ -55,7 +55,8 @@ export async function sitzungLesen(token: string | undefined): Promise<GeleseneS
benutzerId: payload.benutzerId, benutzerId: payload.benutzerId,
email: payload.email, email: payload.email,
name: typeof payload.name === "string" ? payload.name : payload.email, name: typeof payload.name === "string" ? payload.name : payload.email,
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort", herkunft:
payload.herkunft === "oidc" || payload.herkunft === "passkey" ? payload.herkunft : "passwort",
ausgestelltAm: new Date((payload.iat ?? 0) * 1000), ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
}; };
} catch { } catch {
+14 -6
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { alertsAbgleichen } from "@/server/alerts/abgleich"; import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln"; import { REGELN } from "@/server/alerts/regeln";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
@@ -16,18 +16,26 @@ function neuLaden() {
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */ /** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
export async function alertQuittieren(formular: FormData) { export async function alertQuittieren(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("mitarbeiten");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
await prisma.alert.update({ where: { id }, data: { quittiertAm: new Date() } }); // Ein Kundenkonto darf nur eigene Warnungen quittieren. Bedingung und Schreiben in einem
// Schritt: So kann zwischen Prüfung und Änderung nichts dazwischenkommen.
const nurKunde = await sichtbarerKunde();
const ergebnis = await prisma.alert.updateMany({
where: { id, ...(nurKunde ? { host: { kundeId: nurKunde } } : {}) },
data: { quittiertAm: new Date() },
});
if (ergebnis.count === 0) return;
await protokolliere("alert.quittiert", "Alert", id, {}); await protokolliere("alert.quittiert", "Alert", id, {});
neuLaden(); neuLaden();
} }
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */ /** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
export async function alertSchliessen(formular: FormData) { export async function alertSchliessen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -38,7 +46,7 @@ export async function alertSchliessen(formular: FormData) {
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */ /** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> { export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const ergebnis = await alertsAbgleichen(); const ergebnis = await alertsAbgleichen();
neuLaden(); neuLaden();
return { return {
@@ -51,7 +59,7 @@ export async function warnregelnSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
for (const regel of REGELN) { for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on"; const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record<string, number | number[]> = {}; const schwellwerte: Record<string, number | number[]> = {};
+6 -6
View File
@@ -23,7 +23,7 @@ export async function emailKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString() || null; const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? ""; const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587); const port = Number(formular.get("port") ?? 587);
@@ -80,7 +80,7 @@ export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString() || null; const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? ""; const url = formular.get("url")?.toString().trim() ?? "";
@@ -119,7 +119,7 @@ export async function webhookKanalSpeichern(
} }
export async function kanalLoeschen(formular: FormData) { export async function kanalLoeschen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -130,7 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
/** Verschickt eine Testnachricht über einen Kanal. */ /** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> { export async function testSenden(formular: FormData): Promise<void> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -145,7 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */ /** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) { export async function zusammenfassungSpeichern(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus"; const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({ await prisma.einstellung.upsert({
@@ -160,7 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */ /** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> { export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich"; const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum); await zusammenfassungSenden(zeitraum);
neuLaden(); neuLaden();
+165
View File
@@ -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<string, string>;
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<string, string> {
const felder: Record<string, string> = {};
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<AktionsErgebnis> {
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<AktionsErgebnis> {
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");
}
+2 -1
View File
@@ -23,7 +23,7 @@ export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis, _zustand: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("verwaltung");
const vorher = await einstellungen(); const vorher = await einstellungen();
@@ -42,6 +42,7 @@ export async function einstellungenSpeichernAktion(
anmeldeversuche: zahl(formular, "anmeldeversuche"), anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"), oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"), oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
oidcStandardRolle: text(formular, "oidcStandardRolle") || vorher.oidcStandardRolle,
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"), aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"), aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
+13 -3
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
/** Speichert Notizen und Tags eines Hosts. */ /** Speichert Notizen und Tags eines Hosts. */
@@ -11,10 +11,20 @@ export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("mitarbeiten");
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!hostId) return { fehler: "Host nicht gefunden." }; 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 notizen = formular.get("notizen")?.toString().trim() || null;
const tags = (formular.get("tags")?.toString() ?? "") const tags = (formular.get("tags")?.toString() ?? "")
.split(",") .split(",")
@@ -41,7 +51,7 @@ export async function hostNotizenSpeichern(
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */ /** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
export async function hostKundeSetzen(formular: FormData) { export async function hostKundeSetzen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
+2 -2
View File
@@ -9,7 +9,7 @@ import type { AktionsErgebnis } from "./kunden";
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */ /** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> { export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const hostIds = formular.getAll("hostId").map(String).filter(Boolean); const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.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. */ /** Hebt die Zuordnung eines einzelnen Hosts auf. */
export async function hostZuordnungAufheben(formular: FormData) { export async function hostZuordnungAufheben(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!hostId) return; if (!hostId) return;
+4 -4
View File
@@ -29,7 +29,7 @@ const kundeSchema = z.object({
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */ /** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> { export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = leerZuNull(formular.get("id")); const id = leerZuNull(formular.get("id"));
const eingabe = kundeSchema.safeParse({ const eingabe = kundeSchema.safeParse({
name: formular.get("name")?.toString().trim() ?? "", name: formular.get("name")?.toString().trim() ?? "",
@@ -68,7 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
} }
export async function kundeLoeschen(formular: FormData) { export async function kundeLoeschen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -94,7 +94,7 @@ export async function standortSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = leerZuNull(formular.get("id")); const id = leerZuNull(formular.get("id"));
const eingabe = standortSchema.safeParse({ const eingabe = standortSchema.safeParse({
kundeId: formular.get("kundeId")?.toString() ?? "", kundeId: formular.get("kundeId")?.toString() ?? "",
@@ -120,7 +120,7 @@ export async function standortSpeichern(
} }
export async function standortLoeschen(formular: FormData) { export async function standortLoeschen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
const kundeId = formular.get("kundeId")?.toString(); const kundeId = formular.get("kundeId")?.toString();
if (!id) return; if (!id) return;
+20
View File
@@ -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");
}
+6 -6
View File
@@ -49,7 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
} }
export async function laufzeitBestaetigenAktion(formular: FormData) { export async function laufzeitBestaetigenAktion(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("laufzeitId")?.toString(); const id = formular.get("laufzeitId")?.toString();
if (!id) return; if (!id) return;
await laufzeitBestaetigen(id); await laufzeitBestaetigen(id);
@@ -57,7 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
} }
export async function laufzeitVerwerfenAktion(formular: FormData) { export async function laufzeitVerwerfenAktion(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("laufzeitId")?.toString(); const id = formular.get("laufzeitId")?.toString();
if (!id) return; if (!id) return;
await laufzeitVerwerfen(id); await laufzeitVerwerfen(id);
@@ -66,7 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
/** Häkchen aus der Rechnungstabelle. */ /** Häkchen aus der Rechnungstabelle. */
export async function statusUmschalten(formular: FormData): Promise<void> { export async function statusUmschalten(formular: FormData): Promise<void> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString(); const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true"; const wert = formular.get("wert")?.toString() === "true";
@@ -88,7 +88,7 @@ export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -126,7 +126,7 @@ export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -157,7 +157,7 @@ export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim(); const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start")); const start = datum(formular.get("start"));
+3 -3
View File
@@ -17,7 +17,7 @@ export async function enrollmentTokenErzeugen(
_vorher: TokenErgebnis, _vorher: TokenErgebnis,
formular: FormData, formular: FormData,
): Promise<TokenErgebnis> { ): Promise<TokenErgebnis> {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null; const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
@@ -60,7 +60,7 @@ export async function enrollmentTokenErzeugen(
} }
export async function enrollmentTokenWiderrufen(formular: FormData) { export async function enrollmentTokenWiderrufen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -70,7 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
} }
export async function hostTokenWiderrufen(formular: FormData) { export async function hostTokenWiderrufen(formular: FormData) {
await sitzungErforderlich(); await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!id) return; if (!id) return;
+3 -1
View File
@@ -64,7 +64,7 @@ export async function mitPasswortAnmelden(
*/ */
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> { export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim(); const normalisiert = email.toLowerCase().trim();
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen(); const { oidcDomaenen, oidcKontenAnlegen, oidcStandardRolle } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) { if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" }); 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, email: normalisiert,
name: name || normalisiert, name: name || normalisiert,
oidcSubject: subject, oidcSubject: subject,
rolle: oidcStandardRolle,
letzterLoginAm: new Date(), letzterLoginAm: new Date(),
}, },
}); });
@@ -101,6 +102,7 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, {
weg: "oidc", weg: "oidc",
neuAngelegt: vorhanden === null, neuAngelegt: vorhanden === null,
...(vorhanden === null ? { rolle: oidcStandardRolle } : {}),
}); });
return benutzer; return benutzer;
+143
View File
@@ -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<Herkunft> {
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;
}
+37
View File
@@ -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<string, readonly Rolle[]>;
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<Rolle, string> = {
ADMIN: "Administrator",
TECHNIKER: "Techniker",
KUNDE: "Kunde",
};
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
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.",
};
+66 -12
View File
@@ -1,7 +1,10 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react";
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung"; 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. * 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 * Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control). * verlassen (OWASP A01 – Broken Access Control).
* *
* Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes * Rolle und Kundenbindung kommen **aus der Datenbank**, nicht aus dem Token: Eine geänderte
* Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten. * 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. */ /** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten( export function sitzungBewerten(
sitzung: GeleseneSitzung | null, sitzung: GeleseneSitzung | null,
@@ -27,18 +37,62 @@ export function sitzungBewerten(
return { gueltig: true }; return { gueltig: true };
} }
export async function sitzungErforderlich(): Promise<GeleseneSitzung> { /**
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<AngemeldeterBenutzer | null> => {
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 const benutzer = await prisma.benutzer.findUnique({
? await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId }, where: { id: sitzung.benutzerId },
select: { aktiv: true, sitzungenUngueltigAb: true }, select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
}) });
: null;
const urteil = sitzungBewerten(sitzung, benutzer); if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
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<AngemeldeterBenutzer> {
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<string | null> {
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<boolean> {
const benutzer = await angemeldet();
return benutzer?.rolle !== "KUNDE";
} }
+5
View File
@@ -36,6 +36,11 @@ export const einstellungenSchema = z.object({
oidcDomaenen: z.string().default(""), oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */ /** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true), 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. */ /** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365), aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730), aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
+10 -2
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
import { meldungsGrenze } from "./hosts"; import { meldungsGrenze } from "./hosts";
/** /**
@@ -7,8 +8,11 @@ import { meldungsGrenze } from "./hosts";
* denn deren Rechnungen sind offen – nicht die der laufenden Periode. * denn deren Rechnungen sind offen – nicht die der laufenden Periode.
*/ */
export async function hostsNachAblauf(jetzt: Date = new Date()) { export async function hostsNachAblauf(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde();
const einkaufSichtbar = await darfEinkaufSehen();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null } }, where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: { subNextduedate: "asc" }, orderBy: { subNextduedate: "asc" },
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
@@ -40,7 +44,8 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
subKey: host.subKey, subKey: host.subKey,
nextduedate: ablauf, nextduedate: ablauf,
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze, 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, verkaufErstellt: laufzeit?.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false, verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false,
}; };
@@ -53,6 +58,9 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
* Liste steht unter „Rechnungen“. * Liste steht unter „Rechnungen“.
*/ */
export async function offeneAufgaben(grenze = 12) { 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({ const laufzeiten = await prisma.laufzeit.findMany({
where: { where: {
status: "BESTAETIGT", status: "BESTAETIGT",
+19 -3
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { meldungsGrenze } from "./hosts"; import { meldungsGrenze } from "./hosts";
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter { export interface HostFilter {
q?: string; q?: string;
@@ -98,7 +99,7 @@ const SORTIERUNGEN: Record<string, Prisma.HostOrderByWithRelationInput> = {
}; };
export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) { 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 seite = Math.max(1, filter.seite ?? 1);
const orderBy = SORTIERUNGEN[filter.sortierung ?? "hostname"] ?? SORTIERUNGEN.hostname; 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<Prisma.HostWhereInput> {
const kundeId = await sichtbarerKunde();
return kundeId ? { AND: [where, { kundeId }] } : where;
}
/** Alle Daten der Host-Detailansicht in einem Rutsch. */ /** Alle Daten der Host-Detailansicht in einem Rutsch. */
export async function hostMitDetails(id: string) { export async function hostMitDetails(id: string) {
return prisma.host.findUnique({ const kundeId = await sichtbarerKunde();
where: { id },
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: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
standort: { select: { id: true, name: true, ort: true } }, standort: { select: { id: true, name: true, ort: true } },
@@ -161,7 +175,9 @@ export type HostDetail = NonNullable<Awaited<ReturnType<typeof hostMitDetails>>>
/** Auswahllisten für die Filterleiste. */ /** Auswahllisten für die Filterleiste. */
export async function filterOptionen() { export async function filterOptionen() {
const kundeId = await sichtbarerKunde();
const kunden = await prisma.kunde.findMany({ const kunden = await prisma.kunde.findMany({
where: kundeId ? { id: kundeId } : {},
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
+39 -12
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
/** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */ /** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */
export const MELDUNG_FRIST_STUNDEN = 48; export const MELDUNG_FRIST_STUNDEN = 48;
@@ -8,6 +9,10 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
} }
export async function hostsOhneKunde() { 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({ return prisma.host.findMany({
where: { kundeId: null }, where: { kundeId: null },
orderBy: { erstmalsGesehenAm: "desc" }, orderBy: { erstmalsGesehenAm: "desc" },
@@ -15,30 +20,43 @@ export async function hostsOhneKunde() {
} }
export async function hostAnzahl() { 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. */ /** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
export async function kennzahlen(jetzt: Date = new Date()) { export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000); 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([ const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
prisma.host.count(), prisma.host.count({ where: hostSicht }),
prisma.host.count({ where: { subStatus: "ACTIVE" } }), prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
prisma.host.count({ where: { kundeId: null } }), kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }),
prisma.host.count({ where: { subNextduedate: { gte: jetzt, lte: in30Tagen } } }), prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
prisma.host.count({ 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([ const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
prisma.laufzeit.count({ // Einkaufsrechnungen gehen ein Kundenkonto nichts an.
kundeId
? Promise.resolve(0)
: prisma.laufzeit.count({
where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } }, where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
}), }),
prisma.laufzeit.count({ kundeId
? Promise.resolve(0)
: prisma.laufzeit.count({
where: { where: {
...laufzeitSicht,
status: "BESTAETIGT", status: "BESTAETIGT",
proxmoxPosition: { isNot: null }, proxmoxPosition: { isNot: null },
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }], OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
@@ -61,8 +79,10 @@ export async function kennzahlen(jetzt: Date = new Date()) {
} }
export async function offeneAlerts(anzahl = 20) { export async function offeneAlerts(anzahl = 20) {
const kundeId = await sichtbarerKunde();
return prisma.alert.findMany({ return prisma.alert.findMany({
where: { erledigtAm: null }, where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }], orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: anzahl, take: anzahl,
}); });
@@ -70,8 +90,11 @@ export async function offeneAlerts(anzahl = 20) {
/** Einträge für die globale Suche (Strg+K). */ /** Einträge für die globale Suche (Strg+K). */
export async function suchIndex() { export async function suchIndex() {
const kundeId = await sichtbarerKunde();
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([ const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({ prisma.host.findMany({
where: kundeId ? { kundeId } : {},
select: { select: {
id: true, id: true,
hostname: true, hostname: true,
@@ -84,13 +107,17 @@ export async function suchIndex() {
}, },
}), }),
prisma.kunde.findMany({ prisma.kunde.findMany({
where: kundeId ? { id: kundeId } : {},
select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true }, select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true },
}), }),
prisma.proxmoxRechnung.findMany({ // 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 } } } } }, select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
}), }),
prisma.kundenrechnung.findMany({ prisma.kundenrechnung.findMany({
where: { nummer: { not: null } }, where: { nummer: { not: null }, ...(kundeId ? { kundeId } : {}) },
select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } }, select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } },
}), }),
]); ]);
+4 -1
View File
@@ -1,9 +1,12 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */ /** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */
export async function ablaufDaten(jetzt: Date = new Date()) { export async function ablaufDaten(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde();
const hosts = await prisma.host.findMany({ const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null } }, where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
orderBy: [{ subNextduedate: "asc" }], orderBy: [{ subNextduedate: "asc" }],
select: { select: {
id: true, id: true,
+13 -1
View File
@@ -1,9 +1,13 @@
import { prisma } from "@/lib/db"; 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. */ /** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
export async function kundenListe() { export async function kundenListe() {
const nurKunde = await sichtbarerKunde();
const kunden = await prisma.kunde.findMany({ const kunden = await prisma.kunde.findMany({
where: nurKunde ? { id: nurKunde } : {},
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
standorte: true, standorte: true,
@@ -42,6 +46,9 @@ export async function kundenListe() {
} }
export async function kundeMitDetails(id: string) { export async function kundeMitDetails(id: string) {
const nurKunde = await sichtbarerKunde();
if (nurKunde && nurKunde !== id) return null;
return prisma.kunde.findUnique({ return prisma.kunde.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -54,6 +61,10 @@ export async function kundeMitDetails(id: string) {
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */ /** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
export async function laufzeitenVonKunde(kundeId: string) { 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({ const laufzeiten = await prisma.laufzeit.findMany({
where: { host: { kundeId } }, where: { host: { kundeId } },
orderBy: { start: "desc" }, orderBy: { start: "desc" },
@@ -72,7 +83,8 @@ export async function laufzeitenVonKunde(kundeId: string) {
start: laufzeit.start, start: laufzeit.start,
ende: laufzeit.ende, ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG", vorschlag: laufzeit.status === "VORSCHLAG",
einkauf: laufzeit.proxmoxPosition einkauf:
einkaufSichtbar && laufzeit.proxmoxPosition
? { ? {
nummer: laufzeit.proxmoxPosition.rechnung.nummer, nummer: laufzeit.proxmoxPosition.rechnung.nummer,
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag), nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
+30 -8
View File
@@ -1,5 +1,6 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { darfEinkaufSehen, sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface RechnungsFilter { export interface RechnungsFilter {
status?: "offen" | "erledigt" | "alle" | "vorschlaege"; status?: "offen" | "erledigt" | "alle" | "vorschlaege";
@@ -58,8 +59,12 @@ function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
} }
export async function rechnungsListe(filter: RechnungsFilter) { 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({ const laufzeiten = await prisma.laufzeit.findMany({
where: bedingung(filter), where,
orderBy: [{ start: "desc" }], orderBy: [{ start: "desc" }],
include: { include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } }, 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 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; const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return { return {
@@ -81,10 +89,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
start: laufzeit.start, start: laufzeit.start,
ende: laufzeit.ende, ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG", vorschlag: laufzeit.status === "VORSCHLAG",
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null, einkaufNummer: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.nummer ?? null) : null,
einkaufBetrag: einkauf, einkaufBetrag: einkauf,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null, einkaufBezahlt:
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null, einkaufSichtbar && laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: einkaufSichtbar ? (laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null) : null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null, verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: verkauf, verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false, verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
@@ -111,15 +120,22 @@ export type RechnungsZeile = Awaited<ReturnType<typeof rechnungsListe>>["zeilen"
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */ /** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
export async function laufzeitJahre(): Promise<string[]> { export async function laufzeitJahre(): Promise<string[]> {
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) => return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
b.localeCompare(a), b.localeCompare(a),
); );
} }
export async function laufzeitDetail(id: string) { export async function laufzeitDetail(id: string) {
return prisma.laufzeit.findUnique({ const nurKunde = await sichtbarerKunde();
where: { id }, const einkaufSichtbar = await darfEinkaufSehen();
const laufzeit = await prisma.laufzeit.findFirst({
where: nurKunde ? { id, host: { kundeId: nurKunde } } : { id },
include: { include: {
host: { host: {
select: { 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() { 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" } }); return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
} }
+6
View File
@@ -24,6 +24,12 @@ describe("Einstellungen", () => {
expect(einstellungenSchema.safeParse({ anmeldeversuche: 1 }).success).toBe(false); 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)", () => { it("lässt eine leere Dashboard-Adresse zu (dann gilt die Umgebung)", () => {
expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe(""); expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe("");
}); });
+145
View File
@@ -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);
});
});
+30
View File
@@ -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");
});
});