Compare commits

...
16 Commits
Author SHA1 Message Date
florian.wilde 9f1eb189db Merge branch 'phase/031-aktualisierung'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 00:25:03 +02:00
florian.wildeandClaude Opus 5 585173622d fix(deps): Next.js auf 16.3.8 wegen kritischer Meldung
Fuer Next.js 16.2.0 bis 16.3.5 wurde eine als kritisch eingestufte Luecke in
next/og gemeldet (RCE, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt next/og nicht
und war damit nicht angreifbar; vor einem Produktivgang gehoert die Fassung
trotzdem aktualisiert.

Ausserdem @simplewebauthn/server auf 14.0.3. Tests, Typen, Lint und Build
gruen, npm audit ohne Befund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 00:25:03 +02:00
florian.wildeandClaude Opus 5 9c6c6a1c35 docs: echte Repository-Adresse eintragen
Prüfung / pruefung (push) Canceled after 0s
README und Inbetriebnahme nannten Platzhalter, die community-scripts-Dateien
einen falschen Benutzerpfad (fw statt florian.wilde) - der Einzeiler haette
dort ins Leere gezeigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:21:12 +02:00
florian.wilde 2195418867 Merge branch 'phase/030-netbird-proxy'
Prüfung / pruefung (push) Canceled after 0s
2026-09-19 10:56:05 +02:00
florian.wildeandClaude Opus 5 ddca903687 docs(betrieb): netbird Reverse Proxy (Beta) aufnehmen (#030)
netbird bringt seit v0.65 einen eigenen Reverse Proxy mit, v0.72 ergaenzte
private Dienste. Damit entfaellt ein zusaetzlicher nginx: TLS-Terminierung
und Weiterleitung durchs Mesh, ohne offenen Port.

Wichtigster Hinweis in der Anleitung: Eine Anmeldehuerde vor dem Dienst
(SSO, Passwort, PIN) gilt auch fuer /api/agent. Die Agents weisen sich mit
eigenem Token aus und koennen sich nirgends anmelden - sie wuerden stumm ins
Leere melden. Zwei Auswege beschrieben: zwei Dienste oder ein privater
Dienst, der nur aus dem netbird-Netz erreichbar ist.

Beta-Einschraenkungen benannt: kein Rosenpass, selbst gehostet nur mit
Traefik (TLS-Passthrough).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 10:56:05 +02:00
florian.wildeandClaude Opus 5 eaaabd8e75 docs(anleitung): drei Anmeldewege im Handbuch beschreiben (#028)
Prüfung / pruefung (push) Canceled after 0s
Das Kapitel nannte nur Passwort und OIDC; der Passkey-Weg fehlte. Bild der
Anmeldeseite aufgefrischt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:53:43 +02:00
florian.wilde 6317b731a2 Merge branch 'phase/029-betriebsdoku'
Prüfung / pruefung (push) Canceled after 0s
2026-09-19 00:51:16 +02:00
florian.wildeandClaude Opus 5 b6fdd4f64a docs(betrieb): Einrichtung Schritt fuer Schritt dokumentieren (#029)
Vier Anleitungen unter docs/betrieb/: Inbetriebnahme in der richtigen
Reihenfolge, HTTPS mit nginx Proxy Manager sowie netbird als privates Netz,
OIDC mit authentik samt Fehlerbildern, und Sicherung einschliesslich
Zurueckspielen.

Beim Aufschreiben zwei Fehler gefunden, die nur hinter einem Reverse Proxy
auftreten: Die OIDC-Rueckkehradresse uebersah die im Dashboard eingetragene
Adresse, und beim Einloesen des Codes wurde die interne Adresse verwendet -
der Anbieter haette den Tausch abgelehnt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:51:16 +02:00
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
florian.wilde 7d8e5c9172 Merge branch 'phase/026-auslieferung'
Prüfung / pruefung (push) Canceled after 0s
2026-09-19 00:11:33 +02:00
florian.wildeandClaude Opus 5 6c3729bae8 feat(deploy): Auslieferung als Docker-Stack und als Helper-Script (#026)
Docker: deploy/update.sh sichert, baut, migriert und prueft die
Erreichbarkeit; der Worker hat jetzt eine Zustandspruefung. Der ganze Stapel
wurde durchgespielt - bauen, starten, migrieren, anmelden, Agent-Skript
abrufen.

Dabei ein echter Fehler aufgefallen: Nach der Anmeldung zeigte die
Weiterleitung auf den internen Containernamen. Hinter einem Reverse Proxy
waere das eine tote Adresse gewesen. Weiterleitungen nehmen jetzt die
eingetragene Adresse, sonst APP_URL, sonst die Proxy-Kopfzeilen.

Dazu Skripte nach Art der Proxmox VE Helper-Scripts (Node und PostgreSQL
direkt im LXC, ohne Docker). Vorerst intern: Eine Einreichung setzt
oeffentlichen Quelltext voraus und laeuft ueber ProxmoxVED.

Ausserdem docs/roadmap.md: Bis 1.0.0 fehlt kein Code mehr, sondern
Erprobung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:11:33 +02:00
florian.wilde ffc80a8fcf Merge branch 'phase/025-einstellungen'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:58:30 +02:00
florian.wildeandClaude Opus 5 d416f3869a feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)
Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:58:30 +02:00
florian.wilde 19b0751c16 Merge branch 'phase/024-owasp'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:45:35 +02:00
florian.wildeandClaude Opus 5 7775b6211f feat(security): Haertung nach OWASP Top 10 (#024)
Drei echte Luecken gefunden und geschlossen:

1. Die 27 Server Actions verliessen sich allein auf proxy.ts. Jede prueft die
   Sitzung jetzt selbst und schlaegt gegen die Datenbank nach, ob das Konto
   noch gilt - damit lassen sich Sitzungen ueberhaupt erst widerrufen.
2. Der Anmelde-Endpunkt prueft die Herkunft des Formulars; vorher war
   Login-CSRF moeglich.
3. Content-Security-Policy mit Nonce je Anfrage und strict-dynamic.

Dazu: npm audit ist ohne Befund (mysql2 und deepmerge-ts per overrides
angehoben statt Prisma zurueckzustufen), eine Benutzerverwaltung fuer die
Kommandozeile und docs/sicherheit.md mit allem, was bewusst offen bleibt -
keine Rollen, keine Zwei-Faktor-Anmeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:35 +02:00
108 changed files with 4940 additions and 238 deletions
+5 -1
View File
@@ -24,7 +24,8 @@ OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET="" OIDC_CLIENT_SECRET=""
OIDC_NAME="authentik" OIDC_NAME="authentik"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails).
# Lässt sich später unter Einstellungen → System überschreiben.
APP_URL="http://localhost:3000" APP_URL="http://localhost:3000"
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde) # Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
@@ -33,6 +34,9 @@ WORKER_CRON="0 * * * *"
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr) # Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
ZUSAMMENFASSUNG_CRON="0 7 * * *" ZUSAMMENFASSUNG_CRON="0 7 * * *"
# Zeitplan fürs Aufräumen alter Reports und Protokolleinträge (Fristen: Einstellungen → System)
AUFRAEUM_CRON="30 3 * * *"
# Ablage für hochgeladene Rechnungs-PDFs # Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads" UPLOAD_DIR="./data/uploads"
+71
View File
@@ -17,6 +17,77 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.19.2] – 2026-10-03
### Sicherheit
- Next.js auf 16.3.8 angehoben. Für 16.2.0 bis 16.3.5 wurde eine als **kritisch** eingestufte Lücke in `next/og` gemeldet (Remote Code Execution, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt `next/og` nicht, war also nicht angreifbar – die Fassung gehört trotzdem aktualisiert, bevor etwas produktiv läuft
- `@simplewebauthn/server` auf 14.0.3
## [0.19.1] – 2026-09-19
### Hinzugefügt
- Anleitung für den netbird Reverse Proxy (Beta): Dienst anlegen, Zertifikat, private Dienste – samt Warnung, dass eine Anmeldehürde vor dem Dienst auch die Agents aussperrt (#030)
## [0.19.0] – 2026-09-19
### Hinzugefügt
- `docs/betrieb/`: Inbetriebnahme, Reverse Proxy (nginx Proxy Manager und netbird), OIDC mit authentik und Sicherung – jeweils Schritt für Schritt (#029)
### Behoben
- Die OIDC-Rückkehradresse berücksichtigte die im Dashboard eingetragene Adresse nicht, sondern nur `APP_URL` (#029)
- Beim Einlösen des OIDC-Codes wurde hinter einem Reverse Proxy die interne Adresse verwendet; der Tausch wäre am Anbieter gescheitert (#029)
## [0.18.0] – 2026-09-19
### Hinzugefügt
- Drei Rollen: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Hosts und Rechnungen – ohne Einkaufspreise und Marge – und dürfen Notizen schreiben sowie Warnungen quittieren (#027)
- Benutzerverwaltung unter *Einstellungen → Benutzer*: anlegen, Rolle ändern, sperren, Passwort setzen, löschen (#027)
- Anmeldung mit Passkeys (WebAuthn), gleichwertig neben Passwort und OIDC; eigene Seite *Meine Passkeys* (#028)
- Einstellung, welche Rolle automatisch angelegte OIDC-Konten bekommen (#027)
### Behoben
- Neu über OIDC angelegte Konten hätten die Vorgabe des Datenmodells geerbt und wären Administratoren geworden; Vorgabe ist jetzt *Techniker* (#027)
- Das Screenshot-Skript meldete sich mit einem erfundenen Konto an – seit der Sitzungsprüfung gegen die Datenbank landete es auf der Anmeldeseite (#027)
## [0.17.0] – 2026-09-19
### Hinzugefügt
- `deploy/update.sh`: sichert, baut neu, migriert und prüft, ob das Dashboard wieder antwortet (#026)
- Installationsskripte nach Art der Proxmox VE Helper-Scripts unter `deploy/community-scripts/` – Container mit Node und PostgreSQL, ohne Docker (#026)
- `docs/roadmap.md`: was bis 1.0.0 fehlt und was danach lohnt (#026)
- Zustandsprüfung für den Worker im Docker-Stack (#026)
### Behoben
- Nach der Anmeldung führte die Weiterleitung im Container auf den internen Containernamen (`http://<container-id>:3000`) statt auf die erreichbare Adresse – hinter einem Reverse Proxy landete man damit im Nichts. Beim Prüfen des Docker-Stapels aufgefallen (#026)
### Geändert
- README auf den tatsächlichen Stand gebracht: Der Feldtest ist erledigt, der Hinweis „noch nicht erprobt“ war überholt (#026)
## [0.16.0] – 2026-09-18
### Hinzugefügt
- *Einstellungen → System*: Dashboard-Adresse, Melderhythmus und Selbstaktualisierung der Agents, kaufmännische Vorgaben, Sicherheitsgrenzen, Firmenangaben und Zeitzone (#025)
- Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Installations-Einzeiler und im systemd-Timer (#025)
- OIDC lässt sich auf bestimmte E-Mail-Domänen begrenzen; das automatische Anlegen unbekannter Konten ist abschaltbar (#025)
- Nächtliches Aufräumen nach einstellbaren Aufbewahrungsfristen für Reports und Protokoll – der jüngste Report jedes Hosts bleibt erhalten (#025)
- Firmenangaben erscheinen im Kopf der PDF-Ausgaben (#025)
### Geändert
- Steuersatz, Zahlungsziel und Aufschlag kommen aus den Einstellungen statt aus fest eingetragenen 19 % (#025)
## [0.15.0] – 2026-09-18
### Hinzugefügt
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
### Behoben
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
## [0.14.1] – 2026-09-18 ## [0.14.1] – 2026-09-18
### Behoben ### Behoben
+33 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 9 h 43 min **Gesamt:** 15 h 23 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -29,6 +29,13 @@
| #018 Feldtest auf echten Hosts | 0 h 40 min | | #018 Feldtest auf echten Hosts | 0 h 40 min |
| #022 Cluster-Abgleich | 0 h 25 min | | #022 Cluster-Abgleich | 0 h 25 min |
| #023 Feldtest PBS | 0 h 35 min | | #023 Feldtest PBS | 0 h 35 min |
| #024 OWASP-Härtung | 0 h 45 min |
| #025 Einstellungen | 1 h 10 min |
| #026 Auslieferung | 0 h 40 min |
| #027 Rollen und Benutzerverwaltung | 1 h 20 min |
| #028 Passkeys | 0 h 45 min |
| #029 Einrichtungsdokumentation | 0 h 40 min |
| #030 netbird Reverse Proxy | 0 h 20 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -51,7 +58,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.14.1 **Version:** 0.19.2
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -138,6 +145,30 @@
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme - Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig - Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
- Einstellungen im Dashboard (#025): vier Bereiche, alle Werte wirken auch wirklich – der Melderhythmus zum Beispiel bis in den erzeugten systemd-Timer hinein, live geprüft
- Dabei die OIDC-Lücke aus #024 geschlossen: Domänenliste und Schalter gegen automatisch angelegte Konten
- Bewusst nicht gebaut: „stumm nach X Tagen“ (steht schon bei den Warnregeln) und die Währung in der Browseransicht
- Auslieferung finalisiert (#026): Docker-Weg mit Update-Skript, dazu Skripte nach Art der Helper-Scripts für einen LXC ohne Docker
- Recherche dazu: Das Gerüst der community-scripts liegt inzwischen in einem eigenen Repository, und neue Skripte gehören zuerst nach ProxmoxVED. Eine Einreichung setzt öffentlichen Quelltext voraus – deshalb vorerst intern
- Roadmap geschrieben: Bis 1.0.0 fehlt kein Code mehr, sondern Erprobung (PMG, PBS mit Lizenz, ein Monat Dauerbetrieb, einmal eine Sicherung zurückspielen)
- Rollen und Benutzerverwaltung (#027): Die Einschränkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben. Sechs Tests prüfen, dass ein Kundenkonto weder fremde Hosts noch Einkaufspreise sieht
- Dabei aufgefallen: Neu angelegte OIDC-Konten hätten die Modellvorgabe ADMIN geerbt – behoben, mit eigener Einstellung und Test
- Passkeys (#028): passwortlos, mehrere je Konto, Aufgabe in einem kurzlebigen Cookie. Nur über HTTPS nutzbar – die Seite sagt es, statt still zu scheitern
- Einrichtungsdokumentation (#029): Beim Aufschreiben des OIDC-Wegs fielen zwei Fehler auf, die nur hinter einem Reverse Proxy auftreten – Rückkehradresse und Code-Einlösung nahmen die interne Adresse. Beides behoben
- Erkenntnis: Eine Anleitung Schritt für Schritt aufzuschreiben findet Fehler, die kein Test findet, weil sie nur in einer bestimmten Betriebsform auftreten
- netbird Reverse Proxy (#030) in die Anleitung aufgenommen – Florians Hinweis, dass die Beta gut läuft. Beim Durchgehen der Möglichkeiten fiel der Fallstrick auf: Eine Anmeldehürde vor dem Dienst trifft auch `/api/agent` und legt alle Agents still
### 03.10.2026 – Prüfung vor dem Einsatz
- `npm audit` vor der Freigabe erneut laufen lassen: In den zwei Wochen seit dem letzten Stand kam eine kritische Meldung zu Next.js dazu (RCE in `next/og`). Wir nutzen `next/og` nicht, waren also nicht angreifbar – angehoben auf 16.3.8, Tests und Build grün
- Lehre für den Betrieb: Vor jedem Produktivgang und danach regelmäßig `npm audit` prüfen, nicht nur bei Änderungen am Code
### 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)
+50 -10
View File
@@ -3,15 +3,17 @@
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway). Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT. Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten > **Status:** einsatzbereit. Der Agent ist auf echten Hosts erprobt – Proxmox VE 9.2 mit und ohne
> Proxmox-Hosts erprobt (#018). > Subscription, im Cluster und einzeln, sowie Proxmox Backup Server 4.2 (#018, #023).
> Ungeprüft: Mail Gateway und eine aktive Subscription auf PBS. Was bis 1.0 fehlt, steht in
> [docs/roadmap.md](docs/roadmap.md).
## Schnellstart ## Schnellstart
Auf einem Rechner mit Docker: Auf einem Rechner mit Docker:
```bash ```bash
git clone <dieses-repo> proxledger && cd proxledger git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env cp .env.example .env
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL # Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d docker compose up -d
@@ -20,14 +22,27 @@ docker compose up -d
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`. Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
Danach ist das Dashboard unter `APP_URL` erreichbar. Danach ist das Dashboard unter `APP_URL` erreichbar.
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an: Aktualisieren geht mit einem Befehl – er sichert vorher, baut neu, migriert und prüft zum Schluss,
ob das Dashboard wieder antwortet:
```bash ```bash
bash deploy/lxc-installer.sh bash deploy/update.sh
``` ```
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter **Auf einem Proxmox-Host** gibt es zwei Wege zu einem fertigen LXC-Container:
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
```bash
bash deploy/lxc-installer.sh # eigener Installer: Container mit Docker darin
```
```bash
# Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Der zweite Weg folgt den Konventionen von [community-scripts.org](https://community-scripts.org) und
braucht kein Docker; Einzelheiten und die Voraussetzungen für eine Einreichung stehen in
[deploy/community-scripts/README.md](deploy/community-scripts/README.md).
### Agent ausrollen ### Agent ausrollen
@@ -105,13 +120,38 @@ Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslie
sie sich mit eigenen Tokens ausweisen. sie sich mit eigenen Tokens ausweisen.
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche - **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
je Kennung und Viertelstunde, danach Sperre. je Kennung und Viertelstunde, danach Sperre (einstellbar unter *Einstellungen → System*).
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse Benutzer sperren oder Passwort setzen: `npm run benutzer -- sperren <email>`.
- **Passkey:** passwortlos über WebAuthn, mehrere je Konto, Verwaltung unter *Meine Passkeys*. Setzt HTTPS voraus (außer `localhost`).
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen – Schritt für Schritt in [docs/betrieb/authentik.md](docs/betrieb/authentik.md). Rückkehradresse
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. Unter *Einstellungen → System* lässt sich
einschränken, welche E-Mail-Domänen zugelassen sind und ob unbekannte Konten angelegt werden dürfen.
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle - **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
Sitzungen ungültig. Sitzungen ungültig.
## Einrichtung im Betrieb
| Anleitung | Inhalt |
|---|---|
| [Inbetriebnahme](docs/betrieb/inbetriebnahme.md) | von null zum meldenden Host, in der richtigen Reihenfolge |
| [Reverse Proxy](docs/betrieb/reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
| [authentik](docs/betrieb/authentik.md) | Anmeldung über OIDC, samt Fehlerbildern |
| [Sicherung](docs/betrieb/sicherung.md) | was gesichert wird – und wie man es zurückspielt |
## Einstellungen
Die meisten Betriebswerte stehen unter *Einstellungen → System* und wirken sofort: Dashboard-Adresse,
Melderhythmus und Selbstaktualisierung der Agents, Steuersatz, Zahlungsziel und Aufschlag,
Sitzungsdauer und Anmeldeversuche, OIDC-Beschränkungen, Aufbewahrungsfristen sowie Firmenangaben und
Zeitzone für die PDF-Ausgaben.
In der Umgebung bleiben nur `APP_SECRET` und `DATABASE_URL` – beide werden gebraucht, bevor die
Anwendung die Datenbank lesen kann.
Wie die Anwendung gegen die OWASP Top 10 dasteht (einschließlich der Punkte, die bewusst offen
bleiben), steht in [docs/sicherheit.md](docs/sicherheit.md).
## Exporte ## Exporte
| Ansicht | CSV | PDF | | Ansicht | CSV | PDF |
+15 -7
View File
@@ -16,9 +16,17 @@ set -o nounset
set -o pipefail set -o pipefail
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}" DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
# Melderhythmus und Streuung setzt das Dashboard beim Ausliefern ein (Einstellungen → System).
# Wird das Skript unverändert aus dem Quelltext ausgeführt, bleiben die Platzhalter stehen;
# dann gelten die Vorgaben.
TIMER_ONCALENDAR="@@TIMER_ONCALENDAR@@"
TIMER_STREUUNG="@@TIMER_STREUUNG@@"
case "$TIMER_ONCALENDAR" in *@@*) TIMER_ONCALENDAR="daily" ;; esac
case "$TIMER_STREUUNG" in *@@*) TIMER_STREUUNG="4h" ;; esac
TOKEN="" TOKEN=""
CA_CERT="" CA_CERT=""
AUTO_UPDATE="no" AUTO_UPDATE="@@AUTO_UPDATE@@"
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
AKTION="install" AKTION="install"
CONFIG_VERZEICHNIS="/etc/proxledger" CONFIG_VERZEICHNIS="/etc/proxledger"
@@ -202,14 +210,14 @@ ProtectSystem=full
ProtectHome=true ProtectHome=true
UNIT UNIT
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT' cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
[Unit] [Unit]
Description=Täglicher ProxLedger-Report Description=ProxLedger-Report
[Timer] [Timer]
OnCalendar=daily OnCalendar=${TIMER_ONCALENDAR}
# Verteilt die Meldungen aller Hosts über vier Stunden. # Verteilt die Meldungen aller Hosts über diesen Zeitraum.
RandomizedDelaySec=4h RandomizedDelaySec=${TIMER_STREUUNG}
Persistent=true Persistent=true
Unit=proxledger.service Unit=proxledger.service
@@ -219,7 +227,7 @@ UNIT
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now proxledger.timer >/dev/null systemctl enable --now proxledger.timer >/dev/null
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)." erfolg "systemd-Timer aktiviert (${TIMER_ONCALENDAR}, zufällig um bis zu ${TIMER_STREUUNG} versetzt)."
hinweis "Sende ersten Report …" hinweis "Sende ersten Report …"
if "$AGENT_PFAD" --report; then if "$AGENT_PFAD" --report; then
+63
View File
@@ -0,0 +1,63 @@
# Installation als LXC-Container nach Art der Proxmox VE Helper-Scripts
Diese beiden Skripte folgen den Konventionen von [community-scripts.org](https://community-scripts.org)
(der Nachfolge von tteck's Helper-Scripts). Sie legen einen unprivilegierten Debian-13-Container an,
installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Repository, richten zwei
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
`/opt/proxledger/.credentials`.
| Datei | Zweck |
|---|---|
| `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) |
| `install/proxledger-install.sh` | Installation im Container |
## Verwendung im eigenen Netz
Auf dem Proxmox-VE-Host als root:
```bash
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Das Gerüst (`build.func`, `install.func`) lädt das Skript von den community-scripts nach – das ist
öffentlich und ohne Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git.
Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist:
```bash
PROXLEDGER_REPO=https://git.example.de/fw/ProxLedger.git PROXLEDGER_BRANCH=main bash -c "$(curl -fsSL …)"
```
Ist das Git-Repository nicht ohne Anmeldung lesbar, gehört ein Lesezugriffs-Token in die Adresse
(`https://<token>@git.example.de/...`). Das Token landet dann in der Prozessliste des Containers –
also besser einen Deploy-Token verwenden, der nur lesen darf.
## Was eine Einreichung bei community-scripts verlangen würde
Wir liefern die Skripte vorerst **nur intern** aus. Für eine Einreichung wären zusätzlich nötig:
1. **Öffentlich erreichbarer Quelltext.** Die Skripte dort holen die Anwendung praktisch immer aus
einem öffentlichen GitHub-Repository (`fetch_and_deploy_gh_release`). Ein privates Gitea genügt nicht.
2. **Pull Request gegen [ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)**, nicht gegen
ProxmoxVE. Neue Skripte, die direkt gegen ProxmoxVE eingereicht werden, werden ohne Prüfung
geschlossen.
3. **MIT-Lizenz** im eigenen Repository und der Lizenzkopf in beiden Skripten (steht bereits darin).
4. **Englische Dokumentation** – die Skripte selbst sind auf Englisch gehalten, die deutschen
Kommentare in diesem Verzeichnis müssten übersetzt werden.
5. **Metadaten für die Webseite.** Die JSON-Dateien liegen inzwischen nicht mehr im Skript-Repository;
wo sie hingehören, steht in der aktuellen Beitragsanleitung. Das ist beim Einreichen zu prüfen.
Die Skripte sind so geschrieben, dass für eine spätere Einreichung nur die Quelle zu tauschen und die
Kommentare zu übersetzen sind – am Ablauf ändert sich nichts.
## Unterschied zum Docker-Weg
| | Helper-Script (LXC) | Docker Compose |
|---|---|---|
| Umgebung | eigener Container auf dem Proxmox-Host | beliebiger Docker-Host |
| Datenbank | PostgreSQL im selben Container | eigener Dienst |
| Aktualisierung | `update`-Weg des Skripts | `bash deploy/update.sh` |
| Sicherung | Proxmox-Sicherung des Containers | `bash deploy/backup.sh` |
Beide Wege sind gleichwertig. Wer ohnehin Proxmox betreibt, fährt mit dem LXC-Weg meist einfacher –
die Sicherung des Containers erledigt dann die gewohnte Proxmox-Sicherung mit.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 tteck
# Author: Florian Wilde
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
APP="ProxLedger"
var_tags="${var_tags:-monitoring;licensing}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-10}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_arm64="${var_arm64:-yes}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/proxledger ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
NODE_VERSION="24" setup_nodejs
msg_info "Stopping Services"
systemctl stop proxledger proxledger-worker
msg_ok "Stopped Services"
# Die .env traegt APP_SECRET; geht sie verloren, sind Sitzungen und gespeicherte
# Zugangsdaten unbrauchbar. Deshalb vor jedem Update sichern und danach zurueckspielen.
create_backup /opt/proxledger/.env
msg_info "Updating ${APP}"
cd /opt/proxledger
$STD git pull --ff-only
$STD npm ci
$STD npx prisma generate
$STD npx prisma migrate deploy
$STD npm run build
$STD npm run build:worker
msg_ok "Updated ${APP}"
restore_backup
msg_info "Starting Services"
systemctl start proxledger proxledger-worker
msg_ok "Started Services"
msg_ok "Update Successful"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
echo -e "${INFO}${YW} Login credentials are in:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}/opt/proxledger/.credentials${CL}"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# Author: Florian Wilde
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
# Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist.
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/florian.wilde/ProxLedger.git}"
ZWEIG="${PROXLEDGER_BRANCH:-main}"
msg_info "Installing Dependencies"
$STD apt install -y git ca-certificates
msg_ok "Installed Dependencies"
NODE_VERSION="24" setup_nodejs
PG_VERSION="17" setup_postgresql
msg_info "Setting up Database"
DB_NAME="proxledger"
DB_USER="proxledger"
DB_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 20)
# APP_SECRET signiert die Sitzungen und verschluesselt gespeicherte Zugangsdaten.
APP_SECRET=$(openssl rand -base64 48)
$STD sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
$STD sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} WITH OWNER ${DB_USER} ENCODING 'UTF8';"
msg_ok "Set up Database"
msg_info "Setting up ProxLedger"
$STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger
cd /opt/proxledger
IP_ADRESSE=$(hostname -I | awk '{print $1}')
cat <<EOF >/opt/proxledger/.env
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
APP_SECRET="${APP_SECRET}"
APP_URL="http://${IP_ADRESSE}:3000"
ADMIN_EMAIL="admin@proxledger.local"
ADMIN_NAME="Administrator"
ADMIN_PASSWORD="${ADMIN_PASS}"
UPLOAD_DIR="/opt/proxledger/data/uploads"
NODE_ENV="production"
PORT="3000"
EOF
chmod 600 /opt/proxledger/.env
mkdir -p /opt/proxledger/data/uploads
$STD npm ci
$STD npx prisma generate
$STD npx prisma migrate deploy
$STD npx prisma db seed
$STD npm run build
$STD npm run build:worker
msg_ok "Set up ProxLedger"
msg_info "Creating Services"
cat <<EOF >/etc/systemd/system/proxledger.service
[Unit]
Description=ProxLedger Dashboard
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/proxledger
EnvironmentFile=/opt/proxledger/.env
ExecStart=/usr/bin/npm run start
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF >/etc/systemd/system/proxledger-worker.service
[Unit]
Description=ProxLedger Worker (Warnregeln und Benachrichtigungen)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/proxledger
EnvironmentFile=/opt/proxledger/.env
ExecStart=/usr/bin/node dist/worker.mjs
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now proxledger proxledger-worker
msg_ok "Created Services"
# Die Zugangsdaten liegen fuer den Betreiber lesbar im Container; APP_SECRET steht in der .env.
{
echo "ProxLedger"
echo "Adresse: http://${IP_ADRESSE}:3000"
echo "Benutzer: admin@proxledger.local"
echo "Passwort: ${ADMIN_PASS}"
echo
echo "Datenbank: ${DB_NAME} / ${DB_USER} / ${DB_PASS}"
echo
echo "Wichtig: APP_SECRET aus /opt/proxledger/.env mitsichern."
echo "Ohne diesen Wert sind Sitzungen und gespeicherte Zugangsdaten unbrauchbar."
} >>~/proxledger.creds
cp ~/proxledger.creds /opt/proxledger/.credentials
chmod 600 /opt/proxledger/.credentials
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
msg_ok "Cleaned"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ProxLedger aktualisieren (Docker-Installation).
#
# bash deploy/update.sh # Quelltext holen, neu bauen, migrieren, neu starten
# bash deploy/update.sh --ohne-sicherung
#
# Vor dem Aktualisieren wird gesichert – das dauert Sekunden und erspart schlechte Abende.
set -o errexit
set -o nounset
set -o pipefail
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$VERZEICHNIS"
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
SICHERN="ja"
[ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein"
command -v docker >/dev/null 2>&1 || fehler "Docker nicht gefunden."
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
if [ "$SICHERN" = "ja" ]; then
blau "Sicherung anlegen …"
bash deploy/backup.sh
gruen "Sicherung abgeschlossen."
fi
if [ -d .git ]; then
blau "Neuen Stand holen …"
git pull --ff-only
fi
blau "Abbilder neu bauen …"
docker compose build --pull
blau "Datenbank migrieren …"
docker compose run --rm migrate
blau "Dienste neu starten …"
docker compose up -d
# Kurz warten und nachsehen, ob das Dashboard wieder antwortet.
blau "Warte auf das Dashboard …"
for versuch in $(seq 1 30); do
if docker compose exec -T web wget -qO- http://localhost:3000/api/gesundheit >/dev/null 2>&1; then
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
docker compose ps
exit 0
fi
sleep 1
done
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
+8
View File
@@ -81,8 +81,16 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
WORKER_CRON: ${WORKER_CRON:-0 * * * *} WORKER_CRON: ${WORKER_CRON:-0 * * * *}
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *} ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
AUFRAEUM_CRON: ${AUFRAEUM_CRON:-30 3 * * *}
volumes: volumes:
- uploads:/data - uploads:/data
healthcheck:
# Der Worker spricht kein HTTP; geprüft wird schlicht, ob der Prozess noch da ist.
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
interval: 60s
timeout: 5s
retries: 3
start_period: 20s
volumes: volumes:
datenbank: datenbank:
+85 -4
View File
@@ -17,11 +17,16 @@ auch mit Hosts hinter NAT.
7. [Warnungen](#7-warnungen) 7. [Warnungen](#7-warnungen)
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten) 8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
9. [Exporte](#9-exporte) 9. [Exporte](#9-exporte)
9a. [Einstellungen](#9a-einstellungen)
9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung) 10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche) 11. [Fehlersuche](#11-fehlersuche)
--- ---
> **Einrichtung statt Bedienung?** Reverse Proxy, OIDC und Sicherung stehen in
> [docs/betrieb/](../betrieb/README.md). Dieses Handbuch beschreibt die tägliche Arbeit.
## 1. Installation ## 1. Installation
### Mit Docker ### Mit Docker
@@ -71,10 +76,16 @@ Traefik liegen in [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
![Anmeldeseite](bilder/login.png) ![Anmeldeseite](bilder/login.png)
Anmelden mit der E-Mail-Adresse und dem Passwort aus der `.env`. Ist ein Anmeldedienst über OIDC Es gibt drei gleichwertige Wege hinein:
eingerichtet (`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`), erscheint zusätzlich ein Knopf
„Mit authentik anmelden“. Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst - **Passkey** – der Knopf ganz oben. Der Browser fragt nach Fingerabdruck, Gesicht oder PIN. Anlegen
einmal ausfällt, kommst du trotzdem hinein. lässt sich einer unter *Meine Passkeys* ([Kapitel 9b](#9b-benutzer-rollen-und-passkeys)).
- **Passwort** – E-Mail-Adresse und Passwort aus der `.env` bzw. aus der Benutzerverwaltung.
- **OIDC** – ist ein Anmeldedienst eingerichtet, erscheint zusätzlich „Mit authentik anmelden“
(Einrichtung: [docs/betrieb/authentik.md](../betrieb/authentik.md)).
Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst einmal ausfällt, kommst du
trotzdem hinein.
Nach der Anmeldung landest du auf dem Dashboard: Nach der Anmeldung landest du auf dem Dashboard:
@@ -310,6 +321,76 @@ Jahresliste für die Buchhaltung.
--- ---
## 9a. Einstellungen
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
![Systemeinstellungen](bilder/system.png)
**Adresse und Agent.** Die Dashboard-Adresse landet im Installations-Einzeiler und in den
Benachrichtigungen. Bleibt das Feld leer, gilt `APP_URL` aus der Umgebung. Melderhythmus, Streuung und
Selbstaktualisierung gelten für **neu installierte** Agents – bestehende übernehmen sie erst, wenn der
Einzeiler dort erneut läuft.
**Kaufmännisches.** Steuersatz, Zahlungsziel und Aufschlag füllen leere Rechnungsfelder vor. Der
Aufschlag rechnet aus dem Einkaufspreis einen Vorschlag für den Verkaufspreis; eingetragene Beträge
bleiben unberührt.
**Sicherheit.** Sitzungsdauer und Anmeldeversuche gelten ab der nächsten Anmeldung. Die beiden
OIDC-Felder sind wichtig, wenn ein Identitätsanbieter viele Konten kennt: Ohne Einschränkung darf
jeder, der sich dort anmelden kann, hier mitarbeiten. Mit einer Domänenliste oder abgeschaltetem
„Konten anlegen“ kommt nur herein, wer hier vorgesehen ist. Die Aufbewahrungsfristen räumen nachts um
halb vier auf; der jüngste Report jedes Hosts bleibt immer erhalten, sonst stünde ein lange stummer
Host plötzlich ohne Angaben da.
**Firma und Darstellung.** Name, Anschrift und E-Mail stehen im Kopf der erzeugten PDF-Dateien, die
Zeitzone gilt für PDF, CSV und Benachrichtigungen.
> `APP_SECRET` und `DATABASE_URL` stehen bewusst **nicht** hier: Beide werden gebraucht, bevor die
> Anwendung die Datenbank überhaupt lesen kann.
---
## 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: 239 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: 291 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: 264 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: 29 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 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.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 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: 258 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+13
View File
@@ -0,0 +1,13 @@
# Betrieb
Anleitungen für alles, was rund um ProxLedger eingerichtet wird.
| Dokument | Inhalt |
|---|---|
| [inbetriebnahme.md](inbetriebnahme.md) | Von null zum laufenden Dashboard – Docker oder LXC, erster Agent, erste Zuordnung |
| [reverse-proxy.md](reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
| [authentik.md](authentik.md) | Anmeldung über OIDC |
| [sicherung.md](sicherung.md) | Was gesichert werden muss und wie man es zurückspielt |
Für die tägliche Arbeit **mit** ProxLedger ist das [Handbuch](../anleitung/README.md) gedacht.
Was gegen Angriffe getan wird (und was bewusst nicht), steht in [sicherheit.md](../sicherheit.md).
+118
View File
@@ -0,0 +1,118 @@
# Anmeldung über authentik (OIDC)
ProxLedger kann die Anmeldung an einen OIDC-Anbieter abgeben. Getestet ist authentik; mit jedem
anderen Anbieter, der OpenID Connect Discovery beherrscht, funktioniert es genauso.
**Der lokale Zugang bleibt daneben immer bestehen.** Das ist Absicht: Fällt authentik aus, kommt man
trotzdem noch ins Dashboard.
---
## 1. In authentik: Provider anlegen
*Applications → Providers → Create → OAuth2/OpenID Provider*
| Feld | Wert |
|---|---|
| Name | `ProxLedger` |
| Authorization flow | der übliche (z. B. *default-provider-authorization-implicit-consent*) |
| Client type | **Confidential** |
| Client ID | übernehmen, wird gleich gebraucht |
| Client Secret | übernehmen, wird gleich gebraucht |
| Redirect URIs | `https://proxledger.firma.de/api/auth/oidc/callback` |
| Scopes | `openid`, `email`, `profile` |
| Signing Key | ein vorhandenes Zertifikat auswählen |
Zur Rückkehradresse zwei Hinweise, an denen die meisten Einrichtungen scheitern:
- Sie muss **wortgleich** übereinstimmen – einschließlich `https`, Portangabe und ohne Schrägstrich am
Ende. ProxLedger bildet sie aus der Dashboard-Adresse unter *Einstellungen → System*, ersatzweise aus
`APP_URL`.
- authentik erlaubt auch reguläre Ausdrücke. Für eine einzelne Installation ist die genaue Adresse die
bessere Wahl; ein zu weiter Ausdruck öffnet eine Umleitung auf fremde Ziele.
## 2. In authentik: Application anlegen
*Applications → Applications → Create*
| Feld | Wert |
|---|---|
| Name | `ProxLedger` |
| Slug | `proxledger` |
| Provider | der eben angelegte |
Der Slug bestimmt die Discovery-Adresse:
```
https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration
```
Diese Adresse **ohne** den Teil `/.well-known/openid-configuration` ist der Aussteller
(`OIDC_ISSUER`) – also `https://authentik.firma.de/application/o/proxledger/`.
Prüfen lässt sie sich mit:
```bash
curl -fsS https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration | head
```
Wer darf sich anmelden, entscheidet in authentik die **Policy-Bindung** an der Application
(*Applications → ProxLedger → Policy / Group / User Bindings*). Ohne Bindung darf jeder, der bei
authentik ein Konto hat.
## 3. In ProxLedger: Werte eintragen
In die `.env` (Docker) oder `/opt/proxledger/.env` (LXC):
```bash
OIDC_ISSUER="https://authentik.firma.de/application/o/proxledger/"
OIDC_CLIENT_ID="…"
OIDC_CLIENT_SECRET="…"
OIDC_NAME="authentik" # Beschriftung des Knopfes auf der Anmeldeseite
APP_URL="https://proxledger.firma.de"
```
Danach neu starten:
```bash
docker compose up -d # Docker
systemctl restart proxledger # LXC
```
Auf der Anmeldeseite erscheint jetzt **Mit authentik anmelden**.
## 4. Rollen festlegen
ProxLedger liest **keine** Gruppen aus authentik. Wer sich zum ersten Mal anmeldet, bekommt die Rolle
aus *Einstellungen → System → Rolle für neue OIDC-Konten* – Vorgabe ist **Techniker**. Die endgültige
Rolle vergibt ein Administrator unter *Einstellungen → Benutzer*.
Das ist bewusst so: Eine Gruppenzuordnung, die stillschweigend Administratorrechte verteilt, ist
schwerer zu überblicken als eine Liste, in der jeder Zugang sichtbar ist.
Zwei Einstellungen helfen dabei, den Kreis klein zu halten:
- **Erlaubte E-Mail-Domänen** – etwa `firma.de`. Alles andere wird abgewiesen, auch wenn die Anmeldung
bei authentik geklappt hat.
- **Unbekannte OIDC-Anmeldungen legen ein Konto an** – abgeschaltet kommt nur herein, wer hier bereits
ein Konto hat. Dann legt man Konten vorab an (ohne Passwort) und die Anmeldung läuft trotzdem über
authentik.
## 5. Prüfen
1. Abmelden, dann **Mit authentik anmelden**.
2. Unter *Einstellungen → Protokoll* steht `anmeldung.erfolgreich` mit `weg: oidc`.
3. Unter *Einstellungen → Benutzer* steht das Konto mit der vergebenen Rolle und der Anmeldeart `OIDC`.
## Wenn es nicht klappt
| Meldung | Ursache |
|---|---|
| „Die Anmeldung über den Anbieter hat nicht geklappt.“ | Sammelmeldung – die Einzelheiten stehen im Protokoll des Containers (`docker compose logs web`). |
| authentik zeigt *Redirect URI Error* | Die Adresse stimmt nicht wortgleich. Häufig: `http` statt `https`, Schrägstrich am Ende, oder die interne statt der öffentlichen Adresse. |
| `invalid_client` | Client-ID oder -Secret falsch, oder der Provider steht auf *Public* statt *Confidential*. |
| Anmeldung klappt, aber ProxLedger meldet „keine E-Mail-Adresse“ | Der Scope `email` fehlt am Provider. |
| Nach der Anmeldung landet man auf einer unerreichbaren Adresse | `APP_URL` bzw. die Dashboard-Adresse zeigt nicht auf die öffentliche Adresse. |
Siehe auch: [Reverse Proxy](reverse-proxy.md) – ohne HTTPS gibt es weder sinnvolle OIDC-Anmeldung noch
Passkeys.
+81
View File
@@ -0,0 +1,81 @@
# Inbetriebnahme
Der kürzeste Weg von null zu einem Dashboard, das Hosts meldet. Rechnen Sie mit einer halben Stunde.
## Reihenfolge
Die Reihenfolge ist nicht beliebig – zwei Schritte hängen an der endgültigen Adresse:
1. Dashboard installieren
2. **Adresse festlegen** (Reverse Proxy, DNS)
3. OIDC einrichten (falls gewünscht)
4. Passkeys anlegen
5. Agents ausrollen
Wer Schritt 4 oder 5 vorzieht, macht sie nach einem Adresswechsel noch einmal: Passkeys gelten nur für
die Domäne, unter der sie entstanden sind, und die Adresse steckt im Installations-Einzeiler.
## 1. Installieren
**Docker** (beliebiger Host mit Docker):
```bash
git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env
# Pflicht: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d
```
**LXC auf Proxmox**, zwei Möglichkeiten:
```bash
bash deploy/lxc-installer.sh # Container mit Docker darin
```
```bash
# Nach Art der Proxmox VE Helper-Scripts, ohne Docker:
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Der erste Administrator entsteht beim Einrichten aus `ADMIN_EMAIL` und `ADMIN_PASSWORD`; beim
LXC-Weg stehen die erzeugten Zugangsdaten in `/opt/proxledger/.credentials`.
**`APP_SECRET` gehört sofort in die Sicherung.** Er signiert die Sitzungen und verschlüsselt die
gespeicherten Zugangsdaten für SMTP und Webhooks. Ohne ihn sind beide unbrauchbar.
## 2. Adresse festlegen
Siehe [reverse-proxy.md](reverse-proxy.md). Danach unter *Einstellungen → System* die **Adresse des
Dashboards** eintragen – sie landet im Installations-Einzeiler und in den Benachrichtigungen.
## 3. Anmeldung einrichten
- Optional OIDC: [authentik.md](authentik.md)
- Weitere Konten unter *Einstellungen → Benutzer* (Administrator, Techniker, Kunde)
- Jeder legt sich unter *Meine Passkeys* einen Passkey an – danach entfällt das Passwort
## 4. Agents ausrollen
1. *Einstellungen → Agent-Tokens* → Enrollment-Token erzeugen (mit Kundenbindung spart es den
Zuordnungsschritt)
2. Auf jedem Proxmox-Host als root:
```bash
curl -fsSL https://proxledger.firma.de/install.sh | bash -s -- --token <ENROLL_TOKEN>
```
3. **In einem Cluster auf jedem Knoten** – jeder Knoten hat eine eigene Subscription. Das Dashboard
warnt von selbst, wenn ein Knoten fehlt.
4. Danach den Enrollment-Token widerrufen.
## 5. Benachrichtigungen
*Einstellungen → Benachrichtigungen*: SMTP-Zugang oder Webhook eintragen, Testnachricht schicken,
Zusammenfassung auf täglich oder wöchentlich stellen.
## Prüfliste
- [ ] `https://…/api/gesundheit` antwortet mit 200
- [ ] Anmeldung klappt, die Adresszeile bleibt auf der öffentlichen Adresse
- [ ] Ein Host meldet sich (*Hosts* zeigt ihn mit Ablaufdatum)
- [ ] Testnachricht ist angekommen
- [ ] Sicherung läuft und wurde **einmal zurückgespielt** ([sicherung.md](sicherung.md))
- [ ] `APP_SECRET` liegt an einem zweiten Ort
+228
View File
@@ -0,0 +1,228 @@
# HTTPS einrichten: nginx Proxy Manager oder netbird
ProxLedger spricht selbst kein HTTPS – davor gehört ein Reverse Proxy. Ohne ihn fehlen drei Dinge:
- **Passkeys** funktionieren nur im sicheren Kontext (HTTPS oder `localhost`).
- Das Sitzungs-Cookie bekommt kein `Secure`-Kennzeichen.
- Die Agent-Kommunikation läuft unverschlüsselt durchs Netz.
Fertige Beispiele für nginx, Caddy und Traefik liegen unter [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
Dieses Dokument beschreibt die beiden Wege, die in der Praxis am häufigsten vorkommen.
---
## Weg A: nginx Proxy Manager
### 1. Proxy Host anlegen
*Hosts → Proxy Hosts → Add Proxy Host*, Reiter **Details**:
| Feld | Wert |
|---|---|
| Domain Names | `proxledger.firma.de` |
| Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts oder des LXC |
| Forward Port | `3000` |
| Cache Assets | aus |
| Block Common Exploits | an |
| Websockets Support | **an** |
Reiter **SSL**: *Request a new SSL Certificate*, dazu **Force SSL** und **HTTP/2 Support**.
### 2. Kopfzeilen ergänzen
Reiter **Advanced**, in das Feld *Custom Nginx Configuration*:
```nginx
# Ohne diese Angaben baut ProxLedger seine Weiterleitungen aus der internen Adresse
# (etwa http://172.18.0.4:3000) – im Browser landet man dann im Nichts.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Rechnungs-PDFs sind größer als die Standardgrenze von 1 MB.
client_max_body_size 12m;
```
### 3. In ProxLedger eintragen
```bash
APP_URL="https://proxledger.firma.de"
```
und unter *Einstellungen → System* dieselbe Adresse als **Adresse des Dashboards**. Sie steht danach
im Installations-Einzeiler für die Agents und ist die Grundlage für Passkeys und OIDC.
### 4. Prüfen
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.firma.de/api/gesundheit
curl -fsSI https://proxledger.firma.de/login | grep -i "content-security-policy\|strict-transport"
```
Danach einmal anmelden und darauf achten, dass die Adresszeile auf `proxledger.firma.de` bleibt.
---
## Weg B: netbird Reverse Proxy (Beta)
Seit v0.65 bringt netbird einen **eigenen Reverse Proxy** mit; v0.72 hat private Dienste und „Bring
Your Own Proxy“ ergänzt. Damit entfällt der nginx Proxy Manager vollständig: netbird übernimmt die
TLS-Terminierung und reicht den Verkehr durch das Mesh an den Ziel-Peer weiter – **ohne offenen Port
und ohne Firewall-Regel auf dem ProxLedger-Host**.
Für ProxLedger ist das der bequemste Weg, weil er die beiden Dinge löst, an denen es sonst hakt: ein
gültiges Zertifikat (damit Passkeys funktionieren) und eine feste öffentliche Adresse.
> **Beta.** Die Funktion ist als Beta gekennzeichnet. Sie unterstützt derzeit **kein Rosenpass**, und
> wer netbird selbst hostet, braucht **Traefik** als äußeren Proxy – nur der beherrscht das nötige
> TLS-Passthrough.
### 1. Dienst anlegen
Im netbird-Dashboard unter *Services → Reverse Proxy → Create*:
| Reiter | Einstellung |
|---|---|
| **Details** | Mode `HTTP`; Subdomain `proxledger`, dazu eine Basis-Domäne; als Target den Peer mit ProxLedger, Protokoll `HTTP`, Port `3000` |
| **Authentication** | für ProxLedger **aus lassen** – Begründung unten |
| **Access Control** | nach Bedarf: IP-Bereiche, Länder, CrowdSec-Bewertung |
| **Settings** | *Host header forwarding* **an**, ebenso das Umschreiben von Weiterleitungen |
Das Zertifikat besorgt netbird selbst über ACME (Let's Encrypt); alternativ lassen sich eigene
Zertifikate hinterlegen.
**Host header forwarding ist nicht optional.** ProxLedger bildet Weiterleitungen, die OIDC-Rückkehr
und die `rpID` der Passkeys aus der öffentlichen Adresse. Kommt beim Dienst nur die interne Adresse
an, landet man nach der Anmeldung auf einer Adresse, die es im Browser nicht gibt.
### 2. Adresse eintragen
```bash
APP_URL="https://proxledger.<ihre-basis-domaene>"
```
und dieselbe Adresse unter *Einstellungen → System* als **Adresse des Dashboards**. Danach neu starten.
### 3. Warum die netbird-Anmeldung hier ausbleiben sollte
netbird kann den Dienst zusätzlich absichern – per SSO über den eigenen Identitätsanbieter, per
Passwort, PIN oder Kopfzeile. Verlockend, aber für ProxLedger meist falsch:
**Die Absicherung gilt für den ganzen Dienst, also auch für `/api/agent`.** Die Agents auf den
Proxmox-Hosts können sich dort nicht anmelden – sie weisen sich mit ihrem eigenen Token aus. Steht
eine SSO-Hürde davor, melden alle Hosts stumm ins Leere, bis der Zwischenspeicher voll ist.
Zwei Wege, wenn die zusätzliche Hürde gewünscht ist:
1. **Zwei Dienste anlegen:** einen für die Oberfläche mit SSO, einen zweiten (eigene Subdomain) ohne
Anmeldung für die Agents. Im Installations-Einzeiler dann die Agent-Adresse verwenden
(`--url https://agents.<domaene>`).
2. **Privater Dienst:** netbird kann einen Dienst so schalten, dass er **nur** aus dem eigenen Netz und
nur für ausgewählte Gruppen erreichbar ist. Das ist die sauberste Lösung, wenn sowohl Arbeitsplätze
als auch alle Proxmox-Hosts ohnehin im netbird-Netz sind: kein offener Zugang, trotzdem gültiges
Zertifikat und damit Passkeys.
### 4. Prüfen
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.<domaene>/api/gesundheit
curl -fsS https://proxledger.<domaene>/install.sh | grep -m1 DASHBOARD_URL
```
Die zweite Zeile muss die **öffentliche** Adresse zeigen – das ist die, die später in jedem Agent steht.
---
## Weg C: netbird ohne Reverse Proxy (kein offener Port)
netbird spannt ein privates Netz über WireGuard. ProxLedger ist dann **nur** für Geräte im Netz
erreichbar – kein Port im Internet, kein Zertifikat von außen nötig.
Das passt gut zu diesem Werkzeug: Die Oberfläche braucht niemand aus dem Internet, und die Proxmox-
Hosts stehen ohnehin meist in Kundennetzen mit netbird oder VPN.
### Aufbau
```
Proxmox-Host (netbird-Client) ──┐
Proxmox-Host (netbird-Client) ──┼── netbird-Netz ──► ProxLedger (netbird-Client, Port 3000)
Arbeitsplatz (netbird-Client) ──┘
```
### 1. netbird auf dem ProxLedger-Host
```bash
curl -fsSL https://pkgs.netbird.io/install.sh | sh
netbird up --setup-key <SETUP-KEY>
netbird status # zeigt die vergebene Adresse, z. B. 100.x.y.z
```
### 2. netbird auf jedem Proxmox-Host
Gleiche Schritte. Danach von dort prüfen:
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" http://100.x.y.z:3000/api/gesundheit
```
### 3. Zugriff einschränken
In der netbird-Verwaltung eine Access-Policy anlegen: Gruppe `proxmox-hosts` darf Gruppe
`proxledger` auf Port 3000 erreichen, sonst nichts. Damit können die Hosts melden, aber nicht
untereinander sprechen.
### 4. HTTPS trotzdem einrichten
netbird verschlüsselt den Transport – der Browser weiß davon aber nichts, und **Passkeys bleiben ohne
HTTPS gesperrt**. Am einfachsten ist hier der Reverse Proxy aus Weg B (auch als privater Dienst
nutzbar). Sonst bleiben zwei Möglichkeiten:
- **Empfohlen:** Zusätzlich einen kleinen Reverse Proxy im netbird-Netz betreiben (nginx oder Caddy)
mit einem Zertifikat für einen internen Namen, ausgestellt über DNS-01 – dafür muss nichts nach
außen offen sein.
- Oder ein eigenes CA-Zertifikat verteilen. Dann brauchen auch die Agents das CA-Zertifikat:
`curl … | bash -s -- --token … --ca-cert /pfad/zur/ca.crt`.
Wer auf Passkeys verzichtet, kann ProxLedger im netbird-Netz auch schlicht über `http://100.x.y.z:3000`
betreiben. In dem Fall `APP_URL` auf genau diese Adresse setzen – sonst setzt ProxLedger ein
`Secure`-Cookie, das der Browser über `http` verwirft, und die Anmeldung läuft ins Leere.
---
## Welcher Weg wofür
| Lage | Empfehlung |
|---|---|
| Eigener Server, Domäne vorhanden, Kunden sollen zugreifen | **Weg A** – nginx Proxy Manager |
| Alles läuft ohnehin über netbird, Zertifikat gewünscht | **Weg B** – netbird Reverse Proxy, bei Bedarf als privater Dienst |
| Kein offener Zugang, keine Passkeys nötig | **Weg C** – netbird pur, `APP_URL` auf die netbird-Adresse |
Weg B ist der kürzeste, wenn netbird ohnehin im Einsatz ist: ein Dienst, ein Zertifikat, kein
offener Port. Nur die Beta-Kennzeichnung sollte man im Hinterkopf behalten und nicht gleichzeitig auf
etwas anderes umstellen, damit bei einem Problem klar ist, woran es liegt.
Der häufigste gemischte Aufbau:
| Zugang | Weg |
|---|---|
| Oberfläche für Mitarbeiter und Kunden | nginx Proxy Manager oder netbird Reverse Proxy |
| Agents aus Kundennetzen | netbird-Netz, oder dieselbe öffentliche Adresse |
Wichtig ist nur, dass die Agents dieselbe Adresse erreichen, die im Installations-Einzeiler steht.
Ändert sich die Adresse später, genügt der Einzeiler erneut auf jedem Host – die Zuordnung bleibt
erhalten, weil sie an der Maschinenkennung hängt, nicht an der Adresse.
## Merkposten
0. **Keine Anmeldehürde vor `/api/agent`.** Egal welcher Proxy: Wer die Oberfläche zusätzlich absichert,
schließt damit auch die Agents aus – die weisen sich mit eigenem Token aus und können sich nirgends
anmelden.
1. **Adresse zuerst festlegen, dann Passkeys anlegen.** Ein Passkey gilt nur für die Domäne, unter der
er registriert wurde.
2. **`APP_URL` und die Dashboard-Adresse müssen auf die öffentliche Adresse zeigen** – daraus entstehen
Weiterleitungen, OIDC-Rückkehradresse und der Agent-Einzeiler.
3. **`client_max_body_size` nicht vergessen**, sonst scheitert das Hochladen von Rechnungs-PDFs mit
einem nichtssagenden 413.
+40
View File
@@ -0,0 +1,40 @@
# Sicherung und Wiederherstellung
## Was gesichert werden muss
| Sache | Wo | Ohne sie |
|---|---|---|
| Datenbank | PostgreSQL | alles weg: Hosts, Kunden, Laufzeiten, Rechnungen |
| `APP_SECRET` | `.env` | alle Sitzungen ungültig, SMTP- und Webhook-Zugangsdaten unlesbar |
| Hochgeladene PDFs | `/data/uploads` (Docker-Volume) bzw. `/opt/proxledger/data/uploads` | Rechnungsanhänge fehlen |
Die Agents brauchen **keine** Sicherung: Sie registrieren sich neu, und die Zuordnung hängt an der
Maschinenkennung, nicht an einem Token.
## Sichern
```bash
bash deploy/backup.sh # Datenbank und Uploads
```
Beim LXC-Weg reicht zusätzlich die gewohnte Proxmox-Sicherung des Containers.
## Zurückspielen
```bash
# Docker
docker compose down
docker compose up -d postgres
gunzip -c sicherung.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
docker compose up -d
```
Danach die `.env` mit **demselben** `APP_SECRET` zurücklegen wie zum Zeitpunkt der Sicherung. Ein
anderer Wert lässt die gespeicherten Zugangsdaten unlesbar werden; das Dashboard sagt das beim
nächsten Versand deutlich, aber die Werte müssen dann neu eingetragen werden.
## Das Wichtigste
**Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.** Spielen Sie sie einmal in eine
leere Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und
`APP_SECRET` zusammenpassen.
+34
View File
@@ -0,0 +1,34 @@
# #024 Härtung nach OWASP
**Status:** erledigt
**Meilenstein:** v0.15.0
**Labels:** security, backend
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
## Ziel
Vor der Freigabe als fertiges Produkt eine Durchsicht gegen die OWASP Top 10 – und die gefundenen
Lücken schließen, statt sie zu vermerken.
## Aufgaben
- [x] Zugriffskontrolle: jede Server Action prüft die Sitzung selbst, nicht nur der Proxy
- [x] Sitzungen widerrufbar machen (gesperrtes Konto, neues Passwort)
- [x] Login-CSRF: Herkunft der Anmeldung prüfen
- [x] Content-Security-Policy mit Nonce je Anfrage
- [x] Abhängigkeiten ohne bekannte Schwachstellen (`npm audit`)
- [x] Benutzerverwaltung auf der Kommandozeile (sperren, entsperren, Passwort)
- [x] `docs/sicherheit.md` mit der Zuordnung zu den OWASP Top 10
- [x] Tests für Sitzungsurteil und Herkunftsprüfung
## Gefunden und behoben
1. **Server Actions waren nur durch `proxy.ts` geschützt.** Keine einzige Aktion prüfte selbst. Der
Proxy greift zwar auch bei POST, aber eine einzige Schicht ist zu wenig für 27 schreibende Endpunkte.
2. **Login-CSRF möglich:** Der Anmelde-Endpunkt ist ein gewöhnliches Formular und prüfte die Herkunft
nicht. Eine fremde Seite hätte jemanden in ein untergeschobenes Konto anmelden können.
3. **Keine CSP.** Jetzt mit Nonce und `strict-dynamic`.
4. **Sitzungen ließen sich nicht beenden.** Ein gesperrtes Konto arbeitete bis zu zwölf Stunden weiter.
5. **`npm audit`: 4 hohe Meldungen** in `mysql2` und `deepmerge-ts` – beides Beiwerk von Prisma. Über
`overrides` angehoben statt Prisma zurückzustufen; danach keine Meldung mehr.
## Bewusst nicht umgesetzt
- **Rollen:** Wer angemeldet ist, darf alles. Für ein internes Werkzeug vertretbar, in `docs/sicherheit.md` vermerkt.
- **Zwei-Faktor-Anmeldung:** Wer sie braucht, nutzt OIDC.
- Die Einschränkung der OIDC-Anmeldung (erlaubte Domänen, automatisches Anlegen) kommt mit #025.
+47
View File
@@ -0,0 +1,47 @@
# #025 Einstellungen im Dashboard
**Status:** erledigt
**Meilenstein:** v0.16.0
**Labels:** backend, ui
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
## Ziel
Werte, die heute im Quelltext oder in der Umgebung stehen, sollen sich im Dashboard ändern lassen –
allen voran die Dashboard-Adresse, die im Installations-Einzeiler landet.
## Aufgaben
- [x] Typisierter Einstellungsdienst mit Vorgaben und Prüfung (zod), alles in einer JSON-Zeile
- [x] Oberfläche unter *Einstellungen → System* mit vier Bereichen
- [x] **Adresse und Agent:** Dashboard-Adresse, Melderhythmus, Streuung, Selbstaktualisierung
- [x] **Kaufmännisches:** Währung, Steuersatz, Zahlungsziel, Aufschlag
- [x] **Sicherheit:** Sitzungsdauer, Anmeldeversuche, OIDC-Domänen, Konten anlegen, Aufbewahrungsfristen
- [x] **Firma und Darstellung:** Name, Anschrift, E-Mail, Zeitzone
- [x] Werte wirksam machen (nicht nur speichern)
- [x] Nächtliches Aufräumen nach den Aufbewahrungsfristen
- [x] Tests und Handbuchkapitel
## Wo die Werte wirken
| Einstellung | Wirkt in |
|---|---|
| Dashboard-Adresse | `/install.sh`, `/agent.sh`, Benachrichtigungen |
| Rhythmus, Streuung, Selbstaktualisierung | erzeugte systemd-Timer und `agent.conf` |
| Steuersatz | neue Rechnungspositionen (Einkauf und Verkauf) |
| Zahlungsziel | vorgeschlagenes Fälligkeitsdatum |
| Aufschlag | vorgeschlagener Verkaufspreis aus dem Einkaufspreis |
| Sitzungsdauer | Gültigkeit des Tokens und Lebensdauer des Cookies |
| Anmeldeversuche | Ratenbremse der Anmeldung |
| OIDC-Domänen, Konten anlegen | `mitOidcAnmelden` – schließt die in #024 vermerkte Lücke |
| Aufbewahrungsfristen | nächtlicher Lauf im Worker (03:30) |
| Firma, Zeitzone | Kopf und Datum der PDF-Ausgaben |
## Entscheidungen
- **Eine JSON-Zeile statt vieler Schlüssel:** Speichern ist ein Schreibvorgang, fehlende Felder
bekommen ihren Vorgabewert, und eine kaputte Zeile legt den Betrieb nicht lahm – dann gelten die Vorgaben.
- **Zehn Sekunden Zwischenspeicher:** Die Werte werden bei fast jeder Anfrage gebraucht, ändern sich
aber selten. Eine Änderung ist trotzdem sofort spürbar.
- **`APP_SECRET` und `DATABASE_URL` bleiben in der Umgebung**; beide werden vor dem ersten
Datenbankzugriff gebraucht.
- **„Stumm nach X Tagen“ bewusst weggelassen:** Das steht schon als Schwellwert bei den Warnregeln.
Zwei Stellen für denselben Wert wären eine Fehlerquelle.
- **Währung und Zeitzone wirken vorerst nur serverseitig** (PDF, CSV, Benachrichtigungen). Die
Browseransicht rechnet weiterhin fest in Euro und Europe/Berlin – das nachzuziehen heißt, die
Formatierung durch alle Client-Komponenten zu reichen, und gehört in einen eigenen Schritt.
+41
View File
@@ -0,0 +1,41 @@
# #026 Auslieferung finalisieren
**Status:** erledigt
**Meilenstein:** v0.17.0
**Labels:** deployment, doku
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
## Ziel
Zwei saubere Wege, ProxLedger in Betrieb zu nehmen: als Docker-Stack und als LXC-Container nach Art
der Proxmox VE Helper-Scripts.
## Aufgaben
- [x] `deploy/update.sh`: sichern, bauen, migrieren, neu starten, Erreichbarkeit prüfen
- [x] Zustandsprüfung für den Worker in `docker-compose.yml`
- [x] `AUFRAEUM_CRON` in `.env.example` und im Compose-Dienst
- [x] `deploy/community-scripts/ct/proxledger.sh` und `install/proxledger-install.sh`
- [x] `deploy/community-scripts/README.md` mit Voraussetzungen für eine Einreichung
- [x] README auf den tatsächlichen Stand gebracht (Feldtest erledigt, Einstellungen, Sicherheit)
- [x] `docs/roadmap.md`
- [x] Docker-Stapel vollständig durchgespielt: bauen, starten, migrieren, anmelden, Agent-Skript abrufen
## Erkenntnisse zu community-scripts
- Die Skripte laden ihr Gerüst inzwischen aus einem eigenen Repository
(`community-scripts/core`, Datei `core/build.func`) – nicht mehr aus `misc/build.func`.
- **Neue Skripte gehören als Pull Request nach ProxmoxVED**, nicht nach ProxmoxVE. Direkt eingereichte
Skripte werden dort ohne Prüfung geschlossen.
- Die JSON-Metadaten für die Webseite liegen nicht mehr im Skript-Repository.
- Eine Einreichung setzt öffentlich erreichbaren Quelltext voraus (in aller Regel GitHub). Solange
ProxLedger im eigenen Gitea liegt, bleiben die Skripte intern; die Quelle lässt sich über
`PROXLEDGER_REPO` setzen.
## Beim Prüfen gefunden
Der Docker-Stapel wurde vollständig gestartet (bauen, migrieren, anmelden). Dabei zeigte sich: Nach der
Anmeldung wies die Weiterleitung auf `http://<container-id>:3000` – die **interne** Adresse des
Containers. Im Browser hinter einem Reverse Proxy wäre das eine tote Adresse gewesen. Weiterleitungen
nehmen jetzt die eingetragene Adresse, sonst `APP_URL`, sonst die Kopfzeilen des Reverse Proxy
(`src/lib/adresse.ts`, vier Tests). Im Stapel gegengeprüft: `location: http://localhost:3001/`.
## Entscheidung
Der eigene `deploy/lxc-installer.sh` (Container **mit Docker darin**) bleibt erhalten. Der neue Weg
installiert Node und PostgreSQL direkt in den Container – das ist schlanker und entspricht dem, was
Proxmox-Betreiber von Helper-Scripts erwarten. Beide Wege stehen gleichberechtigt im README.
@@ -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.
+30
View File
@@ -0,0 +1,30 @@
# #029 Einrichtungsdokumentation
**Status:** erledigt
**Meilenstein:** v0.19.0
**Labels:** doku
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
## Ziel
Anleitungen für alles, was rund um ProxLedger eingerichtet wird: HTTPS, OIDC mit authentik, Sicherung –
und die richtige Reihenfolge, in der das geschehen muss.
## Aufgaben
- [x] `docs/betrieb/inbetriebnahme.md` – von null zum meldenden Host
- [x] `docs/betrieb/reverse-proxy.md` – nginx Proxy Manager und netbird
- [x] `docs/betrieb/authentik.md` – OIDC Schritt für Schritt, mit Fehlerbildern
- [x] `docs/betrieb/sicherung.md` – sichern und **zurückspielen**
- [x] Verweise aus README und Handbuch
## Beim Schreiben gefunden
Zwei Stellen taugten für einen Betrieb hinter einem Reverse Proxy nicht:
1. Die **OIDC-Rückkehradresse** kam allein aus `APP_URL` und übersah die Dashboard-Adresse aus den
Einstellungen. Wer nur letztere gesetzt hatte, bekam vom Anbieter einen *Redirect URI Error*.
2. Beim **Einlösen des Codes** wurde die Adresse der eingehenden Anfrage verwendet – hinter einem
Proxy also die interne. Der Anbieter hat aber die öffentliche hinterlegt; der Tausch wäre
gescheitert. Jetzt wird dieselbe öffentliche Adresse gebildet wie für die Weiterleitungen.
## Entscheidung
Die Reihenfolge steht bewusst am Anfang der Inbetriebnahme: **Adresse festlegen, dann Passkeys und
Agents.** Ein Passkey gilt nur für die Domäne, unter der er entstanden ist, und die Adresse steckt im
Installations-Einzeiler. Wer das übergeht, macht beides zweimal.
+29
View File
@@ -0,0 +1,29 @@
# #030 netbird Reverse Proxy in die Anleitung
**Status:** erledigt
**Meilenstein:** v0.19.1
**Labels:** doku
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Ziel
netbird bringt seit v0.65 einen eigenen Reverse Proxy mit (Beta), v0.72 ergänzte private Dienste.
Damit entfällt ein zusätzlicher nginx – das gehört in die Anleitung, weil es der kürzeste Weg zu
HTTPS ist, wenn netbird ohnehin im Einsatz ist.
## Aufgaben
- [x] Weg B: netbird Reverse Proxy – Dienst anlegen, Zertifikat, Einstellungen
- [x] Alter netbird-Abschnitt wird Weg C (ohne Proxy)
- [x] Entscheidungstabelle, welcher Weg wofür taugt
- [x] Beta-Einschränkungen benennen (kein Rosenpass; selbst gehostet nur mit Traefik)
## Der wichtigste Hinweis
Die Absicherung eines Dienstes (SSO, Passwort, PIN) gilt für den **ganzen** Dienst – also auch für
`/api/agent`. Die Agents weisen sich mit eigenem Token aus und können sich nirgends anmelden; eine
SSO-Hürde davor lässt alle Hosts stumm ins Leere melden, bis der Zwischenspeicher voll ist.
Zwei brauchbare Auswege stehen in der Anleitung: zwei Dienste (Oberfläche mit SSO, Agents ohne) oder
ein privater Dienst, der nur aus dem netbird-Netz erreichbar ist.
## Quellen
- <https://docs.netbird.io/manage/reverse-proxy>
- <https://netbird.io/knowledge-hub/reverse-proxy>
- <https://netbird.io/knowledge-hub/netbird-only-private-services>
+8 -1
View File
@@ -27,4 +27,11 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min | | [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) | | [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
| | **Summe** | | | **121 h** | **9 h 43 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 |
| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 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 |
| [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min |
| [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min |
| | **Summe** | | | **147 h** | **15 h 23 min** |
+72
View File
@@ -0,0 +1,72 @@
# Roadmap
Stand: 19.09.2026, Fassung 0.17.0
## Was steht
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt:
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
## Was bis 1.0.0 fehlt
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten:
| Punkt | Warum es zählt | Aufwand |
|---|---|---|
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit |
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben.
## Nach 1.0 – Vorschläge, nach Nutzen sortiert
### Lohnt sich am ehesten
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
### Sinnvoll, wenn es wächst
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt –
spart Rückfragen.
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
Lizenzjahr erzeugen.
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
ist vertretbar, aber nicht für jeden.
### Nur falls der Bedarf entsteht
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
## Was ich bewusst nicht vorschlage
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
ein IdP – dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf
jedem Proxmox-Host, auch auf einem alten.
+111
View File
@@ -0,0 +1,111 @@
# Sicherheit
Dieses Dokument hält fest, was ProxLedger gegen die üblichen Angriffe tut – und was es **nicht** tut.
Die Gliederung folgt den OWASP Top 10 (2021).
## A01 – Fehlerhafte Zugriffskontrolle
- `src/proxy.ts` schickt jede Anfrage ohne gültige Sitzung zur Anmeldung. Öffentlich sind nur die
Anmeldung, die Agent-API (eigene Tokens), die Gesundheitsprüfung, die beiden Agent-Skripte und die Symbole.
- **Jede Server Action prüft zusätzlich selbst** (`sitzungErforderlich()`). Server Actions sind eigene
Endpunkte; eine einzige Schutzschicht davor wäre zu wenig, falls der Musterausdruck im Proxy je
danebengreift.
- Die Prüfung geht in die Datenbank: Ein gesperrtes Konto oder ein Widerruf beendet laufende Sitzungen
sofort (`npm run benutzer -- sperren <email>`), statt bis zum Ablauf des Tokens zu warten.
- 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.
**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden
gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge
werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML.
**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer
Installation wären ein Umbau, kein Anbau.
## A02 – Kryptografische Fehler
- Passwörter: argon2id (`@node-rs/argon2`), keine eigene Krypto.
- Sitzungen: signiertes JWT (HS256) aus `APP_SECRET`, 12 Stunden gültig, Cookie `httpOnly`, `sameSite=lax`
und `secure`, sobald `APP_URL` auf `https://` steht.
- Zugangsdaten für SMTP und Webhooks liegen AES-256-GCM-verschlüsselt in der Datenbank
(`src/lib/geheimnisse.ts`); der Schlüssel wird aus `APP_SECRET` abgeleitet.
- Agent- und Enrollment-Tokens werden nur als SHA-256-Hash gespeichert. Das ist hier richtig und nicht
zu schwach: Die Tokens sind 32 Byte Zufall, es gibt nichts zu raten.
**Wichtig:** `APP_SECRET` ist der Generalschlüssel. Geht er verloren, sind alle Sitzungen ungültig und die
gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
## A03 – Injektion
- Datenbankzugriffe ausschließlich über Prisma mit gebundenen Werten; kein `$queryRawUnsafe` im Projekt.
- Ausgaben maskiert React selbst.
- Zusätzlich greift eine Content-Security-Policy mit frischem Nonce je Anfrage und `strict-dynamic`
(`src/proxy.ts`). Eingeschleustes `<script>` – etwa über einen manipulierten Hostnamen aus einer
Proxmox-Ausgabe – läuft damit nicht.
- Der Agent baut sein JSON mit einer eigenen Maskierung; Sonderzeichen aus Proxmox-Ausgaben sind als
Testfall hinterlegt.
## A04 – Unsicheres Design
- Push-Modell: Das Dashboard braucht **keinen** Zugang zu den Proxmox-Hosts. Es gibt keine gespeicherten
root-Zugänge, die abhandenkommen könnten – die Hosts melden von sich aus.
- Der Agent sendet nur Statusangaben (Subscription, Version, Sockel, IPs). Keine Gastliste, keine Daten.
- Enrollment-Tokens sind befristet und in der Zahl der Nutzungen begrenzt.
## A05 – Sicherheitsrelevante Fehlkonfiguration
- Kopfzeilen: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
`Strict-Transport-Security` (`next.config.ts`) sowie die CSP aus dem Proxy.
- Die Anwendung startet nicht mit zu kurzem `APP_SECRET`.
- Im Container läuft die Anwendung als unprivilegierter Benutzer; Anhänge werden mit Rechten 640 abgelegt.
- Fehlermeldungen nennen keine Einzelheiten: Bei der Anmeldung steht dieselbe Meldung für „Adresse
unbekannt“ und „Passwort falsch“.
## A06 – Verwundbare Komponenten
- `npm audit --omit=dev` meldet **keine** bekannten Schwachstellen (Stand 18.09.2026).
- Zwei Fassungen werden über `overrides` angehoben: `mysql2` und `deepmerge-ts` kommen als Beiwerk von
Prisma mit, obwohl wir Postgres nutzen. Ohne die Anhebung bliebe nur ein Rückschritt auf Prisma 6.
- Die CI prüft bei jedem Push (#019).
## A07 – Fehler bei Identifizierung und Authentifizierung
- Ratenbremse: fünf Anmeldeversuche je Kennung und Viertelstunde.
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`).
- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht
Administrator.
**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides
in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor.
## A08 – Datenintegrität
- Der Agent prüft vor dem Selbstaktualisieren, ob die geladene Datei wie ein Bash-Skript beginnt.
- Webhooks werden mit HMAC-SHA256 signiert, damit die Gegenstelle die Herkunft prüfen kann.
- Hochgeladene Rechnungen werden nicht dem gemeldeten Typ nach beurteilt, sondern am Dateianfang
geprüft (`%PDF-`), auf 10 MB begrenzt und unter ihrer Prüfsumme abgelegt.
## A09 – Protokollierung
- Audit-Log für Anmeldungen, Änderungen an Kunden, Rechnungen, Tokens, Kanälen und Warnregeln –
einsehbar unter *Einstellungen → Protokoll*.
- Der Agent protokolliert auf dem Host über `logger` ins Journal.
## A10 – Server-Side Request Forgery
- Die einzige ausgehende Verbindung, die sich einstellen lässt, ist der Benachrichtigungs-Webhook. Er
wird bewusst von einem Angemeldeten eingetragen und ist damit kein fremdgesteuerter Aufruf.
- Sonst ruft das Dashboard keine Adressen auf, die aus Daten stammen.
## Was ein Betreiber selbst tun muss
1. HTTPS davorschalten (Beispiele unter `deploy/reverse-proxy/`) und `APP_URL` auf `https://` setzen.
2. `APP_SECRET` und die Datenbank sichern (`deploy/backup.sh`).
3. Das Dashboard nicht ins offene Internet stellen, wenn es nicht sein muss. Die Agent-API muss
erreichbar sein, die Oberfläche nicht.
4. Enrollment-Tokens nach dem Ausrollen widerrufen.
+417 -86
View File
@@ -1,24 +1,26 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.19.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.19.1",
"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.3",
"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",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -33,7 +35,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
@@ -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",
@@ -1632,15 +1646,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
"integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
"integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1681,9 +1695,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
"integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1697,9 +1711,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
"integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1713,9 +1727,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
"integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1732,9 +1746,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
"integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1751,9 +1765,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
"integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1770,9 +1784,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
"integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1789,9 +1803,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
"integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1805,9 +1819,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
"integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -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.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
"integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==",
"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",
@@ -5071,13 +5359,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"devOptional": true, "devOptional": true,
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.9.0"
} }
}, },
"node_modules/define-data-property": { "node_modules/define-data-property": {
@@ -5133,16 +5431,6 @@
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/destr": { "node_modules/destr": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -5601,13 +5889,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
"integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.3.5", "@next/eslint-plugin-next": "16.3.8",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -7820,24 +8108,25 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.15.3", "version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.1", "aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.3",
"long": "^5.2.1", "long": "^5.3.2",
"lru.min": "^1.0.0", "lru.min": "^1.1.4",
"named-placeholders": "^1.1.3", "named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5", "sql-escaper": "^1.5.1"
"sqlstring": "^2.3.2"
}, },
"engines": { "engines": {
"node": ">= 8.0" "node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
} }
}, },
"node_modules/named-placeholders": { "node_modules/named-placeholders": {
@@ -7895,12 +8184,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
"integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.5", "@next/env": "16.3.8",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -7914,14 +8203,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.5", "@next/swc-darwin-arm64": "16.3.8",
"@next/swc-darwin-x64": "16.3.5", "@next/swc-darwin-x64": "16.3.8",
"@next/swc-linux-arm64-gnu": "16.3.5", "@next/swc-linux-arm64-gnu": "16.3.8",
"@next/swc-linux-arm64-musl": "16.3.5", "@next/swc-linux-arm64-musl": "16.3.8",
"@next/swc-linux-x64-gnu": "16.3.5", "@next/swc-linux-x64-gnu": "16.3.8",
"@next/swc-linux-x64-musl": "16.3.5", "@next/swc-linux-x64-musl": "16.3.8",
"@next/swc-win32-arm64-msvc": "16.3.5", "@next/swc-win32-arm64-msvc": "16.3.8",
"@next/swc-win32-x64-msvc": "16.3.5", "@next/swc-win32-x64-msvc": "16.3.8",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -8666,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",
@@ -8748,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",
@@ -9059,12 +9372,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9314,14 +9621,20 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sqlstring": { "node_modules/sql-escaper": {
"version": "2.3.3", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
} }
}, },
"node_modules/stable-hash": { "node_modules/stable-hash": {
@@ -9702,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",
+11 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.14.1", "version": "0.19.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -26,19 +26,22 @@
"lasttest": "tsx scripts/lasttest.ts", "lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs", "screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs", "icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs" "pruefe:doku": "node scripts/pruefe-doku.mjs",
"benutzer": "tsx scripts/benutzer.ts"
}, },
"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.3",
"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",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -53,7 +56,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
@@ -65,5 +68,9 @@
"description": "Proxmox Subscription Dashboard", "description": "Proxmox Subscription Dashboard",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
},
"overrides": {
"mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0"
} }
} }
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
@@ -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;
+36
View File
@@ -72,6 +72,7 @@ model Kunde {
alerts Alert[] alerts Alert[]
@@index([name]) @@index([name])
benutzer Benutzer[]
} }
model Standort { model Standort {
@@ -448,18 +449,53 @@ 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).
sitzungenUngueltigAb DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
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 {
+81
View File
@@ -0,0 +1,81 @@
import "dotenv/config";
import { createInterface } from "node:readline/promises";
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Benutzerverwaltung von der Kommandozeile – solange es dafür keine Oberfläche gibt.
*
* npm run benutzer -- liste
* npm run benutzer -- sperren <email> Konto sperren und alle Sitzungen sofort beenden
* npm run benutzer -- entsperren <email>
* npm run benutzer -- passwort <email> neues Passwort setzen, beendet alle Sitzungen
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function passwortAbfragen(): Promise<string> {
const eingabe = createInterface({ input: process.stdin, output: process.stdout });
const passwort = await eingabe.question("Neues Passwort (mindestens 12 Zeichen): ");
eingabe.close();
if (passwort.length < 12) throw new Error("Zu kurz – mindestens 12 Zeichen.");
return passwort;
}
async function main() {
const [befehl, email] = process.argv.slice(2);
if (befehl === "liste") {
const alle = await prisma.benutzer.findMany({ orderBy: { email: "asc" } });
for (const b of alle) {
console.log(`${b.aktiv ? "aktiv " : "gesperrt"} ${b.email.padEnd(32)} ${b.name}`);
}
return;
}
if (!email) throw new Error("Bitte eine E-Mail-Adresse angeben.");
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer) throw new Error(`Kein Benutzer mit der Adresse ${email}.`);
switch (befehl) {
case "sperren":
// Sperren allein genügt nicht: Das Sitzungs-Token gilt sonst bis zum Ablauf weiter.
await prisma.benutzer.update({
where: { id: benutzer.id },
data: { aktiv: false, sitzungenUngueltigAb: new Date() },
});
console.log(`${benutzer.email} ist gesperrt, alle Sitzungen sind beendet.`);
break;
case "entsperren":
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: true } });
console.log(`${benutzer.email} ist wieder freigegeben.`);
break;
case "passwort": {
const passwort = await passwortAbfragen();
await prisma.benutzer.update({
where: { id: benutzer.id },
data: {
passwortHash: await hash(passwort),
sitzungenUngueltigAb: new Date(),
},
});
console.log(`Passwort gesetzt; bestehende Sitzungen von ${benutzer.email} sind beendet.`);
break;
}
default:
throw new Error("Bekannte Befehle: liste, sperren, entsperren, passwort");
}
}
main()
.catch((fehler: unknown) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+23 -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";
@@ -48,6 +49,9 @@ const SEITEN = [
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" }, { name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ 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: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
]; ];
async function sitzungsToken() { async function sitzungsToken() {
@@ -56,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>
</>
);
}
@@ -1,23 +1,32 @@
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "System" }; export const metadata = { title: "System" };
export default function SystemSeite() { export default async function SystemSeite() {
const werte = await einstellungen();
return ( return (
<> <>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} /> <ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary"> <SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
<Card titel="Systeminformationen" rahmen="keiner">
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt> <dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd> <dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt> <dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd> <dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt> <dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
<dd className="col-8">Konfigurierbar ab #011</dd> <dd className="col-8">
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
die Datenbank gelesen werden kann.
</dd>
</dl> </dl>
</Card> </Card>
</div> </div>
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format"; import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste"; import { hostListe, type HostFilter } from "@/server/queries/hostliste";
import { einstellungen } from "@/server/einstellungen";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number]; type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") { if (new URL(request.url).searchParams.get("format") === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Host-Liste", titel: "Host-Liste",
untertitel: `${alle.length} Hosts`, untertitel: `${alle.length} Hosts`,
quer: true, quer: true,
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden"; import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const gesamt = summen(laufzeiten); const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten); const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: kunde.name, titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`, untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [ children: [
@@ -1,6 +1,7 @@
import { csvAntwort, csvDatei } from "@/lib/csv"; import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { einstellungen } from "@/server/einstellungen";
import { import {
kundenListe, kundenListe,
offeneRechnungspunkteProKunde, offeneRechnungspunkteProKunde,
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") { if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Kundenübersicht", titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`, untertitel: `${zeilen.length} Kunden`,
quer: true, quer: true,
+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 />
+11 -4
View File
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige"; import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen"; import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen"; import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +29,12 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`; const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return ( return (
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
werte={{ werte={{
nummer: einkauf?.nummer ?? null, nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null, datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null, faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag, nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null ? null
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
erstellt: verkauf?.erstellt ?? false, erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null, nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null, datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag, nettoBetrag: verkaufVorschlag,
ustSatz: Number(verkauf?.ustSatz ?? 19), ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
bezahlt: verkauf?.bezahlt ?? false, bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null, bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null, anhangId: verkauf?.anhangId ?? null,
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen"; import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre"; const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") { if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Rechnungsliste", titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`, untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true, quer: true,
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */ /** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request)); const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh"); return skriptAntwort(inhalt, "proxledger-agent.sh");
} }
+42 -5
View File
@@ -1,7 +1,9 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { clientIp } from "@/lib/ratelimit"; import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer"; import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,8 +11,35 @@ export const dynamic = "force-dynamic";
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die * Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort. * Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
*/ */
/**
* Prüft, ob das Formular von der eigenen Oberfläche kam.
*
* Ohne diese Prüfung könnte eine fremde Seite ein Anmeldeformular auf dieses Dashboard abschicken
* und das Opfer unbemerkt in ein untergeschobenes Konto anmelden (Login-CSRF, OWASP A01).
* Server Actions in Next.js prüfen das selbst; dieser Endpunkt ist ein gewöhnliches Formular
* und muss es daher selbst tun.
*/
export function herkunftStimmt(request: Request): boolean {
const herkunft = request.headers.get("origin");
// Formulare ohne Origin (etwa curl oder alte Browser) werden nicht ausgeschlossen –
// der Schutz richtet sich gegen fremde Seiten, die immer einen Origin senden.
if (!herkunft) return true;
const erlaubt = new Set<string>();
if (process.env.APP_URL) erlaubt.add(new URL(process.env.APP_URL).host);
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) erlaubt.add(gastgeber);
try {
return erlaubt.has(new URL(herkunft).host);
} catch {
return false;
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const formular = await request.formData(); const formular = await request.formData();
const { dashboardUrl } = await einstellungen();
const email = formular.get("email")?.toString().trim() ?? ""; const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? ""; const passwort = formular.get("passwort")?.toString() ?? "";
const zielEingabe = formular.get("ziel")?.toString() || "/"; const zielEingabe = formular.get("ziel")?.toString() || "/";
@@ -18,12 +47,16 @@ export async function POST(request: Request) {
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/"; const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
function zurueckZurAnmeldung(meldung: string) { function zurueckZurAnmeldung(meldung: string) {
const adresse = new URL("/login", request.url); const adresse = eigeneAdresse(request, "/login", dashboardUrl);
adresse.searchParams.set("fehler", meldung); adresse.searchParams.set("fehler", meldung);
if (ziel !== "/") adresse.searchParams.set("ziel", ziel); if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
return Response.redirect(adresse, 303); return Response.redirect(adresse, 303);
} }
if (!herkunftStimmt(request)) {
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
}
if (!email || !passwort) { if (!email || !passwort) {
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben."); return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
} }
@@ -32,15 +65,19 @@ export async function POST(request: Request) {
const kopfzeilen = await headers(); const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen)); const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id, benutzerId: benutzer.id,
email: benutzer.email, email: benutzer.email,
name: benutzer.name, name: benutzer.name,
herkunft: "passwort", herkunft: "passwort",
}); },
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen()); sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303); return Response.redirect(eigeneAdresse(request, ziel, dashboardUrl), 303);
} catch (fehler) { } catch (fehler) {
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message); if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
console.error("Anmeldung fehlgeschlagen:", fehler); console.error("Anmeldung fehlgeschlagen:", fehler);
+20 -7
View File
@@ -1,7 +1,9 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer"; import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc"; import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,25 +19,36 @@ export async function GET(request: Request) {
speicher.delete(name); speicher.delete(name);
} }
const { dashboardUrl } = await systemEinstellungen();
if (!einstellungen || !pruefwert || !zustand) { if (!einstellungen || !pruefwert || !zustand) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
try { try {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand); // Hinter einem Reverse Proxy ist `request.url` die interne Adresse. Der Anbieter hat aber die
// öffentliche als Rückkehradresse hinterlegt – mit der internen scheitert der Tausch des Codes.
const angekommen = new URL(request.url);
const oeffentlich = eigeneAdresse(request, `${angekommen.pathname}${angekommen.search}`, dashboardUrl);
const angaben = await anmeldungAbschliessen(einstellungen, oeffentlich, pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name); const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await systemEinstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id, benutzerId: benutzer.id,
email: benutzer.email, email: benutzer.email,
name: benutzer.name, name: benutzer.name,
herkunft: "oidc", herkunft: "oidc",
}); },
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen()); sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302); return Response.redirect(eigeneAdresse(request, ziel.startsWith("/") ? ziel : "/", dashboardUrl), 302);
} catch (fehler) { } catch (fehler) {
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler); console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
} }
@@ -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);
}
}
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */ /** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request)); const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh"); return skriptAntwort(inhalt, "install.sh");
} }
+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>
</>
);
}
@@ -0,0 +1,358 @@
"use client";
import { useActionState } from "react";
import { Card } from "@/components/ui/Card";
import { type AktionsErgebnis, einstellungenSpeichernAktion } from "@/server/actions/einstellungen";
import type { Einstellungen } from "@/server/einstellungen";
const LEER: AktionsErgebnis = {};
function Feld({
name,
beschriftung,
hinweis,
fehler,
children,
}: {
name: string;
beschriftung: string;
hinweis?: string;
fehler?: string;
children: React.ReactNode;
}) {
return (
<div className="mb-3">
<label className="form-label" htmlFor={name}>
{beschriftung}
</label>
{children}
{hinweis ? <div className="form-text">{hinweis}</div> : null}
{fehler ? <div className="invalid-feedback d-block">{fehler}</div> : null}
</div>
);
}
export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl: string }) {
const [ergebnis, aktion, laeuft] = useActionState(einstellungenSpeichernAktion, LEER);
const fehler = ergebnis.felder ?? {};
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="row g-4">
<div className="col-12 col-xl-6">
<Card titel="Adresse und Agent" rahmen="primary">
<Feld
name="dashboardUrl"
beschriftung="Adresse des Dashboards"
hinweis={`Steht im Installations-Einzeiler und in Benachrichtigungen. Leer lassen heißt: aus der Umgebung (${appUrl || "nicht gesetzt"}).`}
fehler={fehler.dashboardUrl}
>
<input
type="url"
className="form-control"
id="dashboardUrl"
name="dashboardUrl"
defaultValue={werte.dashboardUrl}
placeholder="https://proxledger.example.de"
/>
</Feld>
<Feld
name="agentRhythmus"
beschriftung="Wie oft meldet ein Host?"
hinweis="Gilt für neu installierte Agents; bestehende übernehmen es bei der nächsten Installation."
>
<select className="form-select" id="agentRhythmus" name="agentRhythmus" defaultValue={werte.agentRhythmus}>
<option value="taeglich">täglich</option>
<option value="zwoelfstuendlich">alle 12 Stunden</option>
<option value="stuendlich">stündlich</option>
</select>
</Feld>
<Feld
name="agentStreuungStunden"
beschriftung="Zufällige Verzögerung (Stunden)"
hinweis="Verteilt die Meldungen, damit nicht alle Hosts gleichzeitig anklopfen."
fehler={fehler.agentStreuungStunden}
>
<input
type="number"
min={0}
max={12}
className="form-control"
id="agentStreuungStunden"
name="agentStreuungStunden"
defaultValue={werte.agentStreuungStunden}
/>
</Feld>
<div className="form-check">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="agentAutoUpdate"
name="agentAutoUpdate"
value="an"
defaultChecked={werte.agentAutoUpdate}
/>
<label className="form-check-label" htmlFor="agentAutoUpdate">
Agents dürfen sich selbst aktualisieren
</label>
<div className="form-text">
Der Agent lädt bei einer neueren Fassung das Skript vom Dashboard nach.
</div>
</div>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Kaufmännisches" rahmen="info">
<Feld name="waehrung" beschriftung="Währung">
<select className="form-select" id="waehrung" name="waehrung" defaultValue={werte.waehrung}>
<option value="EUR">Euro (€)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</Feld>
<Feld
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
fehler={fehler.steuersatz}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatz"
name="steuersatz"
defaultValue={werte.steuersatz}
/>
</Feld>
<Feld
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
hinweis="Setzt das Fälligkeitsdatum neuer Rechnungen vor."
fehler={fehler.zahlungszielTage}
>
<input
type="number"
min={0}
max={120}
className="form-control"
id="zahlungszielTage"
name="zahlungszielTage"
defaultValue={werte.zahlungszielTage}
/>
</Feld>
<Feld
name="margeProzent"
beschriftung="Aufschlag in Prozent"
hinweis="Vorschlag für den Verkaufspreis, ausgehend vom Einkaufspreis bei Proxmox."
fehler={fehler.margeProzent}
>
<input
type="number"
step="0.5"
min={0}
max={500}
className="form-control"
id="margeProzent"
name="margeProzent"
defaultValue={werte.margeProzent}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Sicherheit" rahmen="danger">
<Feld
name="sitzungsdauerStunden"
beschriftung="Sitzungsdauer in Stunden"
hinweis="Nach dieser Zeit ist eine neue Anmeldung nötig."
fehler={fehler.sitzungsdauerStunden}
>
<input
type="number"
min={1}
max={168}
className="form-control"
id="sitzungsdauerStunden"
name="sitzungsdauerStunden"
defaultValue={werte.sitzungsdauerStunden}
/>
</Feld>
<Feld
name="anmeldeversuche"
beschriftung="Anmeldeversuche je Viertelstunde"
fehler={fehler.anmeldeversuche}
>
<input
type="number"
min={3}
max={50}
className="form-control"
id="anmeldeversuche"
name="anmeldeversuche"
defaultValue={werte.anmeldeversuche}
/>
</Feld>
<Feld
name="oidcDomaenen"
beschriftung="Erlaubte E-Mail-Domänen für OIDC"
hinweis="Mehrere durch Komma trennen, etwa „wilde-server.de, kunde.de“. Leer heißt: alle."
>
<input
type="text"
className="form-control"
id="oidcDomaenen"
name="oidcDomaenen"
defaultValue={werte.oidcDomaenen}
placeholder="firma.de"
/>
</Feld>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="oidcKontenAnlegen"
name="oidcKontenAnlegen"
value="an"
defaultChecked={werte.oidcKontenAnlegen}
/>
<label className="form-check-label" htmlFor="oidcKontenAnlegen">
Unbekannte OIDC-Anmeldungen legen ein Konto an
</label>
<div className="form-text">
Aus heißt: Nur wer hier schon ein Konto hat, kommt herein – auch wenn die Anmeldung
beim Identitätsanbieter klappt.
</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
name="aufbewahrungReportsTage"
beschriftung="Reports aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt. Der aktuelle Stand jedes Hosts bleibt immer erhalten."
fehler={fehler.aufbewahrungReportsTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungReportsTage"
name="aufbewahrungReportsTage"
defaultValue={werte.aufbewahrungReportsTage}
/>
</Feld>
<Feld
name="aufbewahrungAuditTage"
beschriftung="Protokoll aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt."
fehler={fehler.aufbewahrungAuditTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungAuditTage"
name="aufbewahrungAuditTage"
defaultValue={werte.aufbewahrungAuditTage}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Firma und Darstellung" rahmen="keiner">
<Feld
name="firmaName"
beschriftung="Firmenname"
hinweis="Erscheint im Kopf der erzeugten PDF-Rechnungen."
>
<input
type="text"
className="form-control"
id="firmaName"
name="firmaName"
defaultValue={werte.firmaName}
/>
</Feld>
<Feld name="firmaAnschrift" beschriftung="Anschrift">
<textarea
className="form-control"
id="firmaAnschrift"
name="firmaAnschrift"
rows={3}
defaultValue={werte.firmaAnschrift}
/>
</Feld>
<Feld name="firmaEmail" beschriftung="E-Mail-Adresse" fehler={fehler.firmaEmail}>
<input
type="email"
className="form-control"
id="firmaEmail"
name="firmaEmail"
defaultValue={werte.firmaEmail}
/>
</Feld>
<Feld
name="zeitzone"
beschriftung="Zeitzone"
hinweis="Gilt für PDF, CSV-Ausgaben und Benachrichtigungen."
fehler={fehler.zeitzone}
>
<input
type="text"
className="form-control"
id="zeitzone"
name="zeitzone"
defaultValue={werte.zeitzone}
placeholder="Europe/Berlin"
/>
</Feld>
</Card>
</div>
</div>
<div className="mb-4">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
);
}
+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) {
+45
View File
@@ -0,0 +1,45 @@
/**
* Ermittelt die Adresse, unter der das Dashboard **von außen** erreichbar ist.
*
* Im Container ist `request.url` die interne Adresse (`http://<container-id>:3000`). Eine Weiterleitung
* dorthin führt im Browser ins Leere – genau das passierte nach der Anmeldung im Docker-Stapel.
*
* Reihenfolge: eingetragene Adresse → `APP_URL` → Kopfzeilen des Reverse Proxy → Anfrage selbst.
* Die Kopfzeilen stehen bewusst hinter den konfigurierten Werten: Sie kommen vom Client und lassen
* sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber
* unnötig.
*/
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
if (eingetragen) {
try {
return new URL(eingetragen);
} catch {
// Unbrauchbarer Eintrag: weiter mit den übrigen Quellen.
}
}
if (process.env.APP_URL) {
try {
return new URL(process.env.APP_URL);
} catch {
// ebenso
}
}
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) {
const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
try {
return new URL(`${schema}://${gastgeber}`);
} catch {
// ebenso
}
}
return new URL(request.url);
}
/** Baut eine Adresse auf derselben Installation – für Weiterleitungen nach der Anmeldung. */
export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL {
return new URL(pfad, oeffentlicheBasis(request, eingetragen));
}
+2
View File
@@ -0,0 +1,2 @@
/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
export const PASSWORT_MINDESTLAENGE = 12;
+18 -7
View File
@@ -6,14 +6,20 @@ import { SignJWT, jwtVerify } from "jose";
*/ */
export const SITZUNGS_COOKIE = "pl_sitzung"; export const SITZUNGS_COOKIE = "pl_sitzung";
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
export const SITZUNGSDAUER_STUNDEN = 12; export const SITZUNGSDAUER_STUNDEN = 12;
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
export interface GeleseneSitzung extends Sitzung {
ausgestelltAm: Date;
}
export interface Sitzung { export interface Sitzung {
benutzerId: string; benutzerId: string;
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 {
@@ -26,18 +32,21 @@ function schluessel(): Uint8Array {
return new TextEncoder().encode(geheimnis); return new TextEncoder().encode(geheimnis);
} }
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> { export async function sitzungErzeugen(
sitzung: Sitzung,
dauerStunden: number = SITZUNGSDAUER_STUNDEN,
): Promise<string> {
return new SignJWT({ ...sitzung }) return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId) .setSubject(sitzung.benutzerId)
.setIssuedAt() .setIssuedAt()
.setIssuer("proxledger") .setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`) .setExpirationTime(`${dauerStunden}h`)
.sign(schluessel()); .sign(schluessel());
} }
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */ /** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> { export async function sitzungLesen(token: string | undefined): Promise<GeleseneSitzung | null> {
if (!token) return null; if (!token) return null;
try { try {
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" }); const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
@@ -46,7 +55,9 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
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),
}; };
} catch { } catch {
return null; return null;
@@ -64,12 +75,12 @@ export function ueberHttps(): boolean {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} }
export function cookieOptionen() { export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
return { return {
httpOnly: true, httpOnly: true,
sameSite: "lax" as const, sameSite: "lax" as const,
secure: ueberHttps(), secure: ueberHttps(),
path: "/", path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600, maxAge: dauerStunden * 3600,
}; };
} }
+46 -3
View File
@@ -23,19 +23,62 @@ const OEFFENTLICH = [
"/manifest.webmanifest", "/manifest.webmanifest",
]; ];
/**
* Content-Security-Policy je Anfrage, mit frischem Nonce.
*
* `strict-dynamic` heißt: Nur Skripte mit diesem Nonce dürfen laufen (und was sie selbst nachladen).
* Damit läuft eingeschleustes `<script>` aus der Datenbank oder einer Proxmox-Ausgabe nicht mehr –
* die zweite Verteidigungslinie hinter der Maskierung durch React (OWASP A03).
*
* `style-src` erlaubt bewusst `unsafe-inline`: Bootstrap und AdminLTE setzen Stile direkt am Element,
* und ein Stil kann keinen Code ausführen.
*/
function richtlinie(nonce: string): string {
const entwicklung = process.env.NODE_ENV !== "production";
return [
"default-src 'self'",
// In der Entwicklung braucht der Hot-Reload eval; im Betrieb nicht.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
// Verbindungen nur zur eigenen Herkunft; in der Entwicklung zusätzlich der Hot-Reload.
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(entwicklung ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
function mitRichtlinie(request: NextRequest, antwort?: NextResponse): NextResponse {
const nonce = crypto.randomUUID().replaceAll("-", "");
const csp = richtlinie(nonce);
// Next.js versieht seine eigenen Skripte mit diesem Nonce, sobald es ihn in der Anfrage findet.
const kopfzeilen = new Headers(request.headers);
kopfzeilen.set("x-nonce", nonce);
kopfzeilen.set("Content-Security-Policy", csp);
const ergebnis = antwort ?? NextResponse.next({ request: { headers: kopfzeilen } });
ergebnis.headers.set("Content-Security-Policy", csp);
return ergebnis;
}
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const pfad = request.nextUrl.pathname; const pfad = request.nextUrl.pathname;
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) { if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
return NextResponse.next(); return mitRichtlinie(request);
} }
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value); const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
if (sitzung) return NextResponse.next(); if (sitzung) return mitRichtlinie(request);
const ziel = new URL("/login", request.url); const ziel = new URL("/login", request.url);
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`); if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
return NextResponse.redirect(ziel); return mitRichtlinie(request, NextResponse.redirect(ziel));
} }
export const config = { export const config = {
+14 -1
View File
@@ -3,6 +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 { 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";
@@ -15,16 +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("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("fachlichSchreiben");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -35,6 +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("betrieb");
const ergebnis = await alertsAbgleichen(); const ergebnis = await alertsAbgleichen();
neuLaden(); neuLaden();
return { return {
@@ -47,6 +59,7 @@ export async function warnregelnSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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[]> = {};
+7
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse"; import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele"; import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten"; import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand"; import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
@@ -22,6 +23,7 @@ export async function emailKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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);
@@ -78,6 +80,7 @@ export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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() ?? "";
@@ -116,6 +119,7 @@ export async function webhookKanalSpeichern(
} }
export async function kanalLoeschen(formular: FormData) { export async function kanalLoeschen(formular: FormData) {
await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -126,6 +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("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -140,6 +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("betrieb");
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus"; const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({ await prisma.einstellung.upsert({
@@ -154,6 +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("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");
}
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { revalidatePath } from "next/cache";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungen, einstellungenSchema, einstellungenSpeichern } from "@/server/einstellungen";
export interface AktionsErgebnis {
fehler?: string;
felder?: Record<string, string>;
erfolg?: string;
}
const zahl = (formular: FormData, feld: string) => {
const roh = formular.get(feld)?.toString().replace(",", ".").trim();
return roh === undefined || roh === "" ? undefined : Number(roh);
};
const text = (formular: FormData, feld: string) => formular.get(feld)?.toString().trim() ?? "";
const schalter = (formular: FormData, feld: string) => formular.get(feld) === "an";
export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("verwaltung");
const vorher = await einstellungen();
const ergebnis = einstellungenSchema.safeParse({
dashboardUrl: text(formular, "dashboardUrl"),
agentRhythmus: text(formular, "agentRhythmus") || vorher.agentRhythmus,
agentStreuungStunden: zahl(formular, "agentStreuungStunden"),
agentAutoUpdate: schalter(formular, "agentAutoUpdate"),
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
sitzungsdauerStunden: zahl(formular, "sitzungsdauerStunden"),
anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
oidcStandardRolle: text(formular, "oidcStandardRolle") || vorher.oidcStandardRolle,
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
firmaName: text(formular, "firmaName"),
firmaAnschrift: text(formular, "firmaAnschrift"),
firmaEmail: text(formular, "firmaEmail"),
zeitzone: text(formular, "zeitzone") || vorher.zeitzone,
});
if (!ergebnis.success) {
const felder: Record<string, string> = {};
for (const fehler of ergebnis.error.issues) {
const feld = fehler.path[0];
if (typeof feld === "string" && !felder[feld]) felder[feld] = fehler.message;
}
return { fehler: "Bitte die markierten Felder prüfen.", felder };
}
await einstellungenSpeichern(ergebnis.data);
// Nur die geänderten Felder ins Protokoll – sonst steht bei jedem Speichern alles darin.
const geaendert = Object.fromEntries(
Object.entries(ergebnis.data).filter(
([feld, wert]) => wert !== vorher[feld as keyof typeof vorher],
),
);
await protokolliere("einstellungen.gespeichert", "Einstellung", "system", geaendert);
revalidatePath("/einstellungen/system");
return { erfolg: "Einstellungen gespeichert." };
}
+13
View File
@@ -3,6 +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 { 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. */
@@ -10,9 +11,20 @@ export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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(",")
@@ -39,6 +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("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;
+3
View File
@@ -3,11 +3,13 @@
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 { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung"; import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
import type { AktionsErgebnis } from "./kunden"; 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("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;
@@ -30,6 +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("fachlichSchreiben");
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!hostId) return; if (!hostId) return;
+5
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
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";
export interface AktionsErgebnis { export interface AktionsErgebnis {
fehler?: string; fehler?: string;
@@ -28,6 +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("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() ?? "",
@@ -66,6 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
} }
export async function kundeLoeschen(formular: FormData) { export async function kundeLoeschen(formular: FormData) {
await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -91,6 +94,7 @@ export async function standortSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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() ?? "",
@@ -116,6 +120,7 @@ export async function standortSpeichern(
} }
export async function standortLoeschen(formular: FormData) { export async function standortLoeschen(formular: FormData) {
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");
}
+9 -2
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { anhangSpeichern, UploadFehler } from "@/lib/uploads"; import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
einkaufBezahltSetzen, einkaufBezahltSetzen,
kundenrechnungErfassen, kundenrechnungErfassen,
@@ -48,6 +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("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);
@@ -55,6 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
} }
export async function laufzeitVerwerfenAktion(formular: FormData) { export async function laufzeitVerwerfenAktion(formular: FormData) {
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);
@@ -63,6 +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("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";
@@ -84,6 +88,7 @@ export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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." };
@@ -101,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
datum: rechnungsdatum, datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")), faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto, nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19, ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
rabatt: betrag(formular.get("rabatt")), rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null, zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on", bezahlt: formular.get("bezahlt") === "on",
@@ -121,6 +126,7 @@ export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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." };
@@ -133,7 +139,7 @@ export async function kundenrechnungSpeichern(
nummer: formular.get("nummer")?.toString().trim() || null, nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")), datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")), nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19, ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
bezahlt: formular.get("bezahlt") === "on", bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")), bezahltAm: datum(formular.get("bezahltAm")),
anhangId, anhangId,
@@ -151,6 +157,7 @@ export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
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"));
+4
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { erzeugeToken } from "@/lib/tokens"; import { erzeugeToken } from "@/lib/tokens";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
export interface TokenErgebnis extends AktionsErgebnis { export interface TokenErgebnis extends AktionsErgebnis {
@@ -16,6 +17,7 @@ export async function enrollmentTokenErzeugen(
_vorher: TokenErgebnis, _vorher: TokenErgebnis,
formular: FormData, formular: FormData,
): Promise<TokenErgebnis> { ): Promise<TokenErgebnis> {
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;
@@ -58,6 +60,7 @@ export async function enrollmentTokenErzeugen(
} }
export async function enrollmentTokenWiderrufen(formular: FormData) { export async function enrollmentTokenWiderrufen(formular: FormData) {
await sitzungErforderlich("betrieb");
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -67,6 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
} }
export async function hostTokenWiderrufen(formular: FormData) { export async function hostTokenWiderrufen(formular: FormData) {
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;
+17 -6
View File
@@ -1,21 +1,32 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { einstellungen, onCalendar } from "@/server/einstellungen";
import { AGENT_VERSION } from "./http"; import { AGENT_VERSION } from "./http";
/** /**
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei * Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
* Dashboard-Adresse und Agent-Version ein. * Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
* Selbstaktualisierung).
*/ */
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) { export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
const pfad = path.join(process.cwd(), "agent", datei); const pfad = path.join(process.cwd(), "agent", datei);
const inhalt = await readFile(pfad, "utf8"); const [inhalt, werte] = await Promise.all([readFile(pfad, "utf8"), einstellungen()]);
return inhalt return inhalt
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, "")) .replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION); .replaceAll("@@AGENT_VERSION@@", AGENT_VERSION)
.replaceAll("@@TIMER_ONCALENDAR@@", onCalendar(werte.agentRhythmus))
.replaceAll("@@TIMER_STREUUNG@@", `${werte.agentStreuungStunden}h`)
.replaceAll("@@AUTO_UPDATE@@", werte.agentAutoUpdate ? "yes" : "no");
} }
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */ /**
export function basisUrl(request: Request): string { * Bevorzugt die im Dashboard eingetragene Adresse, dann `APP_URL`, sonst die Herkunft der Anfrage.
* Die Reihenfolge ist wichtig: Hinter einem Reverse Proxy stimmt die Herkunft oft nicht.
*/
export async function basisUrl(request: Request): Promise<string> {
const werte = await einstellungen();
if (werte.dashboardUrl) return werte.dashboardUrl;
if (process.env.APP_URL) return process.env.APP_URL; if (process.env.APP_URL) return process.env.APP_URL;
return new URL(request.url).origin; return new URL(request.url).origin;
} }
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { einstellungen } from "@/server/einstellungen";
/**
* Löscht alte Reports und Protokolleinträge nach den eingestellten Fristen (#025).
*
* Der jüngste Report jedes Hosts bleibt **immer** erhalten – sonst stünde ein Host, der lange
* nicht gemeldet hat, plötzlich ohne jede Angabe da. Eine Frist von 0 heißt: nichts löschen.
*/
export async function altdatenLoeschen(jetzt: Date = new Date()): Promise<{
reports: number;
protokoll: number;
}> {
const { aufbewahrungReportsTage, aufbewahrungAuditTage } = await einstellungen();
let reports = 0;
if (aufbewahrungReportsTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungReportsTage * 86_400_000);
const neueste = await prisma.report.groupBy({
by: ["hostId"],
_max: { erhobenAm: true },
});
const behalten = await prisma.report.findMany({
where: {
OR: neueste
.filter((eintrag) => eintrag._max.erhobenAm !== null)
.map((eintrag) => ({ hostId: eintrag.hostId, erhobenAm: eintrag._max.erhobenAm as Date })),
},
select: { id: true },
});
const ergebnis = await prisma.report.deleteMany({
where: {
erhobenAm: { lt: grenze },
id: { notIn: behalten.map((eintrag) => eintrag.id) },
},
});
reports = ergebnis.count;
}
let protokoll = 0;
if (aufbewahrungAuditTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungAuditTage * 86_400_000);
const ergebnis = await prisma.auditEintrag.deleteMany({ where: { erstelltAm: { lt: grenze } } });
protokoll = ergebnis.count;
}
return { reports, protokoll };
}
+36 -4
View File
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit"; import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { domaeneErlaubt, einstellungen } from "@/server/einstellungen";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */ /**
export const anmeldeRateLimit = new RateLimit(5, 5 / 900); * Anmeldeversuche je Kennung und Viertelstunde. Die Zahl steht in den Einstellungen;
* für jede Einstellung wird eine eigene Bremse gehalten, damit der Zustand beim Ändern
* nicht verlorengeht.
*/
const bremsen = new Map<number, RateLimit>();
export function anmeldeBremse(versuche: number): RateLimit {
const vorhanden = bremsen.get(versuche);
if (vorhanden) return vorhanden;
const neu = new RateLimit(versuche, versuche / 900);
bremsen.set(versuche, neu);
return neu;
}
/** Vorgabe für Aufrufer ohne Einstellungen (Tests). */
export const anmeldeRateLimit = anmeldeBremse(5);
export class AnmeldeFehler extends Error {} export class AnmeldeFehler extends Error {}
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
passwort: string, passwort: string,
quelle: string, quelle: string,
): Promise<BenutzerModel> { ): Promise<BenutzerModel> {
const { anmeldeversuche } = await einstellungen();
const kennung = `${quelle}:${email.toLowerCase()}`; const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) { if (!anmeldeBremse(anmeldeversuche).pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten."); throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
} }
@@ -41,10 +59,17 @@ export async function mitPasswortAnmelden(
/** /**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail. * Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten. * Ob ein unbekanntes Konto angelegt werden darf und welche E-Mail-Domänen zugelassen sind,
* steht in den Einstellungen (#025). Vorgabe bleibt: anlegen, alle Domänen.
*/ */
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, oidcStandardRolle } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
throw new AnmeldeFehler("Diese E-Mail-Domäne ist für die Anmeldung nicht zugelassen.");
}
const vorhanden = const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ?? (await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
@@ -54,6 +79,11 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
} }
if (!vorhanden && !oidcKontenAnlegen) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "kein-konto" });
throw new AnmeldeFehler("Für diese Adresse gibt es hier kein Konto.");
}
const benutzer = vorhanden const benutzer = vorhanden
? await prisma.benutzer.update({ ? await prisma.benutzer.update({
where: { id: vorhanden.id }, where: { id: vorhanden.id },
@@ -64,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(),
}, },
}); });
@@ -71,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;
+9 -3
View File
@@ -1,4 +1,5 @@
import * as client from "openid-client"; import * as client from "openid-client";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
/** /**
* Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen, * Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen,
@@ -28,8 +29,13 @@ export function oidcEinstellungen(): OidcEinstellungen | null {
}; };
} }
export function rueckkehrAdresse(): string { /**
const basis = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); * Adresse, an die der Anbieter zurückschickt. Sie muss beim Anbieter **wortgleich** hinterlegt sein.
* Vorrang hat die eingetragene Dashboard-Adresse, dann `APP_URL` (#029).
*/
export async function rueckkehrAdresse(): Promise<string> {
const { dashboardUrl } = await systemEinstellungen();
const basis = (dashboardUrl || process.env.APP_URL || "http://localhost:3000").replace(/\/$/, "");
return `${basis}/api/auth/oidc/callback`; return `${basis}/api/auth/oidc/callback`;
} }
@@ -55,7 +61,7 @@ export async function anmeldungStarten(einstellungen: OidcEinstellungen): Promis
const zustand = client.randomState(); const zustand = client.randomState();
const url = client.buildAuthorizationUrl(konfig, { const url = client.buildAuthorizationUrl(konfig, {
redirect_uri: rueckkehrAdresse(), redirect_uri: await rueckkehrAdresse(),
scope: "openid email profile", scope: "openid email profile",
code_challenge: herausforderung, code_challenge: herausforderung,
code_challenge_method: "S256", code_challenge_method: "S256",
+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.",
};
+98
View File
@@ -0,0 +1,98 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "./rollen";
/**
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
*
* `proxy.ts` hält Unangemeldete von den Seiten fern, aber Server Actions sind eigene
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control).
*
* Rolle und Kundenbindung kommen **aus der Datenbank**, nicht aus dem Token: Eine geänderte
* Rolle wirkt damit sofort und nicht erst nach Ablauf der Sitzung.
*/
export interface AngemeldeterBenutzer extends GeleseneSitzung {
rolle: Rolle;
/** Nur bei der Rolle KUNDE gesetzt: der Kunde, dessen Daten sichtbar sind. */
kundeId: string | null;
}
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten(
sitzung: GeleseneSitzung | null,
benutzer: { aktiv: boolean; sitzungenUngueltigAb: Date | null } | null,
): { gueltig: true } | { gueltig: false; grund: string } {
if (!sitzung) return { gueltig: false, grund: "Bitte anmelden." };
if (!benutzer) return { gueltig: false, grund: "Dieses Konto gibt es nicht mehr." };
if (!benutzer.aktiv) return { gueltig: false, grund: "Dieses Konto ist gesperrt." };
if (benutzer.sitzungenUngueltigAb && sitzung.ausgestelltAm <= benutzer.sitzungenUngueltigAb) {
return { gueltig: false, grund: "Die Sitzung wurde beendet. Bitte neu anmelden." };
}
return { gueltig: true };
}
/**
* 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 = await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { aktiv: true, sitzungenUngueltigAb: true, rolle: true, kundeId: true },
});
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return null;
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";
}
+125
View File
@@ -0,0 +1,125 @@
import { z } from "zod";
import { prisma } from "@/lib/db";
/**
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
*
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
*
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
*/
const SCHLUESSEL = "system";
export const einstellungenSchema = z.object({
// --- Adresse und Agent ---
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
agentAutoUpdate: z.boolean().default(false),
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
steuersatz: z.number().min(0).max(100).default(19),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
// --- Sicherheit ---
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
anmeldeversuche: z.number().int().min(3).max(50).default(5),
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
/**
* Rolle für automatisch angelegte OIDC-Konten. Vorgabe ist bewusst TECHNIKER und nicht ADMIN:
* Wer sich beim Identitätsanbieter anmelden kann, soll nicht nebenbei Administrator werden (#027).
*/
oidcStandardRolle: z.enum(["ADMIN", "TECHNIKER", "KUNDE"]).default("TECHNIKER"),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
// --- Firma und Darstellung ---
firmaName: z.string().max(120).default(""),
firmaAnschrift: z.string().max(400).default(""),
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
zeitzone: z.string().min(1).default("Europe/Berlin"),
});
export type Einstellungen = z.infer<typeof einstellungenSchema>;
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
/**
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
*/
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
export async function einstellungen(): Promise<Einstellungen> {
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
return werte;
}
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: werte },
update: { wert: werte },
});
zwischenspeicher = null;
}
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
export function zwischenspeicherLeeren(): void {
zwischenspeicher = null;
}
/**
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
*/
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
const { zahlungszielTage } = await einstellungen();
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
}
/** systemd-Angabe für den Melderhythmus des Agents. */
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
switch (rhythmus) {
case "stuendlich":
return "hourly";
case "zwoelfstuendlich":
return "0/12:00:00";
default:
return "daily";
}
}
/**
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
*/
export function domaeneErlaubt(email: string, domaenen: string): boolean {
const liste = domaenen
.split(",")
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (liste.length === 0) return true;
const domaene = email.toLowerCase().split("@")[1] ?? "";
return liste.includes(domaene);
}
+9 -3
View File
@@ -121,14 +121,17 @@ export function Bericht({
untertitel, untertitel,
children, children,
quer = false, quer = false,
firma,
}: { }: {
titel: string; titel: string;
untertitel?: string; untertitel?: string;
children: React.ReactNode; children: React.ReactNode;
quer?: boolean; quer?: boolean;
/** Firmenangaben und Zeitzone aus den Einstellungen (#025). */
firma?: { name: string; anschrift: string; email: string; zeitzone: string };
}) { }) {
const erstellt = new Intl.DateTimeFormat("de-DE", { const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin", timeZone: firma?.zeitzone || "Europe/Berlin",
dateStyle: "long", dateStyle: "long",
}).format(new Date()); }).format(new Date());
@@ -138,8 +141,11 @@ export function Bericht({
<View style={stile.kopf} fixed> <View style={stile.kopf} fixed>
<Logo /> <Logo />
<View style={stile.kopfText}> <View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text> <Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text> <Text style={stile.untertitel}>
{firma?.anschrift ? firma.anschrift.replace(/\n/g, " · ") : "Proxmox Subscription Dashboard"}
</Text>
{firma?.email ? <Text style={stile.untertitel}>{firma.email}</Text> : null}
</View> </View>
</View> </View>
+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" } });
} }

Some files were not shown because too many files have changed in this diff Show More