Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e514dd25 | ||
|
|
9f1eb189db | ||
|
|
585173622d | ||
|
|
9c6c6a1c35 | ||
|
|
2195418867 | ||
|
|
ddca903687 | ||
|
|
eaaabd8e75 | ||
|
|
6317b731a2 | ||
|
|
b6fdd4f64a | ||
|
|
2d886ac062 | ||
|
|
2f45e33e20 | ||
|
|
7d8e5c9172 | ||
|
|
6c3729bae8 | ||
|
|
ffc80a8fcf | ||
|
|
d416f3869a | ||
|
|
19b0751c16 | ||
|
|
7775b6211f |
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,86 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.20.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `deploy/update.sh` und `deploy/backup.sh` erkennen die Installationsart selbst und beherrschen jetzt auch die Installation als Dienst (Node und PostgreSQL direkt im Container) – nicht mehr nur Docker (#031)
|
||||||
|
- Eine fehlgeschlagene Sicherung bricht die Aktualisierung ab, bevor etwas verändert wird (#031)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die Zustandsprüfung des Web-Containers konnte nie gelingen: Next bindet im Standalone-Modus an die Adresse aus `HOSTNAME`, und Docker setzt dort den Containernamen – der Server lauschte nur auf der Container-IP. `web` blieb dauerhaft auf „health: starting“ (#031)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 9 h 43 min
|
**Gesamt:** 16 h 08 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
| #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 |
|
||||||
|
| #031 Update für beide Varianten | 0 h 45 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -51,7 +59,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.20.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -138,6 +146,33 @@
|
|||||||
- 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
|
||||||
|
|
||||||
|
- Update- und Sicherungsskript für beide Installationsarten (#031). Beim Durchspielen zwei Fehler gefunden: Die Zustandsprüfung des Web-Containers konnte nie gelingen (Next bindet an `HOSTNAME`, Docker setzt dort den Containernamen), und eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf
|
||||||
|
- Lehre: Ein Skript, das nie unter echten Bedingungen gelaufen ist, ist eine Behauptung. Erst der Durchlauf gegen einen laufenden Stapel hat beide Fehler gezeigt
|
||||||
|
|
||||||
### 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)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ RUN npx prisma generate \
|
|||||||
FROM basis AS laufzeit
|
FROM basis AS laufzeit
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
# Ohne diese Zeile nimmt Next den von Docker gesetzten HOSTNAME als Bindeadresse und lauscht
|
||||||
|
# nur auf der Container-IP. Die Zustandsprüfung über 127.0.0.1 schlägt dann immer fehl.
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
|
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sichert Datenbank und hochgeladene PDFs von ProxLedger.
|
# Sichert Datenbank, hochgeladene PDFs und die Konfiguration von ProxLedger.
|
||||||
|
# Erkennt selbst, ob die Installation mit Docker oder als Dienst im Container läuft.
|
||||||
#
|
#
|
||||||
# ./backup.sh /pfad/zum/sicherungsordner
|
# ./backup.sh /pfad/zum/sicherungsordner
|
||||||
#
|
#
|
||||||
@@ -13,17 +14,50 @@ set -o pipefail
|
|||||||
ZIEL="${1:-/var/backups/proxledger}"
|
ZIEL="${1:-/var/backups/proxledger}"
|
||||||
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
|
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
|
||||||
STAND=$(date +%Y%m%d-%H%M%S)
|
STAND=$(date +%Y%m%d-%H%M%S)
|
||||||
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}"
|
VERZEICHNIS="${COMPOSE_VERZEICHNIS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
|
||||||
mkdir -p "$ZIEL"
|
mkdir -p "$ZIEL"
|
||||||
cd "$COMPOSE_VERZEICHNIS"
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
echo "Sichere Datenbank …"
|
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
|
||||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
ART="dienst"
|
||||||
|
else
|
||||||
|
ART="docker"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Sichere hochgeladene Dateien …"
|
echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
|
||||||
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
|
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
echo "Sichere Datenbank …"
|
||||||
|
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
||||||
|
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
|
echo "Sichere hochgeladene Dateien …"
|
||||||
|
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# DATABASE_URL steht in der .env; pg_dump versteht sie direkt.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -o allexport
|
||||||
|
. ./.env
|
||||||
|
set +o allexport
|
||||||
|
|
||||||
|
[ -n "${DATABASE_URL:-}" ] || {
|
||||||
|
echo "DATABASE_URL fehlt in der .env – ohne sie keine Sicherung." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Sichere Datenbank …"
|
||||||
|
pg_dump "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
|
echo "Sichere hochgeladene Dateien …"
|
||||||
|
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
|
||||||
|
if [ -d "$UPLOADS" ]; then
|
||||||
|
tar -czf "${ZIEL}/uploads-${STAND}.tar.gz" -C "$(dirname "$UPLOADS")" "$(basename "$UPLOADS")"
|
||||||
|
else
|
||||||
|
echo " (kein Upload-Verzeichnis unter ${UPLOADS})"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Sichere Konfiguration …"
|
echo "Sichere Konfiguration …"
|
||||||
cp .env "${ZIEL}/env-${STAND}.txt"
|
cp .env "${ZIEL}/env-${STAND}.txt"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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}"
|
||||||
@@ -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"
|
||||||
@@ -181,4 +181,5 @@ printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beisp
|
|||||||
echo
|
echo
|
||||||
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
|
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
|
||||||
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
|
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
|
||||||
printf ' Aktualisieren: pct exec %s -- bash -c "cd /opt/proxledger && git pull && docker compose up -d --build"\n' "$CTID"
|
printf ' Aktualisieren: pct exec %s -- bash -c "bash /opt/proxledger/deploy/update.sh"\n' "$CTID"
|
||||||
|
printf ' Sichern: pct exec %s -- bash -c "bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger"\n' "$CTID"
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxLedger aktualisieren – erkennt selbst, ob die Installation mit Docker
|
||||||
|
# oder als Dienst im Container (Node + PostgreSQL direkt) läuft.
|
||||||
|
#
|
||||||
|
# bash deploy/update.sh # sichern, holen, 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"
|
||||||
|
|
||||||
|
# Wohin gesichert wird. Der Standardort gehört root; wer anders arbeitet, setzt SICHERUNG_ZIEL.
|
||||||
|
SICHERUNG_ZIEL="${SICHERUNG_ZIEL:-/var/backups/proxledger}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Art der Installation
|
||||||
|
#
|
||||||
|
# Der systemd-Dienst ist das eindeutigere Merkmal: Die docker-compose.yml liegt auch im
|
||||||
|
# Quelltext der Dienst-Installation, ein Dienst namens proxledger aber nur dort.
|
||||||
|
installationsart() {
|
||||||
|
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||||
|
printf 'dienst'
|
||||||
|
elif command -v docker >/dev/null 2>&1 && [ -f docker-compose.yml ]; then
|
||||||
|
printf 'docker'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ART=$(installationsart)
|
||||||
|
[ -n "$ART" ] || fehler "Keine Installation gefunden – weder der Dienst proxledger noch Docker mit docker-compose.yml."
|
||||||
|
|
||||||
|
blau "Installationsart: ${ART}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Sicherung
|
||||||
|
if [ "$SICHERN" = "ja" ]; then
|
||||||
|
blau "Sicherung anlegen nach ${SICHERUNG_ZIEL} …"
|
||||||
|
if ! bash deploy/backup.sh "$SICHERUNG_ZIEL"; then
|
||||||
|
fehler "Die Sicherung ist fehlgeschlagen – deshalb wird nicht aktualisiert.
|
||||||
|
Häufigste Ursache: fehlende Rechte am Zielordner. Mögliche Wege:
|
||||||
|
sudo bash deploy/update.sh
|
||||||
|
SICHERUNG_ZIEL=/pfad/mit/rechten bash deploy/update.sh
|
||||||
|
bash deploy/update.sh --ohne-sicherung (nur wenn anderweitig gesichert ist)"
|
||||||
|
fi
|
||||||
|
gruen "Sicherung abgeschlossen."
|
||||||
|
else
|
||||||
|
printf '\033[1;33m! Ohne Sicherung – auf eigenes Risiko.\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Quelltext
|
||||||
|
if [ -d .git ]; then
|
||||||
|
blau "Neuen Stand holen …"
|
||||||
|
git pull --ff-only
|
||||||
|
else
|
||||||
|
printf '\033[1;33m! Kein Git-Verzeichnis – der Quelltext bleibt wie er ist.\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Aktualisieren
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
|
||||||
|
|
||||||
|
blau "Abbilder neu bauen …"
|
||||||
|
docker compose build --pull
|
||||||
|
|
||||||
|
blau "Datenbank migrieren …"
|
||||||
|
docker compose run --rm migrate
|
||||||
|
|
||||||
|
blau "Dienste neu starten …"
|
||||||
|
docker compose up -d
|
||||||
|
else
|
||||||
|
command -v npm >/dev/null 2>&1 || fehler "npm nicht gefunden – gehört zur Installation als Dienst."
|
||||||
|
[ -f .env ] || fehler "Keine .env gefunden (erwartet in ${VERZEICHNIS})."
|
||||||
|
|
||||||
|
# Die .env trägt APP_SECRET; ein git pull fasst sie nicht an, aber sicher ist sicher.
|
||||||
|
cp .env "/tmp/proxledger-env-$(date +%s).sicherung"
|
||||||
|
|
||||||
|
blau "Abhängigkeiten installieren …"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
blau "Datenbank migrieren …"
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
blau "Anwendung bauen …"
|
||||||
|
npm run build
|
||||||
|
npm run build:worker
|
||||||
|
|
||||||
|
blau "Dienste neu starten …"
|
||||||
|
systemctl restart proxledger proxledger-worker
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Prüfen
|
||||||
|
blau "Warte auf das Dashboard …"
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
|
||||||
|
for versuch in $(seq 1 45); do
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
|
||||||
|
else
|
||||||
|
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$erreichbar" = "ja" ]; then
|
||||||
|
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
|
||||||
|
[ "$ART" = "docker" ] && docker compose ps
|
||||||
|
[ "$ART" = "dienst" ] && systemctl --no-pager --lines=0 status proxledger proxledger-worker | grep -E "●|Active:"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ART" = "docker" ]; then
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
|
||||||
|
else
|
||||||
|
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: journalctl -u proxledger -n 50"
|
||||||
|
fi
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- uploads:/data
|
- uploads:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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/).
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
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,36 +321,121 @@ Jahresliste für die Buchhaltung.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9a. Einstellungen
|
||||||
|
|
||||||
|
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Rolle | Darf |
|
||||||
|
|---|---|
|
||||||
|
| **Administrator** | alles, einschließlich Benutzer und Systemeinstellungen |
|
||||||
|
| **Techniker** | alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer |
|
||||||
|
| **Kunde** | nur die Hosts und Rechnungen des eigenen Kunden, **ohne Einkaufspreise und Marge**; darf Notizen schreiben und Warnungen quittieren |
|
||||||
|
|
||||||
|
Ein Kundenkonto ist an genau einen Kunden gebunden – das Feld erscheint, sobald die Rolle „Kunde“
|
||||||
|
gewählt ist. Die Einschränkung sitzt in den Abfragen selbst, nicht in der Anzeige: Was ein Kundenkonto
|
||||||
|
nicht sehen darf, steht auch nicht im ausgelieferten HTML.
|
||||||
|
|
||||||
|
Ein Passwort ist beim Anlegen freiwillig. Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder
|
||||||
|
einen Passkey anmeldet. **Ein neues Passwort beendet alle laufenden Sitzungen dieses Kontos**, ebenso
|
||||||
|
das Sperren.
|
||||||
|
|
||||||
|
### Passkeys
|
||||||
|
|
||||||
|
Ein Passkey ersetzt das Passwort: Der Browser bestätigt die Anmeldung mit Fingerabdruck, Gesicht, PIN
|
||||||
|
oder einem Sicherheitsschlüssel. Jeder Benutzer verwaltet seine eigenen unter *Meine Passkeys*:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Zwei Dinge sind wichtig:
|
||||||
|
|
||||||
|
1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://`
|
||||||
|
im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt.
|
||||||
|
2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf
|
||||||
|
`https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy
|
||||||
|
einrichten, dann Passkeys anlegen.
|
||||||
|
|
||||||
|
Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als
|
||||||
|
Rückweg immer nutzbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 10. Sicherung und Aktualisierung
|
## 10. Sicherung und Aktualisierung
|
||||||
|
|
||||||
**Sichern:**
|
**Sichern:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/backup.sh /var/backups/proxledger
|
bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env`. Für regelmäßige Sicherungen in die
|
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env` – und erkennt selbst, ob die
|
||||||
Crontab eintragen:
|
Installation mit Docker oder als Dienst im Container läuft. Für regelmäßige Sicherungen in die Crontab:
|
||||||
|
|
||||||
```
|
```
|
||||||
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
**Zurückspielen:**
|
**Zurückspielen** (Docker):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bei der Installation als Dienst stattdessen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c datenbank-20260917-023000.sql.gz | psql "$DATABASE_URL"
|
||||||
|
```
|
||||||
|
|
||||||
**Aktualisieren:**
|
**Aktualisieren:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/proxledger
|
bash /opt/proxledger/deploy/update.sh
|
||||||
git pull
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrationen laufen dabei automatisch.
|
Ein Befehl für beide Installationsarten. Der Reihe nach: sichern, neuen Stand holen, bauen,
|
||||||
|
migrieren, neu starten – und zum Schluss prüfen, ob das Dashboard wieder antwortet. Schlägt die
|
||||||
|
Sicherung fehl, bricht das Skript ab, **bevor** etwas verändert wird.
|
||||||
|
|
||||||
|
| Aufruf | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `bash deploy/update.sh` | der übliche Weg |
|
||||||
|
| `SICHERUNG_ZIEL=/pfad bash deploy/update.sh` | anderer Ort für die Sicherung |
|
||||||
|
| `bash deploy/update.sh --ohne-sicherung` | nur, wenn anderweitig gesichert ist |
|
||||||
|
|
||||||
|
Beim LXC-Weg nach Art der Helper-Scripts geht es auch über das Skript auf dem Proxmox-Host – dort
|
||||||
|
erkennt das Gerüst eine vorhandene Installation und bietet die Aktualisierung an.
|
||||||
|
|
||||||
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
|
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
|
||||||
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
|
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 253 KiB |
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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 /var/backups/proxledger
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript erkennt selbst, ob die Installation mit Docker oder als Dienst läuft, und sichert in beiden
|
||||||
|
Fällen Datenbank, Uploads und `.env`. Beim LXC-Weg reicht zusätzlich die gewohnte Proxmox-Sicherung
|
||||||
|
des Containers.
|
||||||
|
|
||||||
|
Vor jeder Aktualisierung läuft es ohnehin automatisch – [`deploy/update.sh`](../../deploy/update.sh)
|
||||||
|
ruft es als Erstes auf und bricht ab, wenn es fehlschlägt.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #031 Update- und Sicherungsskript für beide Installationsarten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.0
|
||||||
|
**Labels:** deployment
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
`deploy/update.sh` und `deploy/backup.sh` konnten nur Docker. Die Installation als Dienst (Node und
|
||||||
|
PostgreSQL direkt im LXC) ging leer aus – dort half nur der Weg über das Gerüst der community-scripts.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Beide Skripte erkennen die Installationsart selbst (systemd-Dienst oder Docker)
|
||||||
|
- [x] Dienst-Weg: `npm ci`, `prisma migrate deploy`, bauen, `systemctl restart`
|
||||||
|
- [x] Sicherung für den Dienst-Weg: `pg_dump` über `DATABASE_URL`, Uploads aus `UPLOAD_DIR`
|
||||||
|
- [x] Fehlgeschlagene Sicherung bricht die Aktualisierung ab – **vor** jeder Änderung
|
||||||
|
- [x] Hinweis am Ende von `deploy/lxc-installer.sh` auf die Skripte umgestellt
|
||||||
|
- [x] Handbuch und Betriebsdoku angepasst
|
||||||
|
- [x] Docker-Weg vollständig durchgespielt
|
||||||
|
|
||||||
|
## Zwei Fehler beim Durchspielen gefunden
|
||||||
|
1. **Die Zustandsprüfung des Web-Containers konnte nie gelingen.** Next bindet im Standalone-Modus an
|
||||||
|
die Adresse aus `HOSTNAME` – und Docker setzt darin den Containernamen. Der Server lauschte also
|
||||||
|
nur auf der Container-IP, nicht auf `127.0.0.1`. Der Dienst `web` blieb dauerhaft auf
|
||||||
|
„health: starting“; von außen war er trotzdem erreichbar, deshalb fiel es bisher nicht auf.
|
||||||
|
Behoben mit `ENV HOSTNAME=0.0.0.0` im Dockerfile; die Prüfungen verwenden jetzt `127.0.0.1`
|
||||||
|
statt `localhost` (das löst im Container auch auf `::1` auf).
|
||||||
|
2. **Die Sicherung scheiterte stumm an fehlenden Rechten** (`/var/backups` gehört root) und die
|
||||||
|
Aktualisierung lief trotzdem weiter. Jetzt bricht sie ab und nennt die drei Auswege.
|
||||||
|
|
||||||
|
## Prüfung
|
||||||
|
Vollständiger Durchlauf gegen einen laufenden Stapel: Sicherung (Datenbank 6,3 KB mit 25 Tabellen,
|
||||||
|
Uploads, `.env`), Abbilder gebaut, migriert, neu gestartet, Dashboard nach zwei Sekunden wieder da.
|
||||||
|
Danach stand `web` zum ersten Mal auf **healthy**.
|
||||||
@@ -27,4 +27,12 @@ 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 |
|
||||||
|
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
||||||
|
| | **Summe** | | | **149 h** | **16 h 08 min** |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.14.1",
|
"version": "0.20.0",
|
||||||
"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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
benutzerId: benutzer.id,
|
const token = await sitzungErzeugen(
|
||||||
email: benutzer.email,
|
{
|
||||||
name: benutzer.name,
|
benutzerId: benutzer.id,
|
||||||
herkunft: "passwort",
|
email: benutzer.email,
|
||||||
});
|
name: benutzer.name,
|
||||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
herkunft: "passwort",
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
benutzerId: benutzer.id,
|
const token = await sitzungErzeugen(
|
||||||
email: benutzer.email,
|
{
|
||||||
name: benutzer.name,
|
benutzerId: benutzer.id,
|
||||||
herkunft: "oidc",
|
email: benutzer.email,
|
||||||
});
|
name: benutzer.name,
|
||||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
|
herkunft: "oidc",
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
...(darf(rolle, "betrieb")
|
||||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
? [
|
||||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(darf(rolle, "verwaltung")
|
||||||
|
? [
|
||||||
|
{ href: "/einstellungen/benutzer", titel: "Benutzer", icon: "bi-person-gear" },
|
||||||
|
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||||
|
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function istAktiv(href: string) {
|
function istAktiv(href: string) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
|
||||||
|
export const PASSWORT_MINDESTLAENGE = 12;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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[]> = {};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
@@ -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,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,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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||