Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86a328b3c | ||
|
|
b2664de62f | ||
|
|
dfb2118bac | ||
|
|
335fc47496 | ||
|
|
e570d30019 | ||
|
|
c5293f5915 | ||
|
|
c1177067e6 | ||
|
|
e50fb30ab3 | ||
|
|
97f6450f1f | ||
|
|
ca2af0c2fc | ||
|
|
9c873a4069 | ||
|
|
09bb98235b | ||
|
|
4bcf3daedd | ||
|
|
94d5edbc6c | ||
|
|
92210e9099 | ||
|
|
6dfa85381a | ||
|
|
11e514dd25 | ||
|
|
9f1eb189db | ||
|
|
585173622d | ||
|
|
9c6c6a1c35 | ||
|
|
2195418867 | ||
|
|
ddca903687 | ||
|
|
eaaabd8e75 | ||
|
|
6317b731a2 | ||
|
|
b6fdd4f64a | ||
|
|
2d886ac062 | ||
|
|
2f45e33e20 |
@@ -17,6 +17,118 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.23.0] – 2026-10-03
|
||||
|
||||
### Hinzugefügt
|
||||
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
|
||||
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
|
||||
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
|
||||
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
|
||||
|
||||
## [0.22.2] – 2026-10-03
|
||||
|
||||
### Behoben
|
||||
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
|
||||
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
|
||||
|
||||
### Geändert
|
||||
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
|
||||
|
||||
## [0.22.1] – 2026-10-03
|
||||
|
||||
### Behoben
|
||||
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
|
||||
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
|
||||
|
||||
### Hinzugefügt
|
||||
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
|
||||
|
||||
## [0.22.0] – 2026-10-03
|
||||
|
||||
### Geändert
|
||||
- **Der Rabatt gehört jetzt zur Rechnungsposition statt zur Rechnung** – so, wie Proxmox abrechnet: je Subscription-Key eine Zeile mit Listenpreis und eine mit „Client Discount“. Nur so bleibt er bei einer Sammelrechnung dem richtigen Host zugeordnet (#037)
|
||||
- Der Rechnungsbetrag ist die Summe der Positionen nach Abzug ihrer Rabatte; Marge und Summen rechnen mit dem tatsächlich gezahlten Betrag (#037)
|
||||
- Eigener Steuersatz für den Einkauf, Vorgabe 0 % wegen Reverse Charge (#037)
|
||||
- Das Betragsfeld heißt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird (#037)
|
||||
|
||||
### Hinzugefügt
|
||||
- Zwölfte Warnregel `kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen – der Moment, an dem man selbst zahlt (#037)
|
||||
|
||||
### Behoben
|
||||
- Der Rabatt wurde bisher **nirgends abgezogen**, weder in der Summe noch in der Marge (#037)
|
||||
|
||||
## [0.21.0] – 2026-10-03
|
||||
|
||||
### Hinzugefügt
|
||||
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
|
||||
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
|
||||
|
||||
### Behoben
|
||||
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
|
||||
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
|
||||
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
|
||||
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
|
||||
|
||||
## [0.20.2] – 2026-10-03
|
||||
|
||||
### Behoben
|
||||
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
|
||||
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
|
||||
|
||||
### Hinzugefügt
|
||||
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
|
||||
|
||||
## [0.20.1] – 2026-10-03
|
||||
|
||||
### Behoben
|
||||
- **Abmelden führte auf `0.0.0.0:3000`** und damit ins Leere. Next.js macht Weiterleitungen aus Server Actions serverseitig absolut und nahm dafür die Lausch-Adresse des Servers. Betroffen waren Abmelden, die Sitzungsprüfung und das Löschen eines Kunden (#033)
|
||||
- Eine als Lausch-Adresse eingetragene `APP_URL` (`0.0.0.0`, `::`) wird jetzt übergangen statt übernommen (#033)
|
||||
- Die LXC-Installation brach mit *404 – Could not fetch install/proxledger-install.sh* ab: Ein per curl geladenes ct-Skript kennt seine Herkunft nicht und suchte bei den community-scripts (#032)
|
||||
|
||||
### Geändert
|
||||
- Vorgabe für den Arbeitsspeicher des LXC von 2 auf 4 GB – `next build` scheitert sonst während der Installation am Speicher (#032)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 12 h 18 min
|
||||
**Gesamt:** 21 h 43 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -32,6 +32,20 @@
|
||||
| #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 |
|
||||
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
||||
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
||||
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||
| #035 Tabellen als Karten | 0 h 50 min |
|
||||
| #036 Standort am Host | 0 h 30 min |
|
||||
| #037 Rabatt je Position | 0 h 55 min |
|
||||
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
||||
| #040 Schlagworte sichtbar | 0 h 40 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -54,7 +68,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.17.0
|
||||
**Version:** 0.23.0
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -152,6 +166,48 @@
|
||||
- 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
|
||||
|
||||
- Erster echter Einsatz durch Florian – und prompt zwei Fehler, die kein Test gezeigt hatte (#032, #033)
|
||||
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
||||
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
||||
|
||||
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
|
||||
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
|
||||
|
||||
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
|
||||
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
|
||||
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
|
||||
|
||||
- Eine echte Proxmox-Rechnung hat drei Fehler im Rechnungsteil aufgedeckt (#037): Rabatt an der falschen Ebene, nirgends abgezogen, und 19 % USt als Vorgabe, wo Reverse Charge gilt
|
||||
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
|
||||
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
|
||||
|
||||
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
|
||||
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
||||
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
||||
|
||||
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
|
||||
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
|
||||
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
|
||||
|
||||
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
|
||||
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
|
||||
|
||||
### Nächster Schritt
|
||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||
|
||||
@@ -24,6 +24,9 @@ RUN npx prisma generate \
|
||||
FROM basis AS laufzeit
|
||||
ENV NODE_ENV=production
|
||||
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
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push
|
||||
Auf einem Rechner mit Docker:
|
||||
|
||||
```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
|
||||
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
|
||||
docker compose up -d
|
||||
@@ -37,7 +37,7 @@ bash deploy/lxc-installer.sh # eigener Installer: Container mit Docker
|
||||
|
||||
```bash
|
||||
# Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin
|
||||
bash -c "$(curl -fsSL https://<git>/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
|
||||
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
|
||||
@@ -122,13 +122,23 @@ sie sich mit eigenen Tokens ausweisen.
|
||||
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
|
||||
je Kennung und Viertelstunde, danach Sperre (einstellbar unter *Einstellungen → System*).
|
||||
Benutzer sperren oder Passwort setzen: `npm run benutzer -- sperren <email>`.
|
||||
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse
|
||||
- **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
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/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
|
||||
#
|
||||
@@ -13,17 +14,50 @@ set -o pipefail
|
||||
ZIEL="${1:-/var/backups/proxledger}"
|
||||
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
|
||||
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"
|
||||
cd "$COMPOSE_VERZEICHNIS"
|
||||
cd "$VERZEICHNIS"
|
||||
|
||||
echo "Sichere Datenbank …"
|
||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
|
||||
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
|
||||
if systemctl cat proxledger.service >/dev/null 2>&1; then
|
||||
ART="dienst"
|
||||
else
|
||||
ART="docker"
|
||||
fi
|
||||
|
||||
echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
|
||||
|
||||
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
|
||||
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 …"
|
||||
cp .env "${ZIEL}/env-${STAND}.txt"
|
||||
|
||||
@@ -6,6 +6,10 @@ installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Reposito
|
||||
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
|
||||
`/opt/proxledger/.credentials`.
|
||||
|
||||
**Ressourcen:** Die Vorgabe ist 2 CPU-Kerne, **4 GB RAM** und 12 GB Platte. Die 4 GB braucht
|
||||
`next build` während der Installation; im Betrieb genügen 2 GB, die sich im Nachhinein in Proxmox
|
||||
wieder absenken lassen.
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) |
|
||||
@@ -16,11 +20,29 @@ systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsda
|
||||
Auf dem Proxmox-VE-Host als root:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://git.wilde-server.de/fw/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
|
||||
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.
|
||||
Das Gerüst (`build.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.
|
||||
|
||||
**Wichtig:** Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Ohne
|
||||
Angabe sucht das Gerüst `install/proxledger-install.sh` bei den community-scripts auf GitHub und
|
||||
bricht mit *404 – Could not fetch install/proxledger-install.sh* ab. Das ct-Skript setzt deshalb
|
||||
|
||||
```bash
|
||||
COMMUNITY_SCRIPTS_URL="https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts"
|
||||
```
|
||||
|
||||
selbst. Für einen anderen Zweig oder einen Fork lässt sich die Angabe von außen überschreiben:
|
||||
|
||||
```bash
|
||||
COMMUNITY_SCRIPTS_URL=https://git.example.de/<nutzer>/ProxLedger/raw/branch/<zweig>/deploy/community-scripts \
|
||||
bash -c "$(curl -fsSL …/ct/proxledger.sh)"
|
||||
```
|
||||
|
||||
Das Verzeichnis dahinter muss `ct/` und `install/` enthalten – deshalb zeigt die Angabe auf
|
||||
`deploy/community-scripts`, nicht auf die Wurzel des Repositorys.
|
||||
|
||||
Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist:
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
# Woher die App-Skripte kommen. Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht
|
||||
# ermitteln – ohne diese Angabe sucht das Geruest install/proxledger-install.sh bei den
|
||||
# community-scripts und bricht mit 404 ab. Ueberschreibbar fuer eigene Zweige oder Forks.
|
||||
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts}"
|
||||
export COMMUNITY_SCRIPTS_URL
|
||||
_CS_DEFAULT_URL="$COMMUNITY_SCRIPTS_URL"
|
||||
_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/fw/ProxLedger
|
||||
# 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}"
|
||||
# 4 GB fuer die Installation: `next build` braucht deutlich mehr als 2 GB und bricht sonst mit
|
||||
# einem Speicherfehler ab. Nach der Installation genuegen 2 GB im Betrieb.
|
||||
var_ram="${var_ram:-4096}"
|
||||
var_disk="${var_disk:-12}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-13}"
|
||||
var_arm64="${var_arm64:-yes}"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# 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/fw/ProxLedger
|
||||
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
@@ -14,7 +14,7 @@ network_check
|
||||
update_os
|
||||
|
||||
# Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist.
|
||||
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/fw/ProxLedger.git}"
|
||||
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/florian.wilde/ProxLedger.git}"
|
||||
ZWEIG="${PROXLEDGER_BRANCH:-main}"
|
||||
|
||||
msg_info "Installing Dependencies"
|
||||
@@ -40,7 +40,13 @@ 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}')
|
||||
# Erreichbare Adresse des Containers ermitteln. `hostname -I` liefert sie in aller Regel;
|
||||
# notfalls hilft die Route nach draussen. 0.0.0.0 waere eine Lausch-Adresse und im Browser nutzlos.
|
||||
IP_ADRESSE=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -z "$IP_ADRESSE" ] || [ "$IP_ADRESSE" = "0.0.0.0" ]; then
|
||||
IP_ADRESSE=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {print $7; exit}')
|
||||
fi
|
||||
[ -n "$IP_ADRESSE" ] || IP_ADRESSE="localhost"
|
||||
cat <<EOF >/opt/proxledger/.env
|
||||
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
|
||||
APP_SECRET="${APP_SECRET}"
|
||||
@@ -107,6 +113,8 @@ msg_ok "Created Services"
|
||||
{
|
||||
echo "ProxLedger"
|
||||
echo "Adresse: http://${IP_ADRESSE}:3000"
|
||||
echo " (Im Journal steht 0.0.0.0:3000 - das ist nur die Lausch-Adresse,"
|
||||
echo " im Browser gehoert die obige IP oder der spaetere DNS-Name hinein.)"
|
||||
echo "Benutzer: admin@proxledger.local"
|
||||
echo "Passwort: ${ADMIN_PASS}"
|
||||
echo
|
||||
|
||||
@@ -181,4 +181,5 @@ printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beisp
|
||||
echo
|
||||
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 ' 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"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# ProxLedger aktualisieren (Docker-Installation).
|
||||
# ProxLedger aktualisieren – erkennt selbst, ob die Installation mit Docker
|
||||
# oder als Dienst im Container (Node + PostgreSQL direkt) läuft.
|
||||
#
|
||||
# bash deploy/update.sh # Quelltext holen, neu bauen, migrieren, neu starten
|
||||
# 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.
|
||||
@@ -23,38 +24,105 @@ fehler() {
|
||||
SICHERN="ja"
|
||||
[ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein"
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fehler "Docker nicht gefunden."
|
||||
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
|
||||
# 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 …"
|
||||
bash deploy/backup.sh
|
||||
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
|
||||
|
||||
blau "Abbilder neu bauen …"
|
||||
docker compose build --pull
|
||||
# ---------------------------------------------------------------- Aktualisieren
|
||||
if [ "$ART" = "docker" ]; then
|
||||
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
|
||||
|
||||
blau "Datenbank migrieren …"
|
||||
docker compose run --rm migrate
|
||||
blau "Abbilder neu bauen …"
|
||||
docker compose build --pull
|
||||
|
||||
blau "Dienste neu starten …"
|
||||
docker compose up -d
|
||||
blau "Datenbank migrieren …"
|
||||
docker compose run --rm migrate
|
||||
|
||||
# Kurz warten und nachsehen, ob das Dashboard wieder antwortet.
|
||||
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 …"
|
||||
for versuch in $(seq 1 30); do
|
||||
if docker compose exec -T web wget -qO- http://localhost:3000/api/gesundheit >/dev/null 2>&1; then
|
||||
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)."
|
||||
docker compose ps
|
||||
[ "$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
|
||||
|
||||
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
|
||||
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:
|
||||
- uploads:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -18,11 +18,15 @@ auch mit Hosts hinter NAT.
|
||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||
9. [Exporte](#9-exporte)
|
||||
9a. [Einstellungen](#9a-einstellungen)
|
||||
9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
|
||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||
11. [Fehlersuche](#11-fehlersuche)
|
||||
|
||||
---
|
||||
|
||||
> **Einrichtung statt Bedienung?** Reverse Proxy, OIDC und Sicherung stehen in
|
||||
> [docs/betrieb/](../betrieb/README.md). Dieses Handbuch beschreibt die tägliche Arbeit.
|
||||
|
||||
## 1. Installation
|
||||
|
||||
### Mit Docker
|
||||
@@ -72,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
|
||||
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
|
||||
einmal ausfällt, kommst du trotzdem hinein.
|
||||
Es gibt drei gleichwertige Wege hinein:
|
||||
|
||||
- **Passkey** – der Knopf ganz oben. Der Browser fragt nach Fingerabdruck, Gesicht oder PIN. Anlegen
|
||||
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:
|
||||
|
||||
@@ -165,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
|
||||
|
||||
---
|
||||
|
||||
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
|
||||
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
|
||||
|
||||
## 4a. Cluster
|
||||
|
||||
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
||||
@@ -179,6 +192,19 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
|
||||
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
||||
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
||||
|
||||
### Gleichnamige Hosts auseinanderhalten
|
||||
|
||||
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
|
||||
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
|
||||
|
||||
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
|
||||
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
|
||||
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
|
||||
Host-Detail steht die Notiz oben bei den Stammdaten.
|
||||
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
|
||||
|
||||
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kunden und Standorte pflegen
|
||||
@@ -238,6 +264,23 @@ Positionen und wird automatisch nachgeführt.
|
||||
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
|
||||
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
|
||||
|
||||
### Der übliche Ablauf
|
||||
|
||||
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
||||
2. Die Kundenrechnung wird geschrieben und verschickt.
|
||||
3. Der Kunde zahlt. Jetzt meldet ProxLedger: *Kunde hat bezahlt – Proxmox-Rechnung noch offen*.
|
||||
4. Man zahlt bei Proxmox, setzt den Haken „bezahlt“ und lädt die **PAID**-Fassung des PDFs hoch – sie
|
||||
ersetzt die alte.
|
||||
|
||||
### Die Felder der Proxmox-Rechnung
|
||||
|
||||
| Feld | Inhalt |
|
||||
|---|---|
|
||||
| Rechnungsnummer | Nummer von Proxmox. **Dieselbe Nummer bei einer weiteren Laufzeit** hängt diese als Position an dieselbe Rechnung – so werden Sammelrechnungen über mehrere Hosts abgebildet. |
|
||||
| Listenpreis netto | Der Betrag dieser **einen** Laufzeit, wie er auf der Rechnung steht (z. B. 1.100 €). |
|
||||
| Rabatt | Der „Client Discount“ dieser Laufzeit (z. B. 110 €). Gerechnet wird mit Listenpreis minus Rabatt. |
|
||||
| USt | Bei Proxmox in aller Regel 0 – Reverse Charge nach Art. 196. Die Vorgabe steht unter *Einstellungen → System*. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Warnungen
|
||||
@@ -341,36 +384,91 @@ Zeitzone gilt für PDF, CSV und Benachrichtigungen.
|
||||
|
||||
---
|
||||
|
||||
## 9b. Benutzer, Rollen und Passkeys
|
||||
|
||||
Unter *Einstellungen → Benutzer* legen Administratoren Konten an und vergeben eine von drei Rollen:
|
||||
|
||||

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

|
||||
|
||||
Zwei Dinge sind wichtig:
|
||||
|
||||
1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://`
|
||||
im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt.
|
||||
2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf
|
||||
`https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy
|
||||
einrichten, dann Passkeys anlegen.
|
||||
|
||||
Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als
|
||||
Rückweg immer nutzbar.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sicherung und Aktualisierung
|
||||
|
||||
**Sichern:**
|
||||
|
||||
```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
|
||||
Crontab eintragen:
|
||||
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env` – und erkennt selbst, ob die
|
||||
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
|
||||
```
|
||||
|
||||
**Zurückspielen:**
|
||||
**Zurückspielen** (Docker):
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
```bash
|
||||
cd /opt/proxledger
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
bash /opt/proxledger/deploy/update.sh
|
||||
```
|
||||
|
||||
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
|
||||
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
|
||||
@@ -408,6 +506,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
||||
|
||||
## Anhang: dunkles Design und Mobilansicht
|
||||
|
||||
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
|
||||
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
|
||||
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
|
||||
übrigen Angaben stehen auf der jeweiligen Detailseite.
|
||||
|
||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||
Kopfleiste umschalten.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 252 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,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**.
|
||||
@@ -0,0 +1,27 @@
|
||||
# #032 LXC-Installation: Herkunft der Skripte und Speicher
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.20.1
|
||||
**Labels:** deployment
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Auslöser
|
||||
Erster echter Lauf auf `hv-rz5-pve-02`: Der Container entstand, dann brach es ab mit
|
||||
|
||||
```
|
||||
curl: (22) The requested URL returned error: 404
|
||||
✖️ Could not fetch install/proxledger-install.sh
|
||||
📁 Looked in: https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main
|
||||
```
|
||||
|
||||
## Ursache
|
||||
Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Das Gerüst setzt dann
|
||||
die Standardadresse der community-scripts ein und sucht dort `install/proxledger-install.sh`.
|
||||
|
||||
## Behebung
|
||||
- Das ct-Skript setzt `COMMUNITY_SCRIPTS_URL` selbst auf die eigene Gitea-Adresse (für Forks und
|
||||
andere Zweige weiterhin überschreibbar).
|
||||
- Vorgabe für den Arbeitsspeicher von 2 auf **4 GB** angehoben: `next build` braucht während der
|
||||
Installation deutlich mehr als 2 GB und wäre nach zehn Minuten am Speicher gescheitert.
|
||||
- Die ermittelte Adresse wird geprüft; `hostname -I` ohne Ergebnis fällt auf die Route nach außen
|
||||
zurück, und in den Zugangsdaten steht der Hinweis, dass `0.0.0.0` aus dem Journal keine
|
||||
aufrufbare Adresse ist.
|
||||
@@ -0,0 +1,35 @@
|
||||
# #033 Abmelden führte auf die Lausch-Adresse
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.20.1
|
||||
**Labels:** bug, deployment
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Auslöser
|
||||
Im Docker-Betrieb führte **Abmelden** auf `0.0.0.0:3000/login` – eine Adresse, die kein Browser
|
||||
aufrufen kann (`ERR_ADDRESS_INVALID`).
|
||||
|
||||
## Ursache
|
||||
Hausgemacht, und zwar in #031: Für die Zustandsprüfung des Containers wurde `ENV HOSTNAME=0.0.0.0`
|
||||
gesetzt, damit der Server auf allen Schnittstellen lauscht. Next.js macht eine Weiterleitung aus
|
||||
einer Server Action aber **serverseitig absolut** und nimmt dafür genau diese Adresse. Vorher stand
|
||||
dort der Containername (`http://0a5963a591df:3000/`) – derselbe Fehler, nur weniger auffällig.
|
||||
|
||||
Betroffen waren alle Weiterleitungen, die nicht über eine `Request` liefen: Abmelden, die Prüfung in
|
||||
`sitzungErforderlich` und das Löschen eines Kunden.
|
||||
|
||||
## Behebung
|
||||
- Neuer Helfer `eigenerPfad()` in `src/lib/adresse.ts`: baut die Adresse aus Einstellung, `APP_URL`
|
||||
oder den Kopfzeilen der Anfrage – nie aus der Lausch-Adresse.
|
||||
- `oeffentlicheBasis()` übergeht unbrauchbare Angaben (`0.0.0.0`, `::`) jetzt ausdrücklich, statt sie
|
||||
zu übernehmen. Eine falsch eingetragene Adresse macht das Dashboard damit nicht unbenutzbar.
|
||||
- Zwei zusätzliche Tests.
|
||||
|
||||
## Prüfung
|
||||
Gegen einen Docker-Stapel durchgespielt: Mit `APP_URL=http://0.0.0.0:3000` in der `.env` – also der
|
||||
Fehlkonfiguration – leitet die Anwendung jetzt auf den tatsächlich aufgerufenen Namen
|
||||
(`http://localhost:3003/login`) statt auf die Lausch-Adresse.
|
||||
|
||||
## Lehre
|
||||
Der Fix für die Zustandsprüfung hat einen zweiten Fehler sichtbar gemacht, der vorher nur anders
|
||||
aussah. Weiterleitungen gehören grundsätzlich aus der **konfigurierten** Adresse gebaut, nicht aus
|
||||
dem, was der Server zufällig über sich selbst weiß.
|
||||
@@ -0,0 +1,28 @@
|
||||
# #034 Bedienung auf dem Handy
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.20.2
|
||||
**Labels:** ui, bug
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||
|
||||
## Auslöser
|
||||
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
|
||||
rechts die Hälfte abgeschnitten war.
|
||||
|
||||
## Ursache
|
||||
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
|
||||
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
|
||||
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
|
||||
|
||||
## Behebung
|
||||
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
|
||||
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
|
||||
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
|
||||
- Schließt beim Navigieren und mit Escape
|
||||
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
|
||||
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
|
||||
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
|
||||
- Fünf Tests für die Zustandslogik
|
||||
|
||||
## Nicht geändert
|
||||
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
|
||||
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
|
||||
@@ -0,0 +1,28 @@
|
||||
# #035 Tabellen auf dem Handy als Karten
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.21.0
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||
|
||||
## Auslöser
|
||||
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
|
||||
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
|
||||
brachen Buchstabe für Buchstabe um.
|
||||
|
||||
## Aufgaben
|
||||
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
|
||||
ihre Beschriftung aus `data-label` davor
|
||||
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
|
||||
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
|
||||
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
|
||||
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
|
||||
|
||||
## Warum Karten statt Scrollen
|
||||
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
|
||||
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
|
||||
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
|
||||
|
||||
## Nebenbefund
|
||||
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
|
||||
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
|
||||
das Kästchen behält seine Größe.
|
||||
@@ -0,0 +1,22 @@
|
||||
# #036 Standort am Host setzen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.21.0
|
||||
**Labels:** bug, ui
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Auslöser
|
||||
„Den Standort bei den Hosts kann man nicht einstellen.“
|
||||
|
||||
## Was wirklich los war
|
||||
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
|
||||
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
|
||||
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
|
||||
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
|
||||
|
||||
## Behebung
|
||||
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
|
||||
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
|
||||
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
|
||||
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
|
||||
Zuordnung wäre schlimmer als keine
|
||||
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
|
||||
@@ -0,0 +1,46 @@
|
||||
# #037 Rabatt je Position und der tatsächliche Ablauf
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.22.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 55 min
|
||||
|
||||
## Auslöser
|
||||
Florian zeigte eine echte Proxmox-Rechnung (#291017) und beschrieb den Ablauf:
|
||||
|
||||
```
|
||||
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-3ef3be4d75 € 1.100,00
|
||||
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-b7a0d516af € 1.100,00
|
||||
Client Discount - … pve2s-3ef3be4d75 € -110,00
|
||||
Client Discount - … pve2s-b7a0d516af € -110,00
|
||||
Sub Total € 1.980,00
|
||||
```
|
||||
|
||||
Dazu: Man bekommt die Rechnung zuerst als **UNPAID**. Erst wenn der Kunde seine Rechnung bezahlt hat,
|
||||
zahlt man selbst bei Proxmox – und bekommt dieselbe Rechnung dann als **PAID** mit Transaktion.
|
||||
|
||||
## Was falsch war
|
||||
1. **Der Rabatt hing an der Rechnung, nicht an der Position.** Bei einer Sammelrechnung über zwei
|
||||
Hosts ließe sich ein Rabatt keinem Host zuordnen; die Marge je Host wäre falsch.
|
||||
2. **Er wurde nirgends abgezogen** – weder in der Summe noch in der Marge. Ein Feld, das aussieht wie
|
||||
eine Rechengröße und keine ist.
|
||||
3. **Vorgabe 19 % USt** für den Einkauf. Proxmox sitzt in Österreich und rechnet gegenüber Unternehmen
|
||||
mit USt-IdNr. über Reverse Charge ab – richtig sind 0 %.
|
||||
|
||||
## Behebung
|
||||
- `rabatt` wandert an `ProxmoxRechnungPosition`; die Migration verteilt vorhandene Werte anteilig auf
|
||||
die Positionen und rechnet die Rechnungssummen neu. Nichts geht verloren.
|
||||
- Rechnungsbetrag = Summe der Positionen **nach Abzug ihrer Rabatte** – wie das „Sub Total“ auf der
|
||||
echten Rechnung.
|
||||
- Marge und alle Summen rechnen mit dem Betrag nach Abzug.
|
||||
- Das Formular heißt jetzt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird.
|
||||
- Eigener Steuersatz für den Einkauf, Vorgabe 0 (Reverse Charge).
|
||||
- Die Sammelrechnung listet je Position „1.100,00 € − 110,00 € = 990,00 €“.
|
||||
|
||||
## Neue Warnregel
|
||||
`kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen. Genau der
|
||||
Zustand aus Florians Ablauf – das Geld ist da, die Rechnung auch, jetzt kann gezahlt werden. Bewusst
|
||||
unabhängig vom Fälligkeitsdatum, denn die Reihenfolge ist Absicht und keine Nachlässigkeit.
|
||||
|
||||
## Zum PDF
|
||||
Die PAID-Fassung ersetzt beim erneuten Hochladen einfach die UNPAID-Fassung – gleiche Rechnungsnummer,
|
||||
gleiche Rechnung. Gleiche Dateien werden an ihrer Prüfsumme erkannt und nicht doppelt abgelegt.
|
||||
@@ -0,0 +1,31 @@
|
||||
# #038 Seite wird auf dem Handy herausgezoomt
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.22.1
|
||||
**Labels:** bug, ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||
|
||||
## Auslöser
|
||||
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
|
||||
Host-Detailansicht saß verschoben im Bild.
|
||||
|
||||
## Ursache
|
||||
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
|
||||
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
|
||||
|
||||
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
|
||||
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
|
||||
`table-responsive` – anders als die übrigen.
|
||||
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
|
||||
|
||||
## Behebung
|
||||
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
|
||||
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
|
||||
- Die drei Tabellen bekommen ihre Hülle
|
||||
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
|
||||
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
|
||||
wurde die Host-Detailansicht gegengeprüft
|
||||
|
||||
## Nachtrag zu #037
|
||||
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
|
||||
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
|
||||
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
|
||||
@@ -0,0 +1,32 @@
|
||||
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.22.2
|
||||
**Labels:** bug, ui
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Auslöser
|
||||
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
|
||||
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
|
||||
|
||||
## Ursache
|
||||
Zwei Stellen:
|
||||
|
||||
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
|
||||
die Rechnungsliste dorthin.
|
||||
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
|
||||
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
|
||||
|
||||
## Behebung
|
||||
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
|
||||
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
|
||||
zwischen 768 und 992 px, wo die Tabelle am engsten ist
|
||||
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
|
||||
|
||||
## Nebenbefund
|
||||
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
|
||||
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
|
||||
|
||||
## Gelernt
|
||||
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
|
||||
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
|
||||
Klasse (`pl-spalte-laufzeit`).
|
||||
@@ -0,0 +1,26 @@
|
||||
# #040 Schlagworte und Notizen sichtbar machen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.23.0
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||
|
||||
## Auslöser
|
||||
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
|
||||
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
|
||||
|
||||
## Ausgangslage
|
||||
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
|
||||
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
|
||||
|
||||
## Behebung
|
||||
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
|
||||
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
|
||||
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
|
||||
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
|
||||
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
|
||||
Notiztext; die Trefferzeile nennt die Schlagworte.
|
||||
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
|
||||
|
||||
## Entscheidung
|
||||
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
|
||||
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
|
||||
@@ -30,4 +30,18 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
|
||||
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
|
||||
| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min |
|
||||
| | **Summe** | | | **133 h** | **12 h 18 min** |
|
||||
| [#027](027-rollen-benutzerverwaltung.md) | Rollen und Benutzerverwaltung | erledigt | v0.18.0 | 6 h | 1 h 20 min |
|
||||
| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min |
|
||||
| [#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 |
|
||||
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
||||
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
||||
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
|
||||
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
|
||||
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
|
||||
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
||||
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
||||
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
||||
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
|
||||
| | **Summe** | | | **165 h** | **21 h 43 min** |
|
||||
|
||||
@@ -15,8 +15,13 @@ Die Gliederung folgt den OWASP Top 10 (2021).
|
||||
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
|
||||
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
|
||||
|
||||
**Nicht vorhanden:** Rollen. Wer angemeldet ist, darf alles. Für ein Werkzeug, das ein Systemhaus intern
|
||||
betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht.
|
||||
**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
|
||||
die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden
|
||||
gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge
|
||||
werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML.
|
||||
|
||||
**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer
|
||||
Installation wären ein Umbau, kein Anbau.
|
||||
|
||||
## A02 – Kryptografische Fehler
|
||||
|
||||
@@ -70,8 +75,13 @@ gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
|
||||
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
|
||||
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
|
||||
|
||||
**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die
|
||||
Passwortanmeldung ab.
|
||||
- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
|
||||
mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`).
|
||||
- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht
|
||||
Administrator.
|
||||
|
||||
**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides
|
||||
in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor.
|
||||
|
||||
## A08 – Datenintegrität
|
||||
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.14.1",
|
||||
"version": "0.19.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxledger",
|
||||
"version": "0.14.1",
|
||||
"version": "0.19.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"admin-lte": "^4.9.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"croner": "^10.0.1",
|
||||
"jose": "^6.2.12",
|
||||
"next": "16.3.5",
|
||||
"next": "^16.3.8",
|
||||
"nodemailer": "^10.0.10",
|
||||
"openid-client": "^6.8.8",
|
||||
"react": "19.2.8",
|
||||
@@ -33,7 +35,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"eslint-config-next": "^16.3.8",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.7",
|
||||
"prisma": "^7.10.0",
|
||||
@@ -944,6 +946,12 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -1609,6 +1617,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
|
||||
@@ -1632,15 +1646,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz",
|
||||
"integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
|
||||
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz",
|
||||
"integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
|
||||
"integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1681,9 +1695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz",
|
||||
"integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
|
||||
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1697,9 +1711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz",
|
||||
"integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
|
||||
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1713,9 +1727,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz",
|
||||
"integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
|
||||
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1732,9 +1746,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz",
|
||||
"integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
|
||||
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1751,9 +1765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz",
|
||||
"integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
|
||||
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1770,9 +1784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz",
|
||||
"integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
|
||||
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1789,9 +1803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz",
|
||||
"integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
|
||||
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1805,9 +1819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz",
|
||||
"integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
|
||||
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2147,6 +2161,239 @@
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
|
||||
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-asym-key": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz",
|
||||
"integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
|
||||
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
|
||||
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
|
||||
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
|
||||
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
|
||||
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
|
||||
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pfx": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
|
||||
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
|
||||
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
|
||||
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
|
||||
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-post-quantum": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz",
|
||||
"integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-asym-key": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
|
||||
"integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-csr": "^2.9.4",
|
||||
"@peculiar/asn1-ecc": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs9": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -3031,6 +3278,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
|
||||
"integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "14.0.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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -4339,6 +4613,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -5601,13 +5889,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz",
|
||||
"integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
|
||||
"integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -7896,12 +8184,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz",
|
||||
"integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==",
|
||||
"version": "16.3.8",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
|
||||
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.3.5",
|
||||
"@next/env": "16.3.8",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -7915,14 +8203,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.3.5",
|
||||
"@next/swc-darwin-x64": "16.3.5",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.5",
|
||||
"@next/swc-linux-arm64-musl": "16.3.5",
|
||||
"@next/swc-linux-x64-gnu": "16.3.5",
|
||||
"@next/swc-linux-x64-musl": "16.3.5",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.5",
|
||||
"@next/swc-win32-x64-msvc": "16.3.5",
|
||||
"@next/swc-darwin-arm64": "16.3.8",
|
||||
"@next/swc-darwin-x64": "16.3.8",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.8",
|
||||
"@next/swc-linux-arm64-musl": "16.3.8",
|
||||
"@next/swc-linux-x64-gnu": "16.3.8",
|
||||
"@next/swc-linux-x64-musl": "16.3.8",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.8",
|
||||
"@next/swc-win32-x64-msvc": "16.3.8",
|
||||
"sharp": "^0.35.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -8667,6 +8955,24 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
@@ -8749,6 +9055,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -9703,6 +10015,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.17.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -34,12 +34,14 @@
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"admin-lte": "^4.9.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"croner": "^10.0.1",
|
||||
"jose": "^6.2.12",
|
||||
"next": "16.3.5",
|
||||
"next": "^16.3.8",
|
||||
"nodemailer": "^10.0.10",
|
||||
"openid-client": "^6.8.8",
|
||||
"react": "19.2.8",
|
||||
@@ -54,7 +56,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"eslint-config-next": "^16.3.8",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.7",
|
||||
"prisma": "^7.10.0",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Rabatt gehört an die Position, nicht an die Rechnung (#037).
|
||||
--
|
||||
-- Proxmox weist den Rabatt je Subscription-Key als eigene Zeile aus. Bei einer Sammelrechnung
|
||||
-- über mehrere Hosts ließe sich ein Rabatt an der Rechnung keinem Host zuordnen – und die Marge
|
||||
-- je Host wäre falsch.
|
||||
|
||||
ALTER TABLE "ProxmoxRechnungPosition" ADD COLUMN "rabatt" DECIMAL(12,2);
|
||||
|
||||
-- Vorhandene Rabatte von der Rechnung auf ihre Positionen verteilen, anteilig zum Nettobetrag.
|
||||
-- Bei nur einer Position entspricht das genau dem bisherigen Wert.
|
||||
UPDATE "ProxmoxRechnungPosition" AS p
|
||||
SET "rabatt" = ROUND(r."rabatt" * p."nettoBetrag" / NULLIF(s."summe", 0), 2)
|
||||
FROM "ProxmoxRechnung" AS r,
|
||||
(SELECT "rechnungId", SUM("nettoBetrag") AS "summe"
|
||||
FROM "ProxmoxRechnungPosition"
|
||||
GROUP BY "rechnungId") AS s
|
||||
WHERE p."rechnungId" = r."id"
|
||||
AND s."rechnungId" = r."id"
|
||||
AND r."rabatt" IS NOT NULL;
|
||||
|
||||
-- Der Rechnungsbetrag ist ab jetzt die Summe der Positionen nach Abzug ihrer Rabatte.
|
||||
UPDATE "ProxmoxRechnung" AS r
|
||||
SET "nettoBetrag" = COALESCE((
|
||||
SELECT SUM(p."nettoBetrag" - COALESCE(p."rabatt", 0))
|
||||
FROM "ProxmoxRechnungPosition" AS p
|
||||
WHERE p."rechnungId" = r."id"
|
||||
), r."nettoBetrag");
|
||||
|
||||
ALTER TABLE "ProxmoxRechnung" DROP COLUMN "rabatt";
|
||||
|
||||
-- Einkauf bei Proxmox läuft über Reverse Charge; 19 % war als Vorgabe irreführend.
|
||||
ALTER TABLE "ProxmoxRechnung" ALTER COLUMN "ustSatz" SET DEFAULT 0.00;
|
||||
@@ -72,6 +72,7 @@ model Kunde {
|
||||
alerts Alert[]
|
||||
|
||||
@@index([name])
|
||||
benutzer Benutzer[]
|
||||
}
|
||||
|
||||
model Standort {
|
||||
@@ -260,9 +261,10 @@ model ProxmoxRechnung {
|
||||
nummer String @unique
|
||||
datum DateTime @db.Date
|
||||
faelligAm DateTime? @db.Date
|
||||
/// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt.
|
||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
|
||||
rabatt Decimal? @db.Decimal(12, 2)
|
||||
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
|
||||
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
|
||||
zahlungsart String?
|
||||
bezahlt Boolean @default(false)
|
||||
bezahltAm DateTime? @db.Date
|
||||
@@ -283,7 +285,10 @@ model ProxmoxRechnungPosition {
|
||||
rechnungId String
|
||||
bezeichnung String
|
||||
menge Int @default(1)
|
||||
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
|
||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||
/// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037).
|
||||
rabatt Decimal? @db.Decimal(12, 2)
|
||||
|
||||
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||||
laufzeit Laufzeit?
|
||||
@@ -448,12 +453,23 @@ model Benachrichtigungskanal {
|
||||
// ---------------------------------------------------------------- Benutzer und Protokoll
|
||||
|
||||
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
|
||||
/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027).
|
||||
enum Rolle {
|
||||
ADMIN
|
||||
TECHNIKER
|
||||
KUNDE
|
||||
}
|
||||
|
||||
model Benutzer {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
passwortHash String?
|
||||
oidcSubject String? @unique
|
||||
/// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN.
|
||||
rolle Rolle @default(ADMIN)
|
||||
/// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist.
|
||||
kundeId String?
|
||||
aktiv Boolean @default(true)
|
||||
letzterLoginAm DateTime?
|
||||
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
|
||||
@@ -462,6 +478,28 @@ model Benutzer {
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
auditEintraege AuditEintrag[]
|
||||
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
|
||||
passkeys Passkey[]
|
||||
|
||||
@@index([kundeId])
|
||||
}
|
||||
|
||||
/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028).
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
/// Kennung des Schlüssels, base64url wie vom Browser geliefert.
|
||||
credentialId String @unique
|
||||
publicKey Bytes
|
||||
zaehler BigInt @default(0)
|
||||
transports String[] @default([])
|
||||
bezeichnung String
|
||||
erstelltAm DateTime @default(now())
|
||||
zuletztAm DateTime?
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([benutzerId])
|
||||
}
|
||||
|
||||
model AuditEintrag {
|
||||
|
||||
@@ -166,7 +166,6 @@ async function seedDemo() {
|
||||
faelligAm: datum(vorlage.faelligAm),
|
||||
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
||||
ustSatz: vorlage.ustSatz,
|
||||
rabatt: vorlage.rabatt ?? null,
|
||||
zahlungsart: vorlage.zahlungsart ?? null,
|
||||
bezahlt: vorlage.bezahlt,
|
||||
bezahltAm: datum(vorlage.bezahltAm),
|
||||
@@ -175,6 +174,8 @@ async function seedDemo() {
|
||||
id: `prp-${l.id}`,
|
||||
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
||||
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
||||
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
|
||||
rabatt: vorlage.rabatt ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,12 +18,21 @@ import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { request as httpAnfrage } from "node:http";
|
||||
import { SignJWT } from "jose";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||
const ALLE = process.argv.includes("--alle");
|
||||
|
||||
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
|
||||
const MOBIL_ZUSATZ = process.argv
|
||||
.filter((wert, i) => process.argv[i - 1] === "--mobil")
|
||||
.map((eintrag) => {
|
||||
const [name, ...rest] = eintrag.split("=");
|
||||
return { name, pfad: rest.join("="), titel: "Handy" };
|
||||
});
|
||||
|
||||
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
||||
const ZUSATZ = process.argv
|
||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||
@@ -49,6 +58,8 @@ const SEITEN = [
|
||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
|
||||
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
|
||||
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
|
||||
];
|
||||
|
||||
async function sitzungsToken() {
|
||||
@@ -57,14 +68,29 @@ async function sitzungsToken() {
|
||||
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
||||
}
|
||||
|
||||
// Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
|
||||
// Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
|
||||
// Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
|
||||
const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await datenbank.connect();
|
||||
const { rows } = await datenbank.query(
|
||||
`SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
|
||||
);
|
||||
await datenbank.end();
|
||||
const benutzer = rows[0];
|
||||
|
||||
if (!benutzer) {
|
||||
throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
|
||||
}
|
||||
|
||||
return new SignJWT({
|
||||
benutzerId: "screenshots",
|
||||
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
|
||||
name: process.env.ADMIN_NAME ?? "Florian",
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passwort",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("screenshots")
|
||||
.setSubject(benutzer.id)
|
||||
.setIssuedAt()
|
||||
.setIssuer("proxledger")
|
||||
.setExpirationTime("1h")
|
||||
@@ -169,6 +195,11 @@ async function main() {
|
||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||
}
|
||||
|
||||
for (const seite of MOBIL_ZUSATZ) {
|
||||
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
|
||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||
}
|
||||
|
||||
if (ALLE) {
|
||||
const dunkel = profilVorbereiten("dunkel", true);
|
||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||
@@ -177,7 +208,8 @@ async function main() {
|
||||
console.log(` ${seite}-dunkel.png`);
|
||||
}
|
||||
|
||||
for (const seite of ["dashboard", "hosts"]) {
|
||||
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
|
||||
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
|
||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
||||
console.log(` mobil-${seite}.png`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
||||
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
||||
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
||||
Mit {oidc.bezeichnung} anmelden
|
||||
</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}
|
||||
|
||||
<PasskeyAnmeldung ziel={ziel} />
|
||||
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
|
||||
<AnmeldeFormular ziel={ziel} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { Tabs } from "@/components/ui/Tabs";
|
||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
||||
import { hostKundeSetzen } from "@/server/actions/host-detail";
|
||||
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
||||
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
||||
import { hostMitDetails } from "@/server/queries/hostliste";
|
||||
import { meldungsGrenze } from "@/server/queries/hosts";
|
||||
@@ -43,7 +43,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
||||
@@ -104,6 +108,27 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
||||
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
||||
{meldetNicht ? <KeineMeldungBadge /> : null}
|
||||
|
||||
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
|
||||
Hosts ist das die Stelle, an der man sie braucht (#040). */}
|
||||
{host.tags.length > 0 ? (
|
||||
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
|
||||
{host.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||
className="badge pl-bg-neutral text-decoration-none"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{host.notizen ? (
|
||||
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
|
||||
{host.notizen}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Zeile bezeichnung="Kunde">
|
||||
@@ -167,30 +192,12 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
||||
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
||||
|
||||
<form action={hostKundeSetzen} className="mt-3">
|
||||
<input type="hidden" name="hostId" value={host.id} />
|
||||
<label className="form-label small" htmlFor="kundeId">
|
||||
Kunde ändern
|
||||
</label>
|
||||
<div className="input-group input-group-sm">
|
||||
<select
|
||||
id="kundeId"
|
||||
name="kundeId"
|
||||
className="form-select"
|
||||
defaultValue={host.kundeId ?? ""}
|
||||
>
|
||||
<option value="">– nicht zugeordnet –</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn btn-outline-primary">
|
||||
Setzen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<KundeStandortFormular
|
||||
hostId={host.id}
|
||||
kunden={kunden}
|
||||
kundeId={host.kundeId}
|
||||
standortId={host.standortId}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +246,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
inhalt: (
|
||||
<>
|
||||
<h3 className="h6">Keys</h3>
|
||||
<table className="table table-striped align-middle mb-4">
|
||||
<div className="table-responsive mb-4">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
@@ -266,8 +274,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="h6">Server-IDs</h3>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -295,6 +305,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{host.serverIdHistorie.length > 1 ? (
|
||||
<p className="small pl-text-warning mt-2 mb-0">
|
||||
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
||||
@@ -356,7 +367,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
icon: "bi-receipt",
|
||||
inhalt: (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<table className="table table-striped align-middle mb-0 pl-karten-tabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Laufzeit</th>
|
||||
@@ -369,21 +380,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
</thead>
|
||||
<tbody>
|
||||
{host.laufzeiten.map((laufzeit) => {
|
||||
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||
const einkauf = laufzeit.proxmoxPosition
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag)
|
||||
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
|
||||
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||
: null;
|
||||
const verkauf = laufzeit.kundenPosition
|
||||
? Number(laufzeit.kundenPosition.nettoBetrag)
|
||||
: null;
|
||||
return (
|
||||
<tr key={laufzeit.id}>
|
||||
<td>
|
||||
<td className="pl-karten-kopf">
|
||||
<Link
|
||||
href={`/rechnungen/${laufzeit.id}`}
|
||||
className="fw-semibold text-decoration-none"
|
||||
>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
</Link>
|
||||
{laufzeit.status === "VORSCHLAG" ? (
|
||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Proxmox-Rechnung">
|
||||
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
||||
<div>
|
||||
<EinkaufBadge
|
||||
@@ -395,8 +413,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(einkauf)}</td>
|
||||
<td>
|
||||
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
|
||||
<td data-label="Kundenrechnung">
|
||||
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
||||
<div>
|
||||
<VerkaufBadge
|
||||
@@ -405,8 +423,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(verkauf)}</td>
|
||||
<td>
|
||||
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
|
||||
<td data-label="Marge">
|
||||
{einkauf !== null && verkauf !== null
|
||||
? formatEuro(verkauf - einkauf)
|
||||
: "–"}
|
||||
@@ -443,6 +461,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
titel: "Tokens",
|
||||
icon: "bi-key-fill",
|
||||
inhalt: (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -490,6 +509,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -68,6 +68,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
const filter: HostFilter = {
|
||||
q: einzeln(roh.q),
|
||||
kunde: einzeln(roh.kunde),
|
||||
tag: einzeln(roh.tag),
|
||||
status: einzeln(roh.status),
|
||||
produkt: einzeln(roh.produkt),
|
||||
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
||||
@@ -85,6 +86,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
const basis: Record<string, string | undefined> = {
|
||||
q: filter.q,
|
||||
kunde: filter.kunde,
|
||||
tag: filter.tag,
|
||||
status: filter.status,
|
||||
produkt: filter.produkt,
|
||||
ablauf: filter.ablauf,
|
||||
@@ -117,7 +119,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
</>
|
||||
}
|
||||
>
|
||||
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
|
||||
<HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
@@ -152,7 +154,28 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
||||
{host.hostname}
|
||||
</Link>
|
||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||
{host.notizen ? (
|
||||
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
|
||||
<i
|
||||
className="bi bi-sticky ms-2 text-body-secondary"
|
||||
title={host.notizen}
|
||||
aria-label={`Notiz: ${host.notizen}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
||||
{host.tags.length > 0 ? (
|
||||
<div className="mt-1 d-flex flex-wrap gap-1">
|
||||
{host.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||
className="badge pl-bg-neutral text-decoration-none"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{host.kunde ? (
|
||||
|
||||
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
|
||||
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<div style={{ minWidth: "820px" }}>
|
||||
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
|
||||
<div className="pl-timeline">
|
||||
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
|
||||
{monate.map((monat) => (
|
||||
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
||||
{monat}
|
||||
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
|
||||
: "var(--pl-info)";
|
||||
return (
|
||||
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
||||
<div className="small text-truncate" style={{ width: "13rem" }}>
|
||||
<div className="small text-truncate pl-timeline-name">
|
||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
||||
{eintrag.hostname}
|
||||
</Link>
|
||||
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
|
||||
)}
|
||||
|
||||
<p className="small text-body-secondary mb-0">
|
||||
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
||||
<span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
|
||||
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
||||
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
||||
</p>
|
||||
</Card>
|
||||
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
|
||||
tabelle
|
||||
>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ablauf</th>
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Ablauf</th>
|
||||
<th scope="col">Kunde</th>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Proxmox</th>
|
||||
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
|
||||
<tbody>
|
||||
{naechste90Tage.map((eintrag) => (
|
||||
<tr key={eintrag.hostId}>
|
||||
<td>
|
||||
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
||||
<td className="pl-karten-kopf">
|
||||
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
|
||||
{eintrag.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Ablauf">
|
||||
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
||||
</td>
|
||||
<td data-label="Kunde">
|
||||
{eintrag.kunde ? (
|
||||
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
||||
{eintrag.kunde.name}
|
||||
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
|
||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Key">
|
||||
<CopyableCode wert={eintrag.key} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Proxmox">
|
||||
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Kundenrechnung">
|
||||
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
|
||||
}
|
||||
>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Kunde</th>
|
||||
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
|
||||
const punkte = rechnungspunkte.get(kunde.id);
|
||||
return (
|
||||
<tr key={kunde.id}>
|
||||
<td>
|
||||
<td className="pl-karten-kopf">
|
||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||
{kunde.name}
|
||||
</Link>
|
||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||
</td>
|
||||
<td className="pl-mono">{kunde.kundennummer}</td>
|
||||
<td>{kunde.hosts}</td>
|
||||
<td>{kunde.aktiv}</td>
|
||||
<td>
|
||||
<td className="pl-mono" data-label="Kundennummer">
|
||||
{kunde.kundennummer}
|
||||
</td>
|
||||
<td data-label="Hosts">{kunde.hosts}</td>
|
||||
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
|
||||
<td data-label="Nächster Ablauf">
|
||||
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Warnungen">
|
||||
{kunde.offeneAlerts > 0 ? (
|
||||
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
||||
) : (
|
||||
<Badge farbe="success">keine</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Rechnungen">
|
||||
{punkte && punkte.offen > 0 ? (
|
||||
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
|
||||
/**
|
||||
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
if (!sitzung) redirect("/login");
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
|
||||
// entsteht – und weil hier Rolle und Kundenbindung herkommen.
|
||||
const sitzung = await sitzungErforderlich();
|
||||
|
||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||
|
||||
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
}))}
|
||||
suchEintraege={suche}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<Sidebar
|
||||
hostsAnzahl={zahlen.hostsGesamt}
|
||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||
offeneWarnungen={zahlen.offeneAlerts}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<main className="app-main">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
|
||||
per Seitwärtsscrollen ins Bild (#034). */}
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Kunde</th>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col" className="d-none d-md-table-cell">
|
||||
Kunde
|
||||
</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
Key
|
||||
</th>
|
||||
<th scope="col">Ablauf</th>
|
||||
<th scope="col">Proxmox</th>
|
||||
<th scope="col">Kundenrechnung</th>
|
||||
<th scope="col" className="d-none d-sm-table-cell">
|
||||
Proxmox
|
||||
</th>
|
||||
<th scope="col" className="d-none d-sm-table-cell">
|
||||
Kundenrechnung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
|
||||
{host.hostname}
|
||||
</Link>
|
||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
|
||||
<div className="small text-body-secondary d-md-none">
|
||||
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-md-table-cell">
|
||||
{host.kunde ? (
|
||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||
{host.kunde.name}
|
||||
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-lg-table-cell">
|
||||
<CopyableCode wert={host.subKey} />
|
||||
</td>
|
||||
<td>
|
||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-sm-table-cell">
|
||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||
</td>
|
||||
<td>
|
||||
<td className="d-none d-sm-table-cell">
|
||||
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
|
||||
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
||||
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
||||
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
|
||||
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
|
||||
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||
|
||||
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
<hr />
|
||||
<dl className="row mb-0">
|
||||
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
||||
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
|
||||
<dd className="col-7">
|
||||
{formatEuro(einkaufBetrag)}
|
||||
{einkaufRabatt > 0 ? (
|
||||
<span className="small text-body-secondary d-block">
|
||||
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
||||
@@ -135,7 +145,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
) : (
|
||||
position.bezeichnung
|
||||
)}{" "}
|
||||
· {formatEuro(Number(position.nettoBetrag))}
|
||||
·{" "}
|
||||
{position.rabatt ? (
|
||||
<>
|
||||
{formatEuro(Number(position.nettoBetrag))} − {formatEuro(Number(position.rabatt))}{" "}
|
||||
= <strong>{formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))}</strong>
|
||||
</>
|
||||
) : (
|
||||
formatEuro(Number(position.nettoBetrag))
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -156,12 +174,13 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
nummer: einkauf?.nummer ?? null,
|
||||
datum: einkauf?.datum.toISOString() ?? null,
|
||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||
nettoBetrag: einkaufBetrag,
|
||||
nettoBetrag: einkaufListenpreis,
|
||||
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||
rabatt:
|
||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
||||
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||
laufzeit.proxmoxPosition?.rabatt === undefined
|
||||
? null
|
||||
: Number(einkauf.rabatt),
|
||||
: Number(laufzeit.proxmoxPosition.rabatt),
|
||||
zahlungsart: einkauf?.zahlungsart ?? null,
|
||||
bezahlt: einkauf?.bezahlt ?? false,
|
||||
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
||||
|
||||
@@ -197,14 +197,14 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
</form>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Kunde</th>
|
||||
<th scope="col" className="d-none d-md-table-cell">
|
||||
Host
|
||||
</th>
|
||||
<th scope="col" className="d-none d-lg-table-cell">
|
||||
<th scope="col" className="pl-spalte-laufzeit">
|
||||
Laufzeit
|
||||
</th>
|
||||
<th scope="col">Proxmox-Rechnung</th>
|
||||
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
|
||||
return (
|
||||
<tr key={zeile.id}>
|
||||
<td>
|
||||
{/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
|
||||
<td className="d-md-none pl-karten-kopf">
|
||||
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
|
||||
{zeile.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td data-label="Kunde">
|
||||
{zeile.kunde ? (
|
||||
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
|
||||
{zeile.kunde.name}
|
||||
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
{zeile.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="d-none d-lg-table-cell">
|
||||
<td className="pl-spalte-laufzeit" data-label="Laufzeit">
|
||||
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||
</Link>
|
||||
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Proxmox-Rechnung">
|
||||
<CopyableCode wert={zeile.einkaufNummer} />
|
||||
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
|
||||
<div className="small pl-text-danger">
|
||||
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
|
||||
<td className="text-center">
|
||||
<td className="d-none d-xl-table-cell" data-label="Einkauf">
|
||||
{formatEuro(zeile.einkaufBetrag)}
|
||||
</td>
|
||||
<td className="text-center" data-label="Einkauf bezahlt">
|
||||
<StatusHaken
|
||||
laufzeitId={zeile.id}
|
||||
hostId={zeile.hostId}
|
||||
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
titel="Proxmox-Rechnung bezahlt"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Kundenrechnung">
|
||||
<CopyableCode wert={zeile.verkaufNummer} />
|
||||
</td>
|
||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
|
||||
<td className="text-center">
|
||||
<td className="d-none d-xl-table-cell" data-label="Verkauf">
|
||||
{formatEuro(zeile.verkaufBetrag)}
|
||||
</td>
|
||||
<td className="text-center" data-label="Rechnung erstellt">
|
||||
<StatusHaken
|
||||
laufzeitId={zeile.id}
|
||||
hostId={zeile.hostId}
|
||||
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
titel="Kundenrechnung erstellt"
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<td className="text-center" data-label="Vom Kunden bezahlt">
|
||||
<StatusHaken
|
||||
laufzeitId={zeile.id}
|
||||
hostId={zeile.hostId}
|
||||
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
data-label="Marge"
|
||||
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
||||
>
|
||||
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
||||
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
})}
|
||||
{zeilen.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="text-center text-body-secondary py-4">
|
||||
<td colSpan={12} className="text-center text-body-secondary py-4">
|
||||
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -26,7 +26,12 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
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 { sitzungsdauerStunden } = await systemEinstellungen();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,19 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
|
||||
* damit sie auch per Tastatur erreichbar ist.
|
||||
*/
|
||||
.pl-menu-hintergrund {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
|
||||
padding: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------- Inhaltsbereich ---------- */
|
||||
|
||||
.app-content-header {
|
||||
@@ -369,10 +382,170 @@ body {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Zeitleiste der Jahresbahn: Breiten als Klassen, damit sie auf dem Telefon kleiner ausfallen. */
|
||||
.pl-timeline {
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
.pl-timeline-kopf {
|
||||
margin-left: 13rem;
|
||||
}
|
||||
|
||||
.pl-timeline-name {
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
|
||||
.pl-klappliste {
|
||||
width: 22rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Laufzeit-Spalte ist auf dem Telefon der einzige Weg zu den Rechnungsformularen, deshalb
|
||||
* bleibt sie dort sichtbar. Zwischen 768 und 992 px ist die Tabelle am engsten – dort entfällt sie.
|
||||
* Bewusst eine eigene Klasse statt der Bootstrap-Hilfsklassen: deren `display: … !important`
|
||||
* überschreibt das Kartenlayout (#039).
|
||||
*/
|
||||
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||
.pl-spalte-laufzeit {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Mobil ---------- */
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.pl-touch-target {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ein Kontrollkästchen darf nicht selbst 44 px hoch werden – sonst steht in der Zeile ein
|
||||
* großer leerer Kasten. Die Fläche zum Treffen bekommt stattdessen sein Umfeld (#035).
|
||||
*/
|
||||
.pl-haken {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.pl-haken .form-check-input {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Breite Tabellen auf dem Telefon: Jede Zeile wird zur Karte, jede Zelle bekommt ihre
|
||||
* Beschriftung aus `data-label` davor. Das ersetzt das Scrollen nach rechts, bei dem die
|
||||
* Hälfte der Angaben unsichtbar bleibt (#035).
|
||||
*
|
||||
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||
*/
|
||||
@media (max-width: 767.98px) {
|
||||
/*
|
||||
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
|
||||
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
|
||||
* statt am Knopf (#038).
|
||||
*/
|
||||
.pl-klappliste {
|
||||
position: fixed;
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
|
||||
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
|
||||
*/
|
||||
.pl-mono {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle,
|
||||
.pl-karten-tabelle tbody,
|
||||
.pl-karten-tabelle tr,
|
||||
.pl-karten-tabelle td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle tr {
|
||||
padding: 0.35rem 0.25rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.pl-karten-tabelle tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle td {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.75rem;
|
||||
text-align: right;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle td::before {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
content: attr(data-label);
|
||||
}
|
||||
|
||||
/* Die erste Zelle trägt den Namen – sie steht als Überschrift der Karte für sich. */
|
||||
.pl-karten-tabelle td.pl-karten-kopf {
|
||||
display: block;
|
||||
padding-top: 0.6rem;
|
||||
font-size: 1.0625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pl-karten-tabelle td.pl-karten-kopf::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Lange Namen und Schlüssel sollen umbrechen, aber nicht Buchstabe für Buchstabe. */
|
||||
.pl-karten-tabelle td a,
|
||||
.pl-karten-tabelle td .pl-mono {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Auf dem Telefon bleibt mehr Platz für die Monate, wenn die Namensspalte schrumpft. */
|
||||
.pl-timeline {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.pl-timeline-kopf {
|
||||
margin-left: 7rem;
|
||||
}
|
||||
|
||||
.pl-timeline-name {
|
||||
width: 7rem;
|
||||
}
|
||||
|
||||
/* Kartenkopf mit Knöpfen: untereinander statt nebeneinander über den Rand hinaus. */
|
||||
.card-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-header .card-tools {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
||||
|
||||
<Feld
|
||||
name="steuersatz"
|
||||
beschriftung="Steuersatz in Prozent"
|
||||
hinweis="Vorgabe für neue Rechnungspositionen."
|
||||
beschriftung="Steuersatz Verkauf in Prozent"
|
||||
hinweis="Vorgabe für neue Kundenrechnungen."
|
||||
fehler={fehler.steuersatz}
|
||||
>
|
||||
<input
|
||||
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
name="steuersatzEinkauf"
|
||||
beschriftung="Steuersatz Einkauf in Prozent"
|
||||
hinweis="Für die Proxmox-Rechnungen. 0 bei Reverse Charge – der Normalfall mit USt-IdNr."
|
||||
fehler={fehler.steuersatzEinkauf}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={100}
|
||||
className="form-control"
|
||||
id="steuersatzEinkauf"
|
||||
name="steuersatzEinkauf"
|
||||
defaultValue={werte.steuersatzEinkauf}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
name="zahlungszielTage"
|
||||
beschriftung="Zahlungsziel in Tagen"
|
||||
@@ -240,6 +258,23 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
||||
</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)"
|
||||
|
||||
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
*/
|
||||
export function HostFilterLeiste({
|
||||
kunden,
|
||||
tags,
|
||||
werte,
|
||||
}: {
|
||||
kunden: { id: string; name: string }[];
|
||||
tags: string[];
|
||||
werte: Record<string, string | undefined>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
|
||||
router.push(`${pathname}?${neu.toString()}`);
|
||||
}
|
||||
|
||||
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter(
|
||||
const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "q"].filter(
|
||||
(schluessel) => werte[schluessel],
|
||||
).length;
|
||||
|
||||
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="col-6 col-md-2">
|
||||
<label className="visually-hidden" htmlFor="tag">
|
||||
Schlagwort
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="tag"
|
||||
value={werte.tag ?? ""}
|
||||
onChange={(e) => setzen("tag", e.target.value)}
|
||||
>
|
||||
<option value="">Alle Schlagworte</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="col-6 col-md-2">
|
||||
<select
|
||||
className="form-select"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { hostKundeSetzen } from "@/server/actions/host-detail";
|
||||
|
||||
export interface KundeMitStandorten {
|
||||
id: string;
|
||||
name: string;
|
||||
standorte: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kunde und Standort eines Hosts ändern (#036).
|
||||
*
|
||||
* Beides gehört in **ein** Formular: Die Standorte hängen am Kunden, und ein Formular, das nur den
|
||||
* Kunden sendet, würde den Standort beim Speichern mitlöschen – genau das passierte vorher.
|
||||
*/
|
||||
export function KundeStandortFormular({
|
||||
hostId,
|
||||
kunden,
|
||||
kundeId,
|
||||
standortId,
|
||||
}: {
|
||||
hostId: string;
|
||||
kunden: KundeMitStandorten[];
|
||||
kundeId: string | null;
|
||||
standortId: string | null;
|
||||
}) {
|
||||
const [gewaehlterKunde, setGewaehlterKunde] = useState(kundeId ?? "");
|
||||
const standorte = kunden.find((kunde) => kunde.id === gewaehlterKunde)?.standorte ?? [];
|
||||
|
||||
return (
|
||||
<form action={hostKundeSetzen} className="mt-3">
|
||||
<input type="hidden" name="hostId" value={hostId} />
|
||||
|
||||
<label className="form-label small" htmlFor="kundeId">
|
||||
Kunde ändern
|
||||
</label>
|
||||
<select
|
||||
id="kundeId"
|
||||
name="kundeId"
|
||||
className="form-select form-select-sm mb-2"
|
||||
value={gewaehlterKunde}
|
||||
onChange={(e) => setGewaehlterKunde(e.target.value)}
|
||||
>
|
||||
<option value="">– nicht zugeordnet –</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="form-label small" htmlFor="standortId">
|
||||
Standort
|
||||
</label>
|
||||
<div className="input-group input-group-sm">
|
||||
<select
|
||||
id="standortId"
|
||||
name="standortId"
|
||||
className="form-select"
|
||||
defaultValue={standortId ?? ""}
|
||||
disabled={standorte.length === 0}
|
||||
// `key` erzwingt ein frisches Feld, wenn der Kunde wechselt – sonst bliebe die alte Auswahl stehen.
|
||||
key={gewaehlterKunde}
|
||||
>
|
||||
<option value="">– keiner –</option>
|
||||
{standorte.map((standort) => (
|
||||
<option key={standort.id} value={standort.id}>
|
||||
{standort.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn btn-outline-primary">
|
||||
Setzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gewaehlterKunde && standorte.length === 0 ? (
|
||||
<div className="form-text">
|
||||
Für diesen Kunden gibt es noch keinen Standort. Anlegen lässt er sich auf der Kundenseite.
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||
import { menuUmschalten } from "./menuZustand";
|
||||
|
||||
export interface KopfleistenAlert {
|
||||
id: string;
|
||||
@@ -33,10 +36,12 @@ export function Header({
|
||||
alerts,
|
||||
suchEintraege,
|
||||
benutzer,
|
||||
rolle,
|
||||
}: {
|
||||
alerts: KopfleistenAlert[];
|
||||
suchEintraege: SuchEintrag[];
|
||||
benutzer: string;
|
||||
rolle: Rolle;
|
||||
}) {
|
||||
const [sucheOffen, setSucheOffen] = useState(false);
|
||||
const [glockeOffen, setGlockeOffen] = useState(false);
|
||||
@@ -54,16 +59,9 @@ export function Header({
|
||||
return () => window.removeEventListener("keydown", tasten);
|
||||
}, []);
|
||||
|
||||
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
|
||||
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
|
||||
const sidebarUmschalten = useCallback(() => {
|
||||
const body = document.body;
|
||||
if (window.innerWidth < 992) {
|
||||
body.classList.toggle("sidebar-open");
|
||||
body.classList.remove("sidebar-collapse");
|
||||
} else {
|
||||
body.classList.toggle("sidebar-collapse");
|
||||
body.classList.remove("sidebar-open");
|
||||
}
|
||||
menuUmschalten();
|
||||
}, []);
|
||||
|
||||
function themaUmschalten() {
|
||||
@@ -142,8 +140,7 @@ export function Header({
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
||||
style={{ width: "22rem" }}
|
||||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||||
>
|
||||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||||
<div className="dropdown-divider" />
|
||||
@@ -208,11 +205,15 @@ export function Header({
|
||||
<i className="bi bi-person-circle" aria-hidden="true" />
|
||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||
</button>
|
||||
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
|
||||
<span className="dropdown-header">Angemeldet als {benutzer}</span>
|
||||
<div
|
||||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
|
||||
>
|
||||
<span className="dropdown-header">
|
||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||
</span>
|
||||
<div className="dropdown-divider" />
|
||||
<Link href="/einstellungen/system" className="dropdown-item">
|
||||
Einstellungen
|
||||
<Link href="/einstellungen/passkeys" className="dropdown-item">
|
||||
Meine Passkeys
|
||||
</Link>
|
||||
<a className="dropdown-item" href="/api/auth/abmelden">
|
||||
Abmelden
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { menuSchliessen, useMenuOffen } from "./menuZustand";
|
||||
|
||||
interface MenuEintrag {
|
||||
href: string;
|
||||
@@ -17,15 +20,32 @@ export function Sidebar({
|
||||
offeneRechnungspunkte,
|
||||
offeneWarnungen,
|
||||
benutzer,
|
||||
rolle,
|
||||
}: {
|
||||
hostsAnzahl: number;
|
||||
offeneRechnungspunkte: number;
|
||||
offeneWarnungen: number;
|
||||
benutzer: string;
|
||||
rolle: Rolle;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [suche, setSuche] = useState("");
|
||||
const menuOffen = useMenuOffen();
|
||||
|
||||
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
|
||||
useEffect(() => {
|
||||
menuSchliessen();
|
||||
}, [pathname]);
|
||||
|
||||
// Escape schließt, wie bei jedem anderen Overlay auch.
|
||||
useEffect(() => {
|
||||
function taste(ereignis: KeyboardEvent) {
|
||||
if (ereignis.key === "Escape") menuSchliessen();
|
||||
}
|
||||
window.addEventListener("keydown", taste);
|
||||
return () => window.removeEventListener("keydown", taste);
|
||||
}, []);
|
||||
|
||||
const haupt: MenuEintrag[] = [
|
||||
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
||||
@@ -35,7 +55,7 @@ export function Sidebar({
|
||||
icon: "bi-hdd-stack",
|
||||
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
|
||||
},
|
||||
{ href: "/kunden", titel: "Kunden", icon: "bi-people" },
|
||||
...(rolle === "KUNDE" ? [] : [{ href: "/kunden", titel: "Kunden", icon: "bi-people" }]),
|
||||
{
|
||||
href: "/rechnungen",
|
||||
titel: "Rechnungen",
|
||||
@@ -54,12 +74,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[] = [
|
||||
...(darf(rolle, "betrieb")
|
||||
? [
|
||||
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
|
||||
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
|
||||
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
|
||||
]
|
||||
: []),
|
||||
...(darf(rolle, "verwaltung")
|
||||
? [
|
||||
{ href: "/einstellungen/benutzer", titel: "Benutzer", icon: "bi-person-gear" },
|
||||
{ href: "/einstellungen/audit", titel: "Protokoll", icon: "bi-list-check" },
|
||||
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
|
||||
]
|
||||
: []),
|
||||
{ href: "/einstellungen/passkeys", titel: "Meine Passkeys", icon: "bi-fingerprint" },
|
||||
];
|
||||
|
||||
function istAktiv(href: string) {
|
||||
@@ -89,11 +121,30 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fläche hinter dem Menü: antippen schließt. Ohne sie bleibt das Menü auf dem Handy stehen. */}
|
||||
{menuOffen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pl-menu-hintergrund d-lg-none"
|
||||
aria-label="Menü schließen"
|
||||
onClick={menuSchliessen}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand d-flex align-items-center justify-content-between">
|
||||
<Link href="/" className="brand-link">
|
||||
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
|
||||
aria-label="Menü schließen"
|
||||
onClick={menuSchliessen}
|
||||
>
|
||||
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-sidebar-user">
|
||||
@@ -132,5 +183,6 @@ export function Sidebar({
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
|
||||
*
|
||||
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
|
||||
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
|
||||
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
|
||||
* Kopfzeile und Menü gemeinsam hören.
|
||||
*/
|
||||
|
||||
const SCHMAL = 992;
|
||||
|
||||
let offen = false;
|
||||
const zuhoerer = new Set<() => void>();
|
||||
|
||||
function melden() {
|
||||
for (const ruf of zuhoerer) ruf();
|
||||
}
|
||||
|
||||
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
|
||||
function klassenSetzen() {
|
||||
const body = document.body;
|
||||
body.classList.toggle("sidebar-open", offen);
|
||||
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
|
||||
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
|
||||
}
|
||||
|
||||
export function menuUmschalten() {
|
||||
if (window.innerWidth >= SCHMAL) {
|
||||
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
|
||||
document.body.classList.toggle("sidebar-collapse");
|
||||
document.body.classList.remove("sidebar-open");
|
||||
return;
|
||||
}
|
||||
|
||||
offen = !offen;
|
||||
document.body.classList.remove("sidebar-collapse");
|
||||
klassenSetzen();
|
||||
melden();
|
||||
}
|
||||
|
||||
export function menuSchliessen() {
|
||||
if (!offen) return;
|
||||
offen = false;
|
||||
klassenSetzen();
|
||||
melden();
|
||||
}
|
||||
|
||||
function abonnieren(ruf: () => void) {
|
||||
zuhoerer.add(ruf);
|
||||
return () => {
|
||||
zuhoerer.delete(ruf);
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenuOffen(): boolean {
|
||||
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
|
||||
return useSyncExternalStore(
|
||||
abonnieren,
|
||||
() => offen,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
import { formatEuro } from "@/lib/format";
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
|
||||
|
||||
// Listenpreis und Rabatt im Zustand, damit der tatsächlich gezahlte Betrag sofort danebensteht.
|
||||
const [netto, setNetto] = useState(werte.nettoBetrag === null ? "" : String(werte.nettoBetrag));
|
||||
const [rabatt, setRabatt] = useState(werte.rabatt === null ? "" : String(werte.rabatt));
|
||||
const zahl = (wert: string) => Number(wert.replace(",", ".")) || 0;
|
||||
const nettoZahl = zahl(netto);
|
||||
const rabattZahl = zahl(rabatt);
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
|
||||
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="px-netto">
|
||||
Netto (€) *
|
||||
Listenpreis netto (€) *
|
||||
</label>
|
||||
<input
|
||||
id="px-netto"
|
||||
name="nettoBetrag"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.nettoBetrag ?? ""}
|
||||
value={netto}
|
||||
onChange={(e) => setNetto(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label" htmlFor="px-ust">
|
||||
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
|
||||
name="rabatt"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.rabatt ?? ""}
|
||||
value={rabatt}
|
||||
onChange={(e) => setRabatt(e.target.value)}
|
||||
/>
|
||||
<div className="form-text">
|
||||
{rabattZahl > 0 ? (
|
||||
<>
|
||||
Gerechnet wird mit <strong>{formatEuro(nettoZahl - rabattZahl)}</strong>.
|
||||
</>
|
||||
) : (
|
||||
"Proxmox weist ihn je Key als eigene Zeile aus („Client Discount“)."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="px-zahlungsart">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function StatusHaken({
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="d-inline-flex align-items-center justify-content-center">
|
||||
<span className="pl-haken d-inline-flex align-items-center justify-content-center">
|
||||
{laeuft ? (
|
||||
<span
|
||||
className="spinner-border spinner-border-sm text-body-secondary"
|
||||
@@ -47,7 +47,7 @@ export function StatusHaken({
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input pl-touch-target"
|
||||
className="form-check-input"
|
||||
checked={wert}
|
||||
disabled={deaktiviert}
|
||||
onChange={(e) => umschalten(e.target.checked)}
|
||||
|
||||
@@ -9,31 +9,34 @@
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Lausch-Adressen taugen nicht als Ziel für einen Browser: `0.0.0.0` heißt „alle Schnittstellen“.
|
||||
* Steht so etwas in der Konfiguration, wird es übergangen statt eine unerreichbare Seite zu bauen.
|
||||
*/
|
||||
const UNBRAUCHBAR = new Set(["0.0.0.0", "::", "[::]", "", "0"]);
|
||||
|
||||
if (process.env.APP_URL) {
|
||||
function brauchbar(adresse: string | undefined): URL | null {
|
||||
if (!adresse) return null;
|
||||
try {
|
||||
return new URL(process.env.APP_URL);
|
||||
const ergebnis = new URL(adresse);
|
||||
return UNBRAUCHBAR.has(ergebnis.hostname) ? null : ergebnis;
|
||||
} catch {
|
||||
// ebenso
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
|
||||
const ausEinstellung = brauchbar(eingetragen);
|
||||
if (ausEinstellung) return ausEinstellung;
|
||||
|
||||
const ausUmgebung = brauchbar(process.env.APP_URL);
|
||||
if (ausUmgebung) return ausUmgebung;
|
||||
|
||||
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
|
||||
}
|
||||
const ausKopfzeile = brauchbar(`${schema}://${gastgeber}`);
|
||||
if (ausKopfzeile) return ausKopfzeile;
|
||||
}
|
||||
|
||||
return new URL(request.url);
|
||||
@@ -43,3 +46,19 @@ export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
|
||||
export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL {
|
||||
return new URL(pfad, oeffentlicheBasis(request, eingetragen));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dasselbe für Server Actions und Seiten, die keine `Request` zur Hand haben.
|
||||
*
|
||||
* Nötig, weil Next.js eine Weiterleitung aus einer Server Action serverseitig absolut macht – und
|
||||
* dafür die Adresse nimmt, auf die der Server lauscht. Im Container ist das `0.0.0.0` (oder der
|
||||
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
|
||||
*/
|
||||
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
|
||||
const { headers } = await import("next/headers");
|
||||
const kopfzeilen = await headers();
|
||||
|
||||
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
|
||||
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
|
||||
return new URL(pfad, basis).toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Mindestlänge für Passwörter: lieber lang als kompliziert. */
|
||||
export const PASSWORT_MINDESTLAENGE = 12;
|
||||
@@ -19,7 +19,7 @@ export interface Sitzung {
|
||||
email: string;
|
||||
name: string;
|
||||
/** Woher die Anmeldung stammt – für das Audit-Log und die Anzeige. */
|
||||
herkunft: "passwort" | "oidc";
|
||||
herkunft: "passwort" | "oidc" | "passkey";
|
||||
}
|
||||
|
||||
function schluessel(): Uint8Array {
|
||||
@@ -55,7 +55,8 @@ export async function sitzungLesen(token: string | undefined): Promise<GeleseneS
|
||||
benutzerId: payload.benutzerId,
|
||||
email: 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 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { alertsAbgleichen } from "@/server/alerts/abgleich";
|
||||
import { REGELN } from "@/server/alerts/regeln";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
@@ -16,18 +16,26 @@ function neuLaden() {
|
||||
|
||||
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
|
||||
export async function alertQuittieren(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("mitarbeiten");
|
||||
const id = formular.get("id")?.toString();
|
||||
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, {});
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
|
||||
export async function alertSchliessen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -38,7 +46,7 @@ export async function alertSchliessen(formular: FormData) {
|
||||
|
||||
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
|
||||
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const ergebnis = await alertsAbgleichen();
|
||||
neuLaden();
|
||||
return {
|
||||
@@ -51,7 +59,7 @@ export async function warnregelnSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
for (const regel of REGELN) {
|
||||
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
|
||||
const schwellwerte: Record<string, number | number[]> = {};
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eigenerPfad } from "@/lib/adresse";
|
||||
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
|
||||
export async function abmelden() {
|
||||
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||
await protokolliere("abmeldung", "Benutzer", null, {});
|
||||
redirect("/login");
|
||||
|
||||
const { dashboardUrl } = await einstellungen();
|
||||
redirect(await eigenerPfad("/login", dashboardUrl));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function emailKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const host = formular.get("host")?.toString().trim() ?? "";
|
||||
const port = Number(formular.get("port") ?? 587);
|
||||
@@ -80,7 +80,7 @@ export async function webhookKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const url = formular.get("url")?.toString().trim() ?? "";
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function webhookKanalSpeichern(
|
||||
}
|
||||
|
||||
export async function kanalLoeschen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
|
||||
|
||||
/** Verschickt eine Testnachricht über einen Kanal. */
|
||||
export async function testSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
|
||||
|
||||
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
|
||||
export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
||||
|
||||
await prisma.einstellung.upsert({
|
||||
@@ -160,7 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
|
||||
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
||||
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("betrieb");
|
||||
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
||||
await zusammenfassungSenden(zeitraum);
|
||||
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");
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function einstellungenSpeichernAktion(
|
||||
_zustand: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("verwaltung");
|
||||
|
||||
const vorher = await einstellungen();
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
|
||||
|
||||
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
||||
steuersatz: zahl(formular, "steuersatz"),
|
||||
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
|
||||
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
||||
margeProzent: zahl(formular, "margeProzent"),
|
||||
|
||||
@@ -42,6 +43,7 @@ export async function einstellungenSpeichernAktion(
|
||||
anmeldeversuche: zahl(formular, "anmeldeversuche"),
|
||||
oidcDomaenen: text(formular, "oidcDomaenen"),
|
||||
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
|
||||
oidcStandardRolle: text(formular, "oidcStandardRolle") || vorher.oidcStandardRolle,
|
||||
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
|
||||
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { sichtbarerKunde, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Speichert Notizen und Tags eines Hosts. */
|
||||
@@ -11,10 +11,20 @@ export async function hostNotizenSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("mitarbeiten");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return { fehler: "Host nicht gefunden." };
|
||||
|
||||
// Kundenkonten dürfen Notizen pflegen – aber nur an den eigenen Hosts.
|
||||
const nurKunde = await sichtbarerKunde();
|
||||
if (nurKunde) {
|
||||
const eigener = await prisma.host.findFirst({
|
||||
where: { id: hostId, kundeId: nurKunde },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!eigener) return { fehler: "Dieser Host gehört nicht zu Ihrem Zugang." };
|
||||
}
|
||||
|
||||
const notizen = formular.get("notizen")?.toString().trim() || null;
|
||||
const tags = (formular.get("tags")?.toString() ?? "")
|
||||
.split(",")
|
||||
@@ -41,14 +51,30 @@ export async function hostNotizenSpeichern(
|
||||
|
||||
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
|
||||
export async function hostKundeSetzen(formular: FormData) {
|
||||
await sitzungErforderlich();
|
||||
await sitzungErforderlich("fachlichSchreiben");
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
if (!hostId) return;
|
||||
|
||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
|
||||
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
|
||||
// Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
|
||||
// gewechselt hat –, wird der Standort verworfen statt eine falsche Zuordnung zu speichern.
|
||||
const gueltigerStandort =
|
||||
standortId && kundeId
|
||||
? ((await prisma.standort.findFirst({
|
||||
where: { id: standortId, kundeId },
|
||||
select: { id: true },
|
||||
})) ?? null)
|
||||
: null;
|
||||
|
||||
await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { kundeId, standortId: gueltigerStandort?.id ?? null },
|
||||
});
|
||||
await protokolliere("host.zugewiesen", "Host", hostId, {
|
||||
kundeId,
|
||||
standortId: gueltigerStandort?.id ?? null,
|
||||
});
|
||||
|
||||
if (kundeId) {
|
||||
await prisma.alert.updateMany({
|
||||
|
||||