Compare commits

..
36 Commits
Author SHA1 Message Date
florian.wilde f86a328b3c Merge branch 'phase/040-tags-sichtbar'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:43:55 +02:00
florian.wildeandClaude Opus 5 b2664de62f feat(hosts): Schlagworte und Notizen sichtbar machen (#040)
Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:43:55 +02:00
florian.wilde dfb2118bac Merge branch 'phase/039-laufzeit-zugang'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:08:54 +02:00
florian.wildeandClaude Opus 5 335fc47496 fix(ui): Zugang zur Laufzeit auf dem Telefon (#039)
Im Reiter Rechnungen der Host-Detailansicht war die Laufzeit nie verlinkt,
und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar. Auf dem
Telefon fuehrte damit kein Weg zu den Rechnungsformularen.

Die Laufzeit ist jetzt ein Link und zugleich Ueberschrift der Karte; in der
Liste bleibt sie auf dem Telefon sichtbar und entfaellt nur zwischen 768 und
992 px. Dafuer braucht es eine eigene Klasse: Bootstraps d-*-Hilfsklassen
setzen display mit !important und ueberschreiben das Kartenlayout.

Nebenbefund: Auch dieser Reiter rechnete den Einkauf ohne Rabatt - dritte
Stelle nach #037 und #038.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:54 +02:00
florian.wilde e570d30019 Merge branch 'phase/038-klapplisten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:01:43 +02:00
florian.wildeandClaude Opus 5 c5293f5915 fix(ui): Ueberbreite auf dem Handy beseitigen (#038)
Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf,
die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit
einer neuen Aufnahme in Telefonbreite (--mobil).

Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite
rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge
und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:01:43 +02:00
florian.wilde c1177067e6 Merge branch 'phase/037-rabatt-position'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:42:12 +02:00
florian.wildeandClaude Opus 5 e50fb30ab3 feat(rechnungen): Rabatt je Position, Reverse Charge, Ablauf-Regel (#037)
Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit
Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an
die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit
keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde
nirgends abgezogen.

Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe
nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten
Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen
und rechnet die Summen neu - nichts geht verloren.

Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet
gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab.

Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die
Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:42:12 +02:00
florian.wilde 97f6450f1f Merge branch 'phase/035-mobil-tabellen'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:20:20 +02:00
florian.wildeandClaude Opus 5 ca2af0c2fc feat(ui): Tabellen als Karten auf dem Handy, Standort am Host (#035, #036)
Breite Tabellen (Kunden, Rechnungen, Ablauf-Kalender) werden unterhalb von
768 px zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts
abgeschnitten zu sein. Kartenkoepfe brechen um, die Jahresbahn bekommt eine
schmalere Namensspalte und einen Hinweis aufs Wischen.

Standort am Host (#036): Das Formular sendete nur den Kunden, die Aktion las
aber auch standortId - jede Kundenzuweisung hat den Standort still geloescht.
Jetzt stehen Kunde und Standort in einem Formular, die Liste richtet sich nach
dem Kunden, und die Aktion prueft, dass der Standort zum Kunden gehoert.

Nebenbefund: Das Kontrollkaestchen fuer den Rechnungsstatus trug
pl-touch-target und wurde dadurch selbst zum 44 Pixel hohen leeren Kasten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:20:20 +02:00
florian.wilde 9c873a4069 Merge branch 'phase/034-mobil'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:55:59 +02:00
florian.wildeandClaude Opus 5 09bb98235b fix(ui): Seitenmenue auf dem Handy schliessbar machen (#034)
Das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht
einbinden. Die Klasse sidebar-open liess sich damit setzen, aber nichts nahm
sie wieder weg: keine Flaeche zum Antippen, kein Schliessen beim Navigieren,
kein Escape. Auf dem Telefon blieb das Menue ueber dem Inhalt stehen.

Jetzt gibt es einen eigenen Zustand, auf den Kopfzeile und Menue gemeinsam
hoeren, eine abdunkelnde Flaeche dahinter, einen Schliessen-Knopf im Menuekopf
und das Schliessen beim Navigieren. Der Inhalt dahinter scrollt nicht mehr mit.

Ausserdem zeigt die Dashboard-Tabelle auf dem Telefon nur Host und Ablauf, den
Kunden klein darunter - vorher war rechts die Haelfte abgeschnitten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:55:59 +02:00
florian.wilde 4bcf3daedd Merge branch 'phase/033-adresse-haerten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:35:19 +02:00
florian.wildeandClaude Opus 5 94d5edbc6c fix(adresse): Weiterleitungen nie aus der Lausch-Adresse bauen (#033, #032)
Abmelden fuehrte im Docker-Betrieb auf 0.0.0.0:3000 - eine Adresse, die kein
Browser aufrufen kann. Ursache war hausgemacht: ENV HOSTNAME=0.0.0.0 aus
#031 reparierte die Zustandspruefung und lenkte zugleich alle Weiterleitungen
aus Server Actions dorthin, weil Next sie serverseitig absolut macht. Vorher
stand dort der Containername - derselbe Fehler, nur unauffaelliger.

Neuer Helfer eigenerPfad() baut die Adresse aus Einstellung, APP_URL oder den
Kopfzeilen der Anfrage. Betroffen waren Abmelden, die Sitzungspruefung und das
Loeschen eines Kunden. Zusaetzlich uebergeht oeffentlicheBasis() eine als
Lausch-Adresse eingetragene APP_URL, statt sie zu uebernehmen.

Gegen einen Docker-Stapel geprueft: Mit APP_URL=http://0.0.0.0:3000 leitet die
Anwendung jetzt auf den tatsaechlich aufgerufenen Namen.

Dazu #032: Das ct-Skript setzt die Herkunft der App-Skripte selbst, und die
Speichervorgabe steht auf 4 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:19 +02:00
florian.wilde 92210e9099 Merge branch 'phase/032-ct-basis'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:01:58 +02:00
florian.wildeandClaude Opus 5 6dfa85381a fix(lxc): Herkunft der Skripte im ct-Skript festhalten
Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln.
Das Geruest suchte install/proxledger-install.sh deshalb bei den
community-scripts auf GitHub und brach mit 404 ab, nachdem der Container
bereits angelegt war. Jetzt setzt das Skript COMMUNITY_SCRIPTS_URL selbst;
fuer Forks und andere Zweige bleibt es ueberschreibbar.

Ausserdem die Vorgabe fuer den Arbeitsspeicher auf 4 GB angehoben: next build
braucht waehrend der Installation deutlich mehr als 2 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:01:58 +02:00
florian.wildeandClaude Opus 5 11e514dd25 feat(deploy): Update und Sicherung fuer beide Installationsarten (#031)
Prüfung / pruefung (push) Canceled after 0s
deploy/update.sh und deploy/backup.sh konnten nur Docker. Beide erkennen die
Installationsart jetzt selbst und beherrschen auch die Installation als
Dienst: npm ci, prisma migrate deploy, bauen, systemctl restart - und
pg_dump ueber DATABASE_URL fuer die Sicherung.

Beim Durchspielen gegen einen laufenden Stapel zwei Fehler gefunden:

1. Die Zustandspruefung 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. Der
   Dienst web blieb dauerhaft auf "health: starting". Behoben mit
   ENV HOSTNAME=0.0.0.0; geprueft wird jetzt ueber 127.0.0.1.
2. Eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf. Jetzt
   bricht sie ab, bevor etwas veraendert wird, und nennt die Auswege.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 07:26:30 +02:00
florian.wilde 9f1eb189db Merge branch 'phase/031-aktualisierung'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 00:25:03 +02:00
florian.wildeandClaude Opus 5 585173622d fix(deps): Next.js auf 16.3.8 wegen kritischer Meldung
Fuer Next.js 16.2.0 bis 16.3.5 wurde eine als kritisch eingestufte Luecke in
next/og gemeldet (RCE, GHSA-vcvr-r3jv-pc5j). ProxLedger nutzt next/og nicht
und war damit nicht angreifbar; vor einem Produktivgang gehoert die Fassung
trotzdem aktualisiert.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:35 +02:00
florian.wilde 94f1eb4e6f Merge branch 'phase/023-feldtest-pbs'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:27:02 +02:00
florian.wildeandClaude Opus 5 1bfd549838 fix(agent): PBS-Ausgaben richtig auswerten (#023)
Feldtest auf einem echten Proxmox Backup Server 4.2: `proxmox-backup-manager
subscription get` gibt eine Rahmentabelle aus, keine "feld: wert"-Zeilen. Der
Agent hat dort bisher nichts gelesen - nicht einmal die Server-ID. PBS kann
(anders als PVE) `--output-format json`; das wird jetzt genutzt, die Tabelle
dient als Rueckfall. Die Testfaelle stammen ab sofort vom echten Host, der
bisherige PBS-Testfall war erfunden und hat den Fehler verdeckt.

Ausserdem: die PBS-Version enthielt die laufende Fassung doppelt, und die
Versionspruefung meldete "neue Version" auch bei aelteren Fassungen.

Installation, Report, Warnungen und Uninstall wurden auf dem Host vollstaendig
durchgespielt - damit ist auch der letzte offene Punkt aus #018 erledigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:27:02 +02:00
florian.wildeandClaude Opus 5 52466eee3f docs(feldtest): Feldtest fuer PBS und PMG als #023 festhalten
Prüfung / pruefung (push) Canceled after 0s
PBS-Hosts stehen spaeter zur Verfuegung, allerdings nur unlizenziert; fuer
PMG gibt es derzeit keinen Host. Der heutige PBS-Testfall im Testlauf ist
erfunden (JSON) und gehoert gegen echte Ausgaben geprueft - bei PVE war
genau diese Annahme falsch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:45:36 +02:00
157 changed files with 7142 additions and 492 deletions
+5 -1
View File
@@ -24,7 +24,8 @@ OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET="" OIDC_CLIENT_SECRET=""
OIDC_NAME="authentik" OIDC_NAME="authentik"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails).
# Lässt sich später unter Einstellungen → System überschreiben.
APP_URL="http://localhost:3000" APP_URL="http://localhost:3000"
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde) # Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
@@ -33,6 +34,9 @@ WORKER_CRON="0 * * * *"
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr) # Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
ZUSAMMENFASSUNG_CRON="0 7 * * *" ZUSAMMENFASSUNG_CRON="0 7 * * *"
# Zeitplan fürs Aufräumen alter Reports und Protokolleinträge (Fristen: Einstellungen → System)
AUFRAEUM_CRON="30 3 * * *"
# Ablage für hochgeladene Rechnungs-PDFs # Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads" UPLOAD_DIR="./data/uploads"
+161
View File
@@ -17,6 +17,167 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### 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
- `deploy/update.sh`: sichert, baut neu, migriert und prüft, ob das Dashboard wieder antwortet (#026)
- Installationsskripte nach Art der Proxmox VE Helper-Scripts unter `deploy/community-scripts/` – Container mit Node und PostgreSQL, ohne Docker (#026)
- `docs/roadmap.md`: was bis 1.0.0 fehlt und was danach lohnt (#026)
- Zustandsprüfung für den Worker im Docker-Stack (#026)
### Behoben
- Nach der Anmeldung führte die Weiterleitung im Container auf den internen Containernamen (`http://<container-id>:3000`) statt auf die erreichbare Adresse – hinter einem Reverse Proxy landete man damit im Nichts. Beim Prüfen des Docker-Stapels aufgefallen (#026)
### Geändert
- README auf den tatsächlichen Stand gebracht: Der Feldtest ist erledigt, der Hinweis „noch nicht erprobt“ war überholt (#026)
## [0.16.0] – 2026-09-18
### Hinzugefügt
- *Einstellungen → System*: Dashboard-Adresse, Melderhythmus und Selbstaktualisierung der Agents, kaufmännische Vorgaben, Sicherheitsgrenzen, Firmenangaben und Zeitzone (#025)
- Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Installations-Einzeiler und im systemd-Timer (#025)
- OIDC lässt sich auf bestimmte E-Mail-Domänen begrenzen; das automatische Anlegen unbekannter Konten ist abschaltbar (#025)
- Nächtliches Aufräumen nach einstellbaren Aufbewahrungsfristen für Reports und Protokoll – der jüngste Report jedes Hosts bleibt erhalten (#025)
- Firmenangaben erscheinen im Kopf der PDF-Ausgaben (#025)
### Geändert
- Steuersatz, Zahlungsziel und Aufschlag kommen aus den Einstellungen statt aus fest eingetragenen 19 % (#025)
## [0.15.0] – 2026-09-18
### Hinzugefügt
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
### Behoben
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
## [0.14.1] – 2026-09-18
### Behoben
- Auf Proxmox Backup Server las der Agent **keine einzige Angabe** aus: `proxmox-backup-manager subscription get` gibt eine Rahmentabelle aus, keine `feld: wert`-Zeilen. Jetzt wird für PBS `--output-format json` genutzt, und die Tabelle wird als Rückfall ausgewertet (#023)
- Die PBS-Version enthielt die laufende Fassung ein zweites Mal (#023)
- Der Agent meldete eine „neue Version“, auch wenn die installierte neuer war als die des Dashboards (#023)
### Geändert
- Agent-Version 0.2.1; die Tests prüfen die Versionsangabe jetzt gegen die Konstante des Dashboards statt gegen eine abgeschriebene Zahl (#023)
## [0.14.0] – 2026-09-18 ## [0.14.0] – 2026-09-18
### Hinzugefügt ### Hinzugefügt
+78 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 9 h 08 min **Gesamt:** 21 h 43 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -28,6 +28,24 @@
| #021 Geheimnisse verschlüsseln | 0 h 20 min | | #021 Geheimnisse verschlüsseln | 0 h 20 min |
| #018 Feldtest auf echten Hosts | 0 h 40 min | | #018 Feldtest auf echten Hosts | 0 h 40 min |
| #022 Cluster-Abgleich | 0 h 25 min | | #022 Cluster-Abgleich | 0 h 25 min |
| #023 Feldtest PBS | 0 h 35 min |
| #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 | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -50,7 +68,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.14.0 **Version:** 0.23.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -133,6 +151,63 @@
- #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent - #022 abgeschlossen: Cluster-Abgleich – jeder Agent meldet die Mitglieder, das Dashboard warnt bei Knoten ohne Agent
- **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind. - **Fehler im eigenen Werkzeug gefunden:** Alle Handbuch-Bilder waren in Wahrheit die Anmeldeseite. Firefox verwirft eine selbst angelegte Cookie-Datei stillschweigend. Jetzt läuft ein lokaler Vermittler mit, und das Skript bricht ab, wenn alle Aufnahmen gleich groß sind.
- Feldtest auf einem echten Proxmox Backup Server (#023): der Agent hätte dort **nichts** gelesen – PBS gibt eine Rahmentabelle aus, kein `feld: wert`. Behoben, mit den echten Ausgaben als Testfälle
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
- Einstellungen im Dashboard (#025): vier Bereiche, alle Werte wirken auch wirklich – der Melderhythmus zum Beispiel bis in den erzeugten systemd-Timer hinein, live geprüft
- Dabei die OIDC-Lücke aus #024 geschlossen: Domänenliste und Schalter gegen automatisch angelegte Konten
- Bewusst nicht gebaut: „stumm nach X Tagen“ (steht schon bei den Warnregeln) und die Währung in der Browseransicht
- Auslieferung finalisiert (#026): Docker-Weg mit Update-Skript, dazu Skripte nach Art der Helper-Scripts für einen LXC ohne Docker
- Recherche dazu: Das Gerüst der community-scripts liegt inzwischen in einem eigenen Repository, und neue Skripte gehören zuerst nach ProxmoxVED. Eine Einreichung setzt öffentlichen Quelltext voraus – deshalb vorerst intern
- Roadmap geschrieben: Bis 1.0.0 fehlt kein Code mehr, sondern Erprobung (PMG, PBS mit Lizenz, ein Monat Dauerbetrieb, einmal eine Sicherung zurückspielen)
- Rollen und Benutzerverwaltung (#027): Die Einschränkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben. Sechs Tests prüfen, dass ein Kundenkonto weder fremde Hosts noch Einkaufspreise sieht
- Dabei aufgefallen: Neu angelegte OIDC-Konten hätten die Modellvorgabe ADMIN geerbt – behoben, mit eigener Einstellung und Test
- Passkeys (#028): passwortlos, mehrere je Konto, Aufgabe in einem kurzlebigen Cookie. Nur über HTTPS nutzbar – die Seite sagt es, statt still zu scheitern
- Einrichtungsdokumentation (#029): Beim Aufschreiben des OIDC-Wegs fielen zwei Fehler auf, die nur hinter einem Reverse Proxy auftreten – Rückkehradresse und Code-Einlösung nahmen die interne Adresse. Beides behoben
- Erkenntnis: Eine Anleitung Schritt für Schritt aufzuschreiben findet Fehler, die kein Test findet, weil sie nur in einer bestimmten Betriebsform auftreten
- netbird Reverse Proxy (#030) in die Anleitung aufgenommen – Florians Hinweis, dass die Beta gut läuft. Beim Durchgehen der Möglichkeiten fiel der Fallstrick auf: Eine Anmeldehürde vor dem Dienst trifft auch `/api/agent` und legt alle Agents still
### 03.10.2026 – Prüfung vor dem Einsatz
- `npm audit` vor der Freigabe erneut laufen lassen: In den zwei Wochen seit dem letzten Stand kam eine kritische Meldung zu Next.js dazu (RCE in `next/og`). Wir nutzen `next/og` nicht, waren also nicht angreifbar – angehoben auf 16.3.8, Tests und Build grün
- Lehre für den Betrieb: Vor jedem Produktivgang und danach regelmäßig `npm audit` prüfen, nicht nur bei Änderungen am Code
- Update- und Sicherungsskript für beide Installationsarten (#031). Beim Durchspielen zwei Fehler gefunden: Die Zustandsprüfung des Web-Containers konnte nie gelingen (Next bindet an `HOSTNAME`, Docker setzt dort den Containernamen), und eine fehlgeschlagene Sicherung hielt die Aktualisierung nicht auf
- Lehre: Ein Skript, das nie unter echten Bedingungen gelaufen ist, ist eine Behauptung. Erst der Durchlauf gegen einen laufenden Stapel hat beide Fehler gezeigt
- 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 ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlen noch: Feldtest auf PBS und PMG, Uninstall auf einem echten Host - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+3
View File
@@ -24,6 +24,9 @@ RUN npx prisma generate \
FROM basis AS laufzeit FROM basis AS laufzeit
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
# Ohne diese Zeile nimmt Next den von Docker gesetzten HOSTNAME als Bindeadresse und lauscht
# nur auf der Container-IP. Die Zustandsprüfung über 127.0.0.1 schlägt dann immer fehl.
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger RUN addgroup -g 1001 -S proxledger && adduser -S proxledger -u 1001 -G proxledger
+50 -10
View File
@@ -3,15 +3,17 @@
Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway). Zentrales Dashboard für den Subscription-Status aller Proxmox-Hosts (VE, Backup Server, Mail Gateway).
Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT. Auf jedem Host meldet ein schlanker Agent täglich per HTTPS seinen Status (Push-Modell) – auch aus Kundennetzen hinter NAT.
> **Status:** in Entwicklung. Der Funktionsumfang steht, der Agent wurde aber noch nicht auf echten > **Status:** einsatzbereit. Der Agent ist auf echten Hosts erprobt – Proxmox VE 9.2 mit und ohne
> Proxmox-Hosts erprobt (#018). > Subscription, im Cluster und einzeln, sowie Proxmox Backup Server 4.2 (#018, #023).
> Ungeprüft: Mail Gateway und eine aktive Subscription auf PBS. Was bis 1.0 fehlt, steht in
> [docs/roadmap.md](docs/roadmap.md).
## Schnellstart ## Schnellstart
Auf einem Rechner mit Docker: Auf einem Rechner mit Docker:
```bash ```bash
git clone <dieses-repo> proxledger && cd proxledger git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env cp .env.example .env
# Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL # Pflichtangaben setzen: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d docker compose up -d
@@ -20,14 +22,27 @@ docker compose up -d
Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`. Beim ersten Start legt der Dienst `migrate` das Schema an und erzeugt den Administrator aus der `.env`.
Danach ist das Dashboard unter `APP_URL` erreichbar. Danach ist das Dashboard unter `APP_URL` erreichbar.
**Auf einem Proxmox-Host** legt der Installer einen fertigen LXC-Container an: Aktualisieren geht mit einem Befehl – er sichert vorher, baut neu, migriert und prüft zum Schluss,
ob das Dashboard wieder antwortet:
```bash ```bash
bash deploy/lxc-installer.sh bash deploy/update.sh
``` ```
Er fragt Ressourcen und Netzwerk ab, installiert Docker, holt den Quelltext, erzeugt Zufallspasswörter **Auf einem Proxmox-Host** gibt es zwei Wege zu einem fertigen LXC-Container:
und startet alles. Am Ende stehen Adresse, Benutzer und Passwort auf dem Bildschirm.
```bash
bash deploy/lxc-installer.sh # eigener Installer: Container mit Docker darin
```
```bash
# Nach Art der Proxmox VE Helper-Scripts: Container mit Node und PostgreSQL direkt darin
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Der zweite Weg folgt den Konventionen von [community-scripts.org](https://community-scripts.org) und
braucht kein Docker; Einzelheiten und die Voraussetzungen für eine Einreichung stehen in
[deploy/community-scripts/README.md](deploy/community-scripts/README.md).
### Agent ausrollen ### Agent ausrollen
@@ -105,13 +120,38 @@ Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslie
sie sich mit eigenen Tokens ausweisen. sie sich mit eigenen Tokens ausweisen.
- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche - **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche
je Kennung und Viertelstunde, danach Sperre. je Kennung und Viertelstunde, danach Sperre (einstellbar unter *Einstellungen → System*).
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse Benutzer sperren oder Passwort setzen: `npm run benutzer -- sperren <email>`.
- **Passkey:** passwortlos über WebAuthn, mehrere je Konto, Verwaltung unter *Meine Passkeys*. Setzt HTTPS voraus (außer `localhost`).
- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen – Schritt für Schritt in [docs/betrieb/authentik.md](docs/betrieb/authentik.md). Rückkehradresse
ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt ist `<APP_URL>/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt
absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. Unter *Einstellungen → System* lässt sich
einschränken, welche E-Mail-Domänen zugelassen sind und ob unbekannte Konten angelegt werden dürfen.
- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle - **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle
Sitzungen ungültig. Sitzungen ungültig.
## Einrichtung im Betrieb
| Anleitung | Inhalt |
|---|---|
| [Inbetriebnahme](docs/betrieb/inbetriebnahme.md) | von null zum meldenden Host, in der richtigen Reihenfolge |
| [Reverse Proxy](docs/betrieb/reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
| [authentik](docs/betrieb/authentik.md) | Anmeldung über OIDC, samt Fehlerbildern |
| [Sicherung](docs/betrieb/sicherung.md) | was gesichert wird – und wie man es zurückspielt |
## Einstellungen
Die meisten Betriebswerte stehen unter *Einstellungen → System* und wirken sofort: Dashboard-Adresse,
Melderhythmus und Selbstaktualisierung der Agents, Steuersatz, Zahlungsziel und Aufschlag,
Sitzungsdauer und Anmeldeversuche, OIDC-Beschränkungen, Aufbewahrungsfristen sowie Firmenangaben und
Zeitzone für die PDF-Ausgaben.
In der Umgebung bleiben nur `APP_SECRET` und `DATABASE_URL` – beide werden gebraucht, bevor die
Anwendung die Datenbank lesen kann.
Wie die Anwendung gegen die OWASP Top 10 dasteht (einschließlich der Punkte, die bewusst offen
bleiben), steht in [docs/sicherheit.md](docs/sicherheit.md).
## Exporte ## Exporte
| Ansicht | CSV | PDF | | Ansicht | CSV | PDF |
+15 -7
View File
@@ -16,9 +16,17 @@ set -o nounset
set -o pipefail set -o pipefail
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}" DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
# Melderhythmus und Streuung setzt das Dashboard beim Ausliefern ein (Einstellungen → System).
# Wird das Skript unverändert aus dem Quelltext ausgeführt, bleiben die Platzhalter stehen;
# dann gelten die Vorgaben.
TIMER_ONCALENDAR="@@TIMER_ONCALENDAR@@"
TIMER_STREUUNG="@@TIMER_STREUUNG@@"
case "$TIMER_ONCALENDAR" in *@@*) TIMER_ONCALENDAR="daily" ;; esac
case "$TIMER_STREUUNG" in *@@*) TIMER_STREUUNG="4h" ;; esac
TOKEN="" TOKEN=""
CA_CERT="" CA_CERT=""
AUTO_UPDATE="no" AUTO_UPDATE="@@AUTO_UPDATE@@"
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
AKTION="install" AKTION="install"
CONFIG_VERZEICHNIS="/etc/proxledger" CONFIG_VERZEICHNIS="/etc/proxledger"
@@ -202,14 +210,14 @@ ProtectSystem=full
ProtectHome=true ProtectHome=true
UNIT UNIT
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT' cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
[Unit] [Unit]
Description=Täglicher ProxLedger-Report Description=ProxLedger-Report
[Timer] [Timer]
OnCalendar=daily OnCalendar=${TIMER_ONCALENDAR}
# Verteilt die Meldungen aller Hosts über vier Stunden. # Verteilt die Meldungen aller Hosts über diesen Zeitraum.
RandomizedDelaySec=4h RandomizedDelaySec=${TIMER_STREUUNG}
Persistent=true Persistent=true
Unit=proxledger.service Unit=proxledger.service
@@ -219,7 +227,7 @@ UNIT
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now proxledger.timer >/dev/null systemctl enable --now proxledger.timer >/dev/null
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)." erfolg "systemd-Timer aktiviert (${TIMER_ONCALENDAR}, zufällig um bis zu ${TIMER_STREUUNG} versetzt)."
hinweis "Sende ersten Report …" hinweis "Sende ersten Report …"
if "$AGENT_PFAD" --report; then if "$AGENT_PFAD" --report; then
+32 -5
View File
@@ -12,7 +12,7 @@
set -o nounset set -o nounset
set -o pipefail set -o pipefail
AGENT_VERSION="0.2.0" AGENT_VERSION="0.2.1"
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}" CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}" SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
SPOOL_MAXIMUM=30 SPOOL_MAXIMUM=30
@@ -106,15 +106,24 @@ produkt_erkennen() {
} }
subscription_ausgabe() { subscription_ausgabe() {
local ausgabe=""
case "$1" in case "$1" in
# PVE kennt --output-format nicht (geprueft auf 9.2), liefert aber "feld: wert".
pve) pvesubscription get 2>/dev/null ;; pve) pvesubscription get 2>/dev/null ;;
pbs) proxmox-backup-manager subscription get 2>/dev/null ;; # PBS gibt ohne --output-format eine Rahmentabelle aus, aus der sich schlecht lesen laesst.
# JSON kann es (geprueft auf 4.2); aeltere Fassungen faengt die Tabellenauswertung ab.
pbs)
ausgabe=$(proxmox-backup-manager subscription get --output-format json 2>/dev/null)
[ -n "$ausgabe" ] || ausgabe=$(proxmox-backup-manager subscription get 2>/dev/null)
printf '%s' "$ausgabe"
;;
pmg) pmgsubscription get 2>/dev/null ;; pmg) pmgsubscription get 2>/dev/null ;;
*) printf '' ;; *) printf '' ;;
esac esac
} }
# Liest ein Feld aus der Subscription-Ausgabe. Unterstützt "feld: wert" und JSON. # Liest ein Feld aus der Subscription-Ausgabe. Unterstuetzt "feld: wert", JSON und die
# Rahmentabelle von PBS ("| feld | wert |" mit Unicode-Strichen).
feld_lesen() { feld_lesen() {
local feld="$1" text="$2" wert local feld="$1" text="$2" wert
wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1) wert=$(printf '%s\n' "$text" | sed -n "s/^[[:space:]]*${feld}[[:space:]]*:[[:space:]]*//p" | head -n1)
@@ -122,6 +131,15 @@ feld_lesen() {
wert=$(printf '%s\n' "$text" | wert=$(printf '%s\n' "$text" |
sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1) sed -n "s/.*\"${feld}\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -n1)
fi fi
if [ -z "$wert" ]; then
# Tabellenzeile: Trennzeichen ist U+2502; awk zerlegt daran und liefert die zweite Spalte.
wert=$(printf '%s\n' "$text" |
awk -F'\xe2\x94\x82' -v feld="$feld" '
NF >= 3 {
name = $2; gsub(/^[ \t]+|[ \t]+$/, "", name)
if (name == feld) { wert = $3; gsub(/^[ \t]+|[ \t]+$/, "", wert); print wert; exit }
}')
fi
# Umschließende Anführungszeichen und Leerzeichen entfernen. # Umschließende Anführungszeichen und Leerzeichen entfernen.
wert=${wert%\"} wert=${wert%\"}
wert=${wert#\"} wert=${wert#\"}
@@ -133,7 +151,9 @@ produkt_version() {
# Der Kernel wird separat gemeldet, deshalb hier abschneiden. # Der Kernel wird separat gemeldet, deshalb hier abschneiden.
case "$1" in case "$1" in
pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;; pve) pveversion 2>/dev/null | head -n1 | sed 's/ (running kernel.*//' ;;
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 ;; # PBS haengt die laufende Fassung an ("… 4.2.6-1 running version: 4.2.1");
# gemeldet wird die installierte.
pbs) proxmox-backup-manager version 2>/dev/null | head -n1 | sed 's/ running version.*//' ;;
pmg) pmgversion 2>/dev/null | head -n1 ;; pmg) pmgversion 2>/dev/null | head -n1 ;;
*) printf '' ;; *) printf '' ;;
esac esac
@@ -395,8 +415,15 @@ aktion_test() {
[ "$(printf '%s' "$antwort" | tail -n1)" = "200" ] [ "$(printf '%s' "$antwort" | tail -n1)" = "200" ]
} }
# Ist $1 eine neuere Fassung als $2? `sort -V` vergleicht Versionsnummern der Reihe nach.
version_neuer() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}
version_pruefen() { version_pruefen() {
if [ -n "${NEUESTE_VERSION:-}" ] && [ "$NEUESTE_VERSION" != "$AGENT_VERSION" ]; then # Nur melden, wenn das Dashboard wirklich eine neuere Fassung kennt. Sonst warnt ein Agent,
# der einer Testinstallation vorauseilt, gegen sich selbst (im Feldtest #023 aufgefallen).
if [ -n "${NEUESTE_VERSION:-}" ] && version_neuer "$NEUESTE_VERSION" "$AGENT_VERSION"; then
protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})." protokoll notice "Neue Agent-Version verfügbar: ${NEUESTE_VERSION} (installiert: ${AGENT_VERSION})."
if [ "$AUTO_UPDATE" = "yes" ]; then if [ "$AUTO_UPDATE" = "yes" ]; then
aktion_update aktion_update
+41 -7
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Sichert Datenbank und hochgeladene PDFs von ProxLedger. # Sichert Datenbank, hochgeladene PDFs und die Konfiguration von ProxLedger.
# Erkennt selbst, ob die Installation mit Docker oder als Dienst im Container läuft.
# #
# ./backup.sh /pfad/zum/sicherungsordner # ./backup.sh /pfad/zum/sicherungsordner
# #
@@ -13,17 +14,50 @@ set -o pipefail
ZIEL="${1:-/var/backups/proxledger}" ZIEL="${1:-/var/backups/proxledger}"
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}" AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-30}"
STAND=$(date +%Y%m%d-%H%M%S) STAND=$(date +%Y%m%d-%H%M%S)
COMPOSE_VERZEICHNIS="${COMPOSE_VERZEICHNIS:-/opt/proxledger}" VERZEICHNIS="${COMPOSE_VERZEICHNIS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
mkdir -p "$ZIEL" mkdir -p "$ZIEL"
cd "$COMPOSE_VERZEICHNIS" cd "$VERZEICHNIS"
echo "Sichere Datenbank …" # Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \ if systemctl cat proxledger.service >/dev/null 2>&1; then
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" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …" echo "Sichere hochgeladene Dateien …"
docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true docker compose run --rm -T --entrypoint tar web -czf - /data/uploads > "${ZIEL}/uploads-${STAND}.tar.gz" 2>/dev/null || true
else
# DATABASE_URL steht in der .env; pg_dump versteht sie direkt.
# shellcheck disable=SC1091
set -o allexport
. ./.env
set +o allexport
[ -n "${DATABASE_URL:-}" ] || {
echo "DATABASE_URL fehlt in der .env – ohne sie keine Sicherung." >&2
exit 1
}
echo "Sichere Datenbank …"
pg_dump "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
if [ -d "$UPLOADS" ]; then
tar -czf "${ZIEL}/uploads-${STAND}.tar.gz" -C "$(dirname "$UPLOADS")" "$(basename "$UPLOADS")"
else
echo " (kein Upload-Verzeichnis unter ${UPLOADS})"
fi
fi
echo "Sichere Konfiguration …" echo "Sichere Konfiguration …"
cp .env "${ZIEL}/env-${STAND}.txt" cp .env "${ZIEL}/env-${STAND}.txt"
+85
View File
@@ -0,0 +1,85 @@
# Installation als LXC-Container nach Art der Proxmox VE Helper-Scripts
Diese beiden Skripte folgen den Konventionen von [community-scripts.org](https://community-scripts.org)
(der Nachfolge von tteck's Helper-Scripts). Sie legen einen unprivilegierten Debian-13-Container an,
installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Repository, richten zwei
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
`/opt/proxledger/.credentials`.
**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`) |
| `install/proxledger-install.sh` | Installation im Container |
## Verwendung im eigenen Netz
Auf dem Proxmox-VE-Host als root:
```bash
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Das Gerüst (`build.func`) 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:
```bash
PROXLEDGER_REPO=https://git.example.de/fw/ProxLedger.git PROXLEDGER_BRANCH=main bash -c "$(curl -fsSL …)"
```
Ist das Git-Repository nicht ohne Anmeldung lesbar, gehört ein Lesezugriffs-Token in die Adresse
(`https://<token>@git.example.de/...`). Das Token landet dann in der Prozessliste des Containers –
also besser einen Deploy-Token verwenden, der nur lesen darf.
## Was eine Einreichung bei community-scripts verlangen würde
Wir liefern die Skripte vorerst **nur intern** aus. Für eine Einreichung wären zusätzlich nötig:
1. **Öffentlich erreichbarer Quelltext.** Die Skripte dort holen die Anwendung praktisch immer aus
einem öffentlichen GitHub-Repository (`fetch_and_deploy_gh_release`). Ein privates Gitea genügt nicht.
2. **Pull Request gegen [ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)**, nicht gegen
ProxmoxVE. Neue Skripte, die direkt gegen ProxmoxVE eingereicht werden, werden ohne Prüfung
geschlossen.
3. **MIT-Lizenz** im eigenen Repository und der Lizenzkopf in beiden Skripten (steht bereits darin).
4. **Englische Dokumentation** – die Skripte selbst sind auf Englisch gehalten, die deutschen
Kommentare in diesem Verzeichnis müssten übersetzt werden.
5. **Metadaten für die Webseite.** Die JSON-Dateien liegen inzwischen nicht mehr im Skript-Repository;
wo sie hingehören, steht in der aktuellen Beitragsanleitung. Das ist beim Einreichen zu prüfen.
Die Skripte sind so geschrieben, dass für eine spätere Einreichung nur die Quelle zu tauschen und die
Kommentare zu übersetzen sind – am Ablauf ändert sich nichts.
## Unterschied zum Docker-Weg
| | Helper-Script (LXC) | Docker Compose |
|---|---|---|
| Umgebung | eigener Container auf dem Proxmox-Host | beliebiger Docker-Host |
| Datenbank | PostgreSQL im selben Container | eigener Dienst |
| Aktualisierung | `update`-Weg des Skripts | `bash deploy/update.sh` |
| Sicherung | Proxmox-Sicherung des Containers | `bash deploy/backup.sh` |
Beide Wege sind gleichwertig. Wer ohnehin Proxmox betreibt, fährt mit dem LXC-Weg meist einfacher –
die Sicherung des Containers erledigt dann die gewohnte Proxmox-Sicherung mit.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# 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/florian.wilde/ProxLedger
APP="ProxLedger"
var_tags="${var_tags:-monitoring;licensing}"
var_cpu="${var_cpu:-2}"
# 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}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/proxledger ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
NODE_VERSION="24" setup_nodejs
msg_info "Stopping Services"
systemctl stop proxledger proxledger-worker
msg_ok "Stopped Services"
# Die .env traegt APP_SECRET; geht sie verloren, sind Sitzungen und gespeicherte
# Zugangsdaten unbrauchbar. Deshalb vor jedem Update sichern und danach zurueckspielen.
create_backup /opt/proxledger/.env
msg_info "Updating ${APP}"
cd /opt/proxledger
$STD git pull --ff-only
$STD npm ci
$STD npx prisma generate
$STD npx prisma migrate deploy
$STD npm run build
$STD npm run build:worker
msg_ok "Updated ${APP}"
restore_backup
msg_info "Starting Services"
systemctl start proxledger proxledger-worker
msg_ok "Started Services"
msg_ok "Update Successful"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
echo -e "${INFO}${YW} Login credentials are in:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}/opt/proxledger/.credentials${CL}"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# Author: Florian Wilde
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://git.wilde-server.de/florian.wilde/ProxLedger
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
# Die Quelle laesst sich ueberschreiben, solange das Repository nicht oeffentlich ist.
QUELLE="${PROXLEDGER_REPO:-https://git.wilde-server.de/florian.wilde/ProxLedger.git}"
ZWEIG="${PROXLEDGER_BRANCH:-main}"
msg_info "Installing Dependencies"
$STD apt install -y git ca-certificates
msg_ok "Installed Dependencies"
NODE_VERSION="24" setup_nodejs
PG_VERSION="17" setup_postgresql
msg_info "Setting up Database"
DB_NAME="proxledger"
DB_USER="proxledger"
DB_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 20)
# APP_SECRET signiert die Sitzungen und verschluesselt gespeicherte Zugangsdaten.
APP_SECRET=$(openssl rand -base64 48)
$STD sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
$STD sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} WITH OWNER ${DB_USER} ENCODING 'UTF8';"
msg_ok "Set up Database"
msg_info "Setting up ProxLedger"
$STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger
cd /opt/proxledger
# 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}"
APP_URL="http://${IP_ADRESSE}:3000"
ADMIN_EMAIL="admin@proxledger.local"
ADMIN_NAME="Administrator"
ADMIN_PASSWORD="${ADMIN_PASS}"
UPLOAD_DIR="/opt/proxledger/data/uploads"
NODE_ENV="production"
PORT="3000"
EOF
chmod 600 /opt/proxledger/.env
mkdir -p /opt/proxledger/data/uploads
$STD npm ci
$STD npx prisma generate
$STD npx prisma migrate deploy
$STD npx prisma db seed
$STD npm run build
$STD npm run build:worker
msg_ok "Set up ProxLedger"
msg_info "Creating Services"
cat <<EOF >/etc/systemd/system/proxledger.service
[Unit]
Description=ProxLedger Dashboard
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/proxledger
EnvironmentFile=/opt/proxledger/.env
ExecStart=/usr/bin/npm run start
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF >/etc/systemd/system/proxledger-worker.service
[Unit]
Description=ProxLedger Worker (Warnregeln und Benachrichtigungen)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/proxledger
EnvironmentFile=/opt/proxledger/.env
ExecStart=/usr/bin/node dist/worker.mjs
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl enable -q --now proxledger proxledger-worker
msg_ok "Created Services"
# Die Zugangsdaten liegen fuer den Betreiber lesbar im Container; APP_SECRET steht in der .env.
{
echo "ProxLedger"
echo "Adresse: http://${IP_ADRESSE}:3000"
echo " (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
echo "Datenbank: ${DB_NAME} / ${DB_USER} / ${DB_PASS}"
echo
echo "Wichtig: APP_SECRET aus /opt/proxledger/.env mitsichern."
echo "Ohne diesen Wert sind Sitzungen und gespeicherte Zugangsdaten unbrauchbar."
} >>~/proxledger.creds
cp ~/proxledger.creds /opt/proxledger/.credentials
chmod 600 /opt/proxledger/.credentials
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
msg_ok "Cleaned"
+2 -1
View File
@@ -181,4 +181,5 @@ printf ' 3. Für den Betrieb über HTTPS einen Reverse Proxy davorsetzen (Beisp
echo echo
gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht." gelb "APP_URL in /opt/proxledger/.env auf die endgültige Adresse setzen, sobald der Reverse Proxy steht."
printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID" printf ' Logs ansehen: pct exec %s -- docker compose -f /opt/proxledger/docker-compose.yml logs -f\n' "$CTID"
printf ' Aktualisieren: pct exec %s -- bash -c "cd /opt/proxledger && git pull && docker compose up -d --build"\n' "$CTID" printf ' Aktualisieren: pct exec %s -- bash -c "bash /opt/proxledger/deploy/update.sh"\n' "$CTID"
printf ' Sichern: pct exec %s -- bash -c "bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger"\n' "$CTID"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# ProxLedger aktualisieren – erkennt selbst, ob die Installation mit Docker
# oder als Dienst im Container (Node + PostgreSQL direkt) läuft.
#
# bash deploy/update.sh # sichern, holen, bauen, migrieren, neu starten
# bash deploy/update.sh --ohne-sicherung
#
# Vor dem Aktualisieren wird gesichert – das dauert Sekunden und erspart schlechte Abende.
set -o errexit
set -o nounset
set -o pipefail
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$VERZEICHNIS"
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
SICHERN="ja"
[ "${1:-}" = "--ohne-sicherung" ] && SICHERN="nein"
# Wohin gesichert wird. Der Standardort gehört root; wer anders arbeitet, setzt SICHERUNG_ZIEL.
SICHERUNG_ZIEL="${SICHERUNG_ZIEL:-/var/backups/proxledger}"
# ---------------------------------------------------------------- Art der Installation
#
# Der systemd-Dienst ist das eindeutigere Merkmal: Die docker-compose.yml liegt auch im
# Quelltext der Dienst-Installation, ein Dienst namens proxledger aber nur dort.
installationsart() {
if systemctl cat proxledger.service >/dev/null 2>&1; then
printf 'dienst'
elif command -v docker >/dev/null 2>&1 && [ -f docker-compose.yml ]; then
printf 'docker'
fi
}
ART=$(installationsart)
[ -n "$ART" ] || fehler "Keine Installation gefunden – weder der Dienst proxledger noch Docker mit docker-compose.yml."
blau "Installationsart: ${ART}"
# ---------------------------------------------------------------- Sicherung
if [ "$SICHERN" = "ja" ]; then
blau "Sicherung anlegen nach ${SICHERUNG_ZIEL} …"
if ! bash deploy/backup.sh "$SICHERUNG_ZIEL"; then
fehler "Die Sicherung ist fehlgeschlagen – deshalb wird nicht aktualisiert.
Häufigste Ursache: fehlende Rechte am Zielordner. Mögliche Wege:
sudo bash deploy/update.sh
SICHERUNG_ZIEL=/pfad/mit/rechten bash deploy/update.sh
bash deploy/update.sh --ohne-sicherung (nur wenn anderweitig gesichert ist)"
fi
gruen "Sicherung abgeschlossen."
else
printf '\033[1;33m! Ohne Sicherung – auf eigenes Risiko.\033[0m\n'
fi
# ---------------------------------------------------------------- Quelltext
if [ -d .git ]; then
blau "Neuen Stand holen …"
git pull --ff-only
else
printf '\033[1;33m! Kein Git-Verzeichnis – der Quelltext bleibt wie er ist.\033[0m\n'
fi
# ---------------------------------------------------------------- Aktualisieren
if [ "$ART" = "docker" ]; then
[ -f .env ] || fehler "Keine .env gefunden – wird dieses Skript im richtigen Verzeichnis ausgeführt?"
blau "Abbilder neu bauen …"
docker compose build --pull
blau "Datenbank migrieren …"
docker compose run --rm migrate
blau "Dienste neu starten …"
docker compose up -d
else
command -v npm >/dev/null 2>&1 || fehler "npm nicht gefunden – gehört zur Installation als Dienst."
[ -f .env ] || fehler "Keine .env gefunden (erwartet in ${VERZEICHNIS})."
# Die .env trägt APP_SECRET; ein git pull fasst sie nicht an, aber sicher ist sicher.
cp .env "/tmp/proxledger-env-$(date +%s).sicherung"
blau "Abhängigkeiten installieren …"
npm ci
blau "Datenbank migrieren …"
npx prisma generate
npx prisma migrate deploy
blau "Anwendung bauen …"
npm run build
npm run build:worker
blau "Dienste neu starten …"
systemctl restart proxledger proxledger-worker
fi
# ---------------------------------------------------------------- Prüfen
blau "Warte auf das Dashboard …"
PORT="${PORT:-3000}"
for versuch in $(seq 1 45); do
if [ "$ART" = "docker" ]; then
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
else
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
fi
if [ "$erreichbar" = "ja" ]; then
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
[ "$ART" = "docker" ] && docker compose ps
[ "$ART" = "dienst" ] && systemctl --no-pager --lines=0 status proxledger proxledger-worker | grep -E "●|Active:"
exit 0
fi
sleep 1
done
if [ "$ART" = "docker" ]; then
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
else
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: journalctl -u proxledger -n 50"
fi
+9 -1
View File
@@ -60,7 +60,7 @@ services:
volumes: volumes:
- uploads:/data - uploads:/data
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -81,8 +81,16 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
WORKER_CRON: ${WORKER_CRON:-0 * * * *} WORKER_CRON: ${WORKER_CRON:-0 * * * *}
ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *} ZUSAMMENFASSUNG_CRON: ${ZUSAMMENFASSUNG_CRON:-0 7 * * *}
AUFRAEUM_CRON: ${AUFRAEUM_CRON:-30 3 * * *}
volumes: volumes:
- uploads:/data - uploads:/data
healthcheck:
# Der Worker spricht kein HTTP; geprüft wird schlicht, ob der Prozess noch da ist.
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
interval: 60s
timeout: 5s
retries: 3
start_period: 20s
volumes: volumes:
datenbank: datenbank:
+152 -12
View File
@@ -17,11 +17,16 @@ auch mit Hosts hinter NAT.
7. [Warnungen](#7-warnungen) 7. [Warnungen](#7-warnungen)
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten) 8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
9. [Exporte](#9-exporte) 9. [Exporte](#9-exporte)
9a. [Einstellungen](#9a-einstellungen)
9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys)
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung) 10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
11. [Fehlersuche](#11-fehlersuche) 11. [Fehlersuche](#11-fehlersuche)
--- ---
> **Einrichtung statt Bedienung?** Reverse Proxy, OIDC und Sicherung stehen in
> [docs/betrieb/](../betrieb/README.md). Dieses Handbuch beschreibt die tägliche Arbeit.
## 1. Installation ## 1. Installation
### Mit Docker ### Mit Docker
@@ -71,10 +76,16 @@ Traefik liegen in [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
![Anmeldeseite](bilder/login.png) ![Anmeldeseite](bilder/login.png)
Anmelden mit der E-Mail-Adresse und dem Passwort aus der `.env`. Ist ein Anmeldedienst über OIDC Es gibt drei gleichwertige Wege hinein:
eingerichtet (`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`), erscheint zusätzlich ein Knopf
„Mit authentik anmelden“. Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst - **Passkey** – der Knopf ganz oben. Der Browser fragt nach Fingerabdruck, Gesicht oder PIN. Anlegen
einmal ausfällt, kommst du trotzdem hinein. lässt sich einer unter *Meine Passkeys* ([Kapitel 9b](#9b-benutzer-rollen-und-passkeys)).
- **Passwort** – E-Mail-Adresse und Passwort aus der `.env` bzw. aus der Benutzerverwaltung.
- **OIDC** – ist ein Anmeldedienst eingerichtet, erscheint zusätzlich „Mit authentik anmelden“
(Einrichtung: [docs/betrieb/authentik.md](../betrieb/authentik.md)).
Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst einmal ausfällt, kommst du
trotzdem hinein.
Nach der Anmeldung landest du auf dem Dashboard: Nach der Anmeldung landest du auf dem Dashboard:
@@ -113,6 +124,12 @@ Der Installer erkennt selbst, ob es sich um Proxmox VE, Backup Server oder Mail
registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab, registriert den Host, legt die Konfiguration unter `/etc/proxledger/agent.conf` (Rechte 600) ab,
richtet einen systemd-Timer ein und schickt sofort den ersten Report. richtet einen systemd-Timer ein und schickt sofort den ersten Report.
> **Stand der Erprobung:** Proxmox VE (9.2, mit und ohne Subscription, einzeln und im Cluster) und
> Proxmox Backup Server (4.2, ohne Subscription) sind auf echten Hosts geprüft. Für **Mail Gateway**
> stand bisher kein Host zur Verfügung – dort beruht die Auswertung noch auf Annahmen, und bei PBS hat
> sich genau so eine Annahme als falsch erwiesen. Bitte den ersten PMG-Host nach der Installation
> kurz gegenprüfen (`proxledger-agent --dry-run` zeigt, was gemeldet würde).
Nützliche Befehle auf dem Host: Nützliche Befehle auf dem Host:
| Befehl | Zweck | | Befehl | Zweck |
@@ -158,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 ## 4a. Cluster
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei **In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
@@ -172,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 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. 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 ## 5. Kunden und Standorte pflegen
@@ -231,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 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. 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 ## 7. Warnungen
@@ -304,36 +354,121 @@ Jahresliste für die Buchhaltung.
--- ---
## 9a. Einstellungen
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
![Systemeinstellungen](bilder/system.png)
**Adresse und Agent.** Die Dashboard-Adresse landet im Installations-Einzeiler und in den
Benachrichtigungen. Bleibt das Feld leer, gilt `APP_URL` aus der Umgebung. Melderhythmus, Streuung und
Selbstaktualisierung gelten für **neu installierte** Agents – bestehende übernehmen sie erst, wenn der
Einzeiler dort erneut läuft.
**Kaufmännisches.** Steuersatz, Zahlungsziel und Aufschlag füllen leere Rechnungsfelder vor. Der
Aufschlag rechnet aus dem Einkaufspreis einen Vorschlag für den Verkaufspreis; eingetragene Beträge
bleiben unberührt.
**Sicherheit.** Sitzungsdauer und Anmeldeversuche gelten ab der nächsten Anmeldung. Die beiden
OIDC-Felder sind wichtig, wenn ein Identitätsanbieter viele Konten kennt: Ohne Einschränkung darf
jeder, der sich dort anmelden kann, hier mitarbeiten. Mit einer Domänenliste oder abgeschaltetem
„Konten anlegen“ kommt nur herein, wer hier vorgesehen ist. Die Aufbewahrungsfristen räumen nachts um
halb vier auf; der jüngste Report jedes Hosts bleibt immer erhalten, sonst stünde ein lange stummer
Host plötzlich ohne Angaben da.
**Firma und Darstellung.** Name, Anschrift und E-Mail stehen im Kopf der erzeugten PDF-Dateien, die
Zeitzone gilt für PDF, CSV und Benachrichtigungen.
> `APP_SECRET` und `DATABASE_URL` stehen bewusst **nicht** hier: Beide werden gebraucht, bevor die
> Anwendung die Datenbank überhaupt lesen kann.
---
## 9b. Benutzer, Rollen und Passkeys
Unter *Einstellungen → Benutzer* legen Administratoren Konten an und vergeben eine von drei Rollen:
![Benutzerverwaltung](bilder/benutzer.png)
| Rolle | Darf |
|---|---|
| **Administrator** | alles, einschließlich Benutzer und Systemeinstellungen |
| **Techniker** | alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer |
| **Kunde** | nur die Hosts und Rechnungen des eigenen Kunden, **ohne Einkaufspreise und Marge**; darf Notizen schreiben und Warnungen quittieren |
Ein Kundenkonto ist an genau einen Kunden gebunden – das Feld erscheint, sobald die Rolle „Kunde“
gewählt ist. Die Einschränkung sitzt in den Abfragen selbst, nicht in der Anzeige: Was ein Kundenkonto
nicht sehen darf, steht auch nicht im ausgelieferten HTML.
Ein Passwort ist beim Anlegen freiwillig. Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder
einen Passkey anmeldet. **Ein neues Passwort beendet alle laufenden Sitzungen dieses Kontos**, ebenso
das Sperren.
### Passkeys
Ein Passkey ersetzt das Passwort: Der Browser bestätigt die Anmeldung mit Fingerabdruck, Gesicht, PIN
oder einem Sicherheitsschlüssel. Jeder Benutzer verwaltet seine eigenen unter *Meine Passkeys*:
![Passkeys](bilder/passkeys.png)
Zwei Dinge sind wichtig:
1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://`
im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt.
2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf
`https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy
einrichten, dann Passkeys anlegen.
Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als
Rückweg immer nutzbar.
---
## 10. Sicherung und Aktualisierung ## 10. Sicherung und Aktualisierung
**Sichern:** **Sichern:**
```bash ```bash
./deploy/backup.sh /var/backups/proxledger bash /opt/proxledger/deploy/backup.sh /var/backups/proxledger
``` ```
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env`. Für regelmäßige Sicherungen in die Das Skript sichert Datenbank, hochgeladene PDFs und die `.env` – und erkennt selbst, ob die
Crontab eintragen: Installation mit Docker oder als Dienst im Container läuft. Für regelmäßige Sicherungen in die Crontab:
``` ```
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger 30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
``` ```
**Zurückspielen:** **Zurückspielen** (Docker):
```bash ```bash
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
``` ```
Bei der Installation als Dienst stattdessen:
```bash
gunzip -c datenbank-20260917-023000.sql.gz | psql "$DATABASE_URL"
```
**Aktualisieren:** **Aktualisieren:**
```bash ```bash
cd /opt/proxledger bash /opt/proxledger/deploy/update.sh
git pull
docker compose up -d --build
``` ```
Migrationen laufen dabei automatisch. Ein Befehl für beide Installationsarten. Der Reihe nach: sichern, neuen Stand holen, bauen,
migrieren, neu starten – und zum Schluss prüfen, ob das Dashboard wieder antwortet. Schlägt die
Sicherung fehl, bricht das Skript ab, **bevor** etwas verändert wird.
| Aufruf | Wirkung |
|---|---|
| `bash deploy/update.sh` | der übliche Weg |
| `SICHERUNG_ZIEL=/pfad bash deploy/update.sh` | anderer Ort für die Sicherung |
| `bash deploy/update.sh --ohne-sicherung` | nur, wenn anderweitig gesichert ist |
Beim LXC-Weg nach Art der Helper-Scripts geht es auch über das Skript auf dem Proxmox-Host – dort
erkennt das Gerüst eine vorhandene Installation und bietet die Aktualisierung an.
> `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine > `APP_SECRET` unbedingt mitsichern: Ohne diesen Wert sind alle Sitzungen ungültig. Und beachte, dass eine
> Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt. > Sicherung die Zugangsdaten für SMTP und Webhook im Klartext enthält – sie gehört entsprechend geschützt.
@@ -371,6 +506,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
## Anhang: dunkles Design und Mobilansicht ## 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 Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
Kopfleiste umschalten. Kopfleiste umschalten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 252 KiB

+13
View File
@@ -0,0 +1,13 @@
# Betrieb
Anleitungen für alles, was rund um ProxLedger eingerichtet wird.
| Dokument | Inhalt |
|---|---|
| [inbetriebnahme.md](inbetriebnahme.md) | Von null zum laufenden Dashboard – Docker oder LXC, erster Agent, erste Zuordnung |
| [reverse-proxy.md](reverse-proxy.md) | HTTPS mit nginx Proxy Manager, netbird Reverse Proxy (Beta) oder netbird pur |
| [authentik.md](authentik.md) | Anmeldung über OIDC |
| [sicherung.md](sicherung.md) | Was gesichert werden muss und wie man es zurückspielt |
Für die tägliche Arbeit **mit** ProxLedger ist das [Handbuch](../anleitung/README.md) gedacht.
Was gegen Angriffe getan wird (und was bewusst nicht), steht in [sicherheit.md](../sicherheit.md).
+118
View File
@@ -0,0 +1,118 @@
# Anmeldung über authentik (OIDC)
ProxLedger kann die Anmeldung an einen OIDC-Anbieter abgeben. Getestet ist authentik; mit jedem
anderen Anbieter, der OpenID Connect Discovery beherrscht, funktioniert es genauso.
**Der lokale Zugang bleibt daneben immer bestehen.** Das ist Absicht: Fällt authentik aus, kommt man
trotzdem noch ins Dashboard.
---
## 1. In authentik: Provider anlegen
*Applications → Providers → Create → OAuth2/OpenID Provider*
| Feld | Wert |
|---|---|
| Name | `ProxLedger` |
| Authorization flow | der übliche (z. B. *default-provider-authorization-implicit-consent*) |
| Client type | **Confidential** |
| Client ID | übernehmen, wird gleich gebraucht |
| Client Secret | übernehmen, wird gleich gebraucht |
| Redirect URIs | `https://proxledger.firma.de/api/auth/oidc/callback` |
| Scopes | `openid`, `email`, `profile` |
| Signing Key | ein vorhandenes Zertifikat auswählen |
Zur Rückkehradresse zwei Hinweise, an denen die meisten Einrichtungen scheitern:
- Sie muss **wortgleich** übereinstimmen – einschließlich `https`, Portangabe und ohne Schrägstrich am
Ende. ProxLedger bildet sie aus der Dashboard-Adresse unter *Einstellungen → System*, ersatzweise aus
`APP_URL`.
- authentik erlaubt auch reguläre Ausdrücke. Für eine einzelne Installation ist die genaue Adresse die
bessere Wahl; ein zu weiter Ausdruck öffnet eine Umleitung auf fremde Ziele.
## 2. In authentik: Application anlegen
*Applications → Applications → Create*
| Feld | Wert |
|---|---|
| Name | `ProxLedger` |
| Slug | `proxledger` |
| Provider | der eben angelegte |
Der Slug bestimmt die Discovery-Adresse:
```
https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration
```
Diese Adresse **ohne** den Teil `/.well-known/openid-configuration` ist der Aussteller
(`OIDC_ISSUER`) – also `https://authentik.firma.de/application/o/proxledger/`.
Prüfen lässt sie sich mit:
```bash
curl -fsS https://authentik.firma.de/application/o/proxledger/.well-known/openid-configuration | head
```
Wer darf sich anmelden, entscheidet in authentik die **Policy-Bindung** an der Application
(*Applications → ProxLedger → Policy / Group / User Bindings*). Ohne Bindung darf jeder, der bei
authentik ein Konto hat.
## 3. In ProxLedger: Werte eintragen
In die `.env` (Docker) oder `/opt/proxledger/.env` (LXC):
```bash
OIDC_ISSUER="https://authentik.firma.de/application/o/proxledger/"
OIDC_CLIENT_ID="…"
OIDC_CLIENT_SECRET="…"
OIDC_NAME="authentik" # Beschriftung des Knopfes auf der Anmeldeseite
APP_URL="https://proxledger.firma.de"
```
Danach neu starten:
```bash
docker compose up -d # Docker
systemctl restart proxledger # LXC
```
Auf der Anmeldeseite erscheint jetzt **Mit authentik anmelden**.
## 4. Rollen festlegen
ProxLedger liest **keine** Gruppen aus authentik. Wer sich zum ersten Mal anmeldet, bekommt die Rolle
aus *Einstellungen → System → Rolle für neue OIDC-Konten* – Vorgabe ist **Techniker**. Die endgültige
Rolle vergibt ein Administrator unter *Einstellungen → Benutzer*.
Das ist bewusst so: Eine Gruppenzuordnung, die stillschweigend Administratorrechte verteilt, ist
schwerer zu überblicken als eine Liste, in der jeder Zugang sichtbar ist.
Zwei Einstellungen helfen dabei, den Kreis klein zu halten:
- **Erlaubte E-Mail-Domänen** – etwa `firma.de`. Alles andere wird abgewiesen, auch wenn die Anmeldung
bei authentik geklappt hat.
- **Unbekannte OIDC-Anmeldungen legen ein Konto an** – abgeschaltet kommt nur herein, wer hier bereits
ein Konto hat. Dann legt man Konten vorab an (ohne Passwort) und die Anmeldung läuft trotzdem über
authentik.
## 5. Prüfen
1. Abmelden, dann **Mit authentik anmelden**.
2. Unter *Einstellungen → Protokoll* steht `anmeldung.erfolgreich` mit `weg: oidc`.
3. Unter *Einstellungen → Benutzer* steht das Konto mit der vergebenen Rolle und der Anmeldeart `OIDC`.
## Wenn es nicht klappt
| Meldung | Ursache |
|---|---|
| „Die Anmeldung über den Anbieter hat nicht geklappt.“ | Sammelmeldung – die Einzelheiten stehen im Protokoll des Containers (`docker compose logs web`). |
| authentik zeigt *Redirect URI Error* | Die Adresse stimmt nicht wortgleich. Häufig: `http` statt `https`, Schrägstrich am Ende, oder die interne statt der öffentlichen Adresse. |
| `invalid_client` | Client-ID oder -Secret falsch, oder der Provider steht auf *Public* statt *Confidential*. |
| Anmeldung klappt, aber ProxLedger meldet „keine E-Mail-Adresse“ | Der Scope `email` fehlt am Provider. |
| Nach der Anmeldung landet man auf einer unerreichbaren Adresse | `APP_URL` bzw. die Dashboard-Adresse zeigt nicht auf die öffentliche Adresse. |
Siehe auch: [Reverse Proxy](reverse-proxy.md) – ohne HTTPS gibt es weder sinnvolle OIDC-Anmeldung noch
Passkeys.
+81
View File
@@ -0,0 +1,81 @@
# Inbetriebnahme
Der kürzeste Weg von null zu einem Dashboard, das Hosts meldet. Rechnen Sie mit einer halben Stunde.
## Reihenfolge
Die Reihenfolge ist nicht beliebig – zwei Schritte hängen an der endgültigen Adresse:
1. Dashboard installieren
2. **Adresse festlegen** (Reverse Proxy, DNS)
3. OIDC einrichten (falls gewünscht)
4. Passkeys anlegen
5. Agents ausrollen
Wer Schritt 4 oder 5 vorzieht, macht sie nach einem Adresswechsel noch einmal: Passkeys gelten nur für
die Domäne, unter der sie entstanden sind, und die Adresse steckt im Installations-Einzeiler.
## 1. Installieren
**Docker** (beliebiger Host mit Docker):
```bash
git clone https://git.wilde-server.de/florian.wilde/ProxLedger.git proxledger && cd proxledger
cp .env.example .env
# Pflicht: APP_SECRET, POSTGRES_PASSWORD, ADMIN_PASSWORD, APP_URL
docker compose up -d
```
**LXC auf Proxmox**, zwei Möglichkeiten:
```bash
bash deploy/lxc-installer.sh # Container mit Docker darin
```
```bash
# Nach Art der Proxmox VE Helper-Scripts, ohne Docker:
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Der erste Administrator entsteht beim Einrichten aus `ADMIN_EMAIL` und `ADMIN_PASSWORD`; beim
LXC-Weg stehen die erzeugten Zugangsdaten in `/opt/proxledger/.credentials`.
**`APP_SECRET` gehört sofort in die Sicherung.** Er signiert die Sitzungen und verschlüsselt die
gespeicherten Zugangsdaten für SMTP und Webhooks. Ohne ihn sind beide unbrauchbar.
## 2. Adresse festlegen
Siehe [reverse-proxy.md](reverse-proxy.md). Danach unter *Einstellungen → System* die **Adresse des
Dashboards** eintragen – sie landet im Installations-Einzeiler und in den Benachrichtigungen.
## 3. Anmeldung einrichten
- Optional OIDC: [authentik.md](authentik.md)
- Weitere Konten unter *Einstellungen → Benutzer* (Administrator, Techniker, Kunde)
- Jeder legt sich unter *Meine Passkeys* einen Passkey an – danach entfällt das Passwort
## 4. Agents ausrollen
1. *Einstellungen → Agent-Tokens* → Enrollment-Token erzeugen (mit Kundenbindung spart es den
Zuordnungsschritt)
2. Auf jedem Proxmox-Host als root:
```bash
curl -fsSL https://proxledger.firma.de/install.sh | bash -s -- --token <ENROLL_TOKEN>
```
3. **In einem Cluster auf jedem Knoten** – jeder Knoten hat eine eigene Subscription. Das Dashboard
warnt von selbst, wenn ein Knoten fehlt.
4. Danach den Enrollment-Token widerrufen.
## 5. Benachrichtigungen
*Einstellungen → Benachrichtigungen*: SMTP-Zugang oder Webhook eintragen, Testnachricht schicken,
Zusammenfassung auf täglich oder wöchentlich stellen.
## Prüfliste
- [ ] `https://…/api/gesundheit` antwortet mit 200
- [ ] Anmeldung klappt, die Adresszeile bleibt auf der öffentlichen Adresse
- [ ] Ein Host meldet sich (*Hosts* zeigt ihn mit Ablaufdatum)
- [ ] Testnachricht ist angekommen
- [ ] Sicherung läuft und wurde **einmal zurückgespielt** ([sicherung.md](sicherung.md))
- [ ] `APP_SECRET` liegt an einem zweiten Ort
+228
View File
@@ -0,0 +1,228 @@
# HTTPS einrichten: nginx Proxy Manager oder netbird
ProxLedger spricht selbst kein HTTPS – davor gehört ein Reverse Proxy. Ohne ihn fehlen drei Dinge:
- **Passkeys** funktionieren nur im sicheren Kontext (HTTPS oder `localhost`).
- Das Sitzungs-Cookie bekommt kein `Secure`-Kennzeichen.
- Die Agent-Kommunikation läuft unverschlüsselt durchs Netz.
Fertige Beispiele für nginx, Caddy und Traefik liegen unter [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
Dieses Dokument beschreibt die beiden Wege, die in der Praxis am häufigsten vorkommen.
---
## Weg A: nginx Proxy Manager
### 1. Proxy Host anlegen
*Hosts → Proxy Hosts → Add Proxy Host*, Reiter **Details**:
| Feld | Wert |
|---|---|
| Domain Names | `proxledger.firma.de` |
| Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts oder des LXC |
| Forward Port | `3000` |
| Cache Assets | aus |
| Block Common Exploits | an |
| Websockets Support | **an** |
Reiter **SSL**: *Request a new SSL Certificate*, dazu **Force SSL** und **HTTP/2 Support**.
### 2. Kopfzeilen ergänzen
Reiter **Advanced**, in das Feld *Custom Nginx Configuration*:
```nginx
# Ohne diese Angaben baut ProxLedger seine Weiterleitungen aus der internen Adresse
# (etwa http://172.18.0.4:3000) – im Browser landet man dann im Nichts.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Rechnungs-PDFs sind größer als die Standardgrenze von 1 MB.
client_max_body_size 12m;
```
### 3. In ProxLedger eintragen
```bash
APP_URL="https://proxledger.firma.de"
```
und unter *Einstellungen → System* dieselbe Adresse als **Adresse des Dashboards**. Sie steht danach
im Installations-Einzeiler für die Agents und ist die Grundlage für Passkeys und OIDC.
### 4. Prüfen
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.firma.de/api/gesundheit
curl -fsSI https://proxledger.firma.de/login | grep -i "content-security-policy\|strict-transport"
```
Danach einmal anmelden und darauf achten, dass die Adresszeile auf `proxledger.firma.de` bleibt.
---
## Weg B: netbird Reverse Proxy (Beta)
Seit v0.65 bringt netbird einen **eigenen Reverse Proxy** mit; v0.72 hat private Dienste und „Bring
Your Own Proxy“ ergänzt. Damit entfällt der nginx Proxy Manager vollständig: netbird übernimmt die
TLS-Terminierung und reicht den Verkehr durch das Mesh an den Ziel-Peer weiter – **ohne offenen Port
und ohne Firewall-Regel auf dem ProxLedger-Host**.
Für ProxLedger ist das der bequemste Weg, weil er die beiden Dinge löst, an denen es sonst hakt: ein
gültiges Zertifikat (damit Passkeys funktionieren) und eine feste öffentliche Adresse.
> **Beta.** Die Funktion ist als Beta gekennzeichnet. Sie unterstützt derzeit **kein Rosenpass**, und
> wer netbird selbst hostet, braucht **Traefik** als äußeren Proxy – nur der beherrscht das nötige
> TLS-Passthrough.
### 1. Dienst anlegen
Im netbird-Dashboard unter *Services → Reverse Proxy → Create*:
| Reiter | Einstellung |
|---|---|
| **Details** | Mode `HTTP`; Subdomain `proxledger`, dazu eine Basis-Domäne; als Target den Peer mit ProxLedger, Protokoll `HTTP`, Port `3000` |
| **Authentication** | für ProxLedger **aus lassen** – Begründung unten |
| **Access Control** | nach Bedarf: IP-Bereiche, Länder, CrowdSec-Bewertung |
| **Settings** | *Host header forwarding* **an**, ebenso das Umschreiben von Weiterleitungen |
Das Zertifikat besorgt netbird selbst über ACME (Let's Encrypt); alternativ lassen sich eigene
Zertifikate hinterlegen.
**Host header forwarding ist nicht optional.** ProxLedger bildet Weiterleitungen, die OIDC-Rückkehr
und die `rpID` der Passkeys aus der öffentlichen Adresse. Kommt beim Dienst nur die interne Adresse
an, landet man nach der Anmeldung auf einer Adresse, die es im Browser nicht gibt.
### 2. Adresse eintragen
```bash
APP_URL="https://proxledger.<ihre-basis-domaene>"
```
und dieselbe Adresse unter *Einstellungen → System* als **Adresse des Dashboards**. Danach neu starten.
### 3. Warum die netbird-Anmeldung hier ausbleiben sollte
netbird kann den Dienst zusätzlich absichern – per SSO über den eigenen Identitätsanbieter, per
Passwort, PIN oder Kopfzeile. Verlockend, aber für ProxLedger meist falsch:
**Die Absicherung gilt für den ganzen Dienst, also auch für `/api/agent`.** Die Agents auf den
Proxmox-Hosts können sich dort nicht anmelden – sie weisen sich mit ihrem eigenen Token aus. Steht
eine SSO-Hürde davor, melden alle Hosts stumm ins Leere, bis der Zwischenspeicher voll ist.
Zwei Wege, wenn die zusätzliche Hürde gewünscht ist:
1. **Zwei Dienste anlegen:** einen für die Oberfläche mit SSO, einen zweiten (eigene Subdomain) ohne
Anmeldung für die Agents. Im Installations-Einzeiler dann die Agent-Adresse verwenden
(`--url https://agents.<domaene>`).
2. **Privater Dienst:** netbird kann einen Dienst so schalten, dass er **nur** aus dem eigenen Netz und
nur für ausgewählte Gruppen erreichbar ist. Das ist die sauberste Lösung, wenn sowohl Arbeitsplätze
als auch alle Proxmox-Hosts ohnehin im netbird-Netz sind: kein offener Zugang, trotzdem gültiges
Zertifikat und damit Passkeys.
### 4. Prüfen
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" https://proxledger.<domaene>/api/gesundheit
curl -fsS https://proxledger.<domaene>/install.sh | grep -m1 DASHBOARD_URL
```
Die zweite Zeile muss die **öffentliche** Adresse zeigen – das ist die, die später in jedem Agent steht.
---
## Weg C: netbird ohne Reverse Proxy (kein offener Port)
netbird spannt ein privates Netz über WireGuard. ProxLedger ist dann **nur** für Geräte im Netz
erreichbar – kein Port im Internet, kein Zertifikat von außen nötig.
Das passt gut zu diesem Werkzeug: Die Oberfläche braucht niemand aus dem Internet, und die Proxmox-
Hosts stehen ohnehin meist in Kundennetzen mit netbird oder VPN.
### Aufbau
```
Proxmox-Host (netbird-Client) ──┐
Proxmox-Host (netbird-Client) ──┼── netbird-Netz ──► ProxLedger (netbird-Client, Port 3000)
Arbeitsplatz (netbird-Client) ──┘
```
### 1. netbird auf dem ProxLedger-Host
```bash
curl -fsSL https://pkgs.netbird.io/install.sh | sh
netbird up --setup-key <SETUP-KEY>
netbird status # zeigt die vergebene Adresse, z. B. 100.x.y.z
```
### 2. netbird auf jedem Proxmox-Host
Gleiche Schritte. Danach von dort prüfen:
```bash
curl -fsS -o /dev/null -w "%{http_code}\n" http://100.x.y.z:3000/api/gesundheit
```
### 3. Zugriff einschränken
In der netbird-Verwaltung eine Access-Policy anlegen: Gruppe `proxmox-hosts` darf Gruppe
`proxledger` auf Port 3000 erreichen, sonst nichts. Damit können die Hosts melden, aber nicht
untereinander sprechen.
### 4. HTTPS trotzdem einrichten
netbird verschlüsselt den Transport – der Browser weiß davon aber nichts, und **Passkeys bleiben ohne
HTTPS gesperrt**. Am einfachsten ist hier der Reverse Proxy aus Weg B (auch als privater Dienst
nutzbar). Sonst bleiben zwei Möglichkeiten:
- **Empfohlen:** Zusätzlich einen kleinen Reverse Proxy im netbird-Netz betreiben (nginx oder Caddy)
mit einem Zertifikat für einen internen Namen, ausgestellt über DNS-01 – dafür muss nichts nach
außen offen sein.
- Oder ein eigenes CA-Zertifikat verteilen. Dann brauchen auch die Agents das CA-Zertifikat:
`curl … | bash -s -- --token … --ca-cert /pfad/zur/ca.crt`.
Wer auf Passkeys verzichtet, kann ProxLedger im netbird-Netz auch schlicht über `http://100.x.y.z:3000`
betreiben. In dem Fall `APP_URL` auf genau diese Adresse setzen – sonst setzt ProxLedger ein
`Secure`-Cookie, das der Browser über `http` verwirft, und die Anmeldung läuft ins Leere.
---
## Welcher Weg wofür
| Lage | Empfehlung |
|---|---|
| Eigener Server, Domäne vorhanden, Kunden sollen zugreifen | **Weg A** – nginx Proxy Manager |
| Alles läuft ohnehin über netbird, Zertifikat gewünscht | **Weg B** – netbird Reverse Proxy, bei Bedarf als privater Dienst |
| Kein offener Zugang, keine Passkeys nötig | **Weg C** – netbird pur, `APP_URL` auf die netbird-Adresse |
Weg B ist der kürzeste, wenn netbird ohnehin im Einsatz ist: ein Dienst, ein Zertifikat, kein
offener Port. Nur die Beta-Kennzeichnung sollte man im Hinterkopf behalten und nicht gleichzeitig auf
etwas anderes umstellen, damit bei einem Problem klar ist, woran es liegt.
Der häufigste gemischte Aufbau:
| Zugang | Weg |
|---|---|
| Oberfläche für Mitarbeiter und Kunden | nginx Proxy Manager oder netbird Reverse Proxy |
| Agents aus Kundennetzen | netbird-Netz, oder dieselbe öffentliche Adresse |
Wichtig ist nur, dass die Agents dieselbe Adresse erreichen, die im Installations-Einzeiler steht.
Ändert sich die Adresse später, genügt der Einzeiler erneut auf jedem Host – die Zuordnung bleibt
erhalten, weil sie an der Maschinenkennung hängt, nicht an der Adresse.
## Merkposten
0. **Keine Anmeldehürde vor `/api/agent`.** Egal welcher Proxy: Wer die Oberfläche zusätzlich absichert,
schließt damit auch die Agents aus – die weisen sich mit eigenem Token aus und können sich nirgends
anmelden.
1. **Adresse zuerst festlegen, dann Passkeys anlegen.** Ein Passkey gilt nur für die Domäne, unter der
er registriert wurde.
2. **`APP_URL` und die Dashboard-Adresse müssen auf die öffentliche Adresse zeigen** – daraus entstehen
Weiterleitungen, OIDC-Rückkehradresse und der Agent-Einzeiler.
3. **`client_max_body_size` nicht vergessen**, sonst scheitert das Hochladen von Rechnungs-PDFs mit
einem nichtssagenden 413.
+45
View File
@@ -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.
+3 -2
View File
@@ -15,7 +15,7 @@ auf echten Hosts laufen.
- [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und - [x] Ausgabeformate von `pvesubscription get`, `proxmox-backup-manager subscription get` und
`pmgsubscription get` mit den Annahmen im Skript abgleichen `pmgsubscription get` mit den Annahmen im Skript abgleichen
- [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null) - [x] Verhalten ohne Subscription geprüft (Status `notfound`, alle übrigen Felder fehlen und werden zu null)
- [x] Timer und Spool-Nachsendung geprüft; Uninstall steht noch aus (Agents laufen vorerst weiter) - [x] Timer, Spool-Nachsendung und Uninstall geprüft (Uninstall am 18.09.2026 auf dem PBS-Host, siehe #023)
- [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen - [x] Abweichungen als Testfälle in `tests/agent-skript.test.ts` übernehmen
## Akzeptanzkriterien ## Akzeptanzkriterien
@@ -59,4 +59,5 @@ funktioniert (Report gepuffert bei unerreichbarem Dashboard, beim nächsten Lauf
sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten. sonst dauert ein Test des Zwischenspeichers zehneinhalb Minuten.
6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022. 6. **Cluster:** Jeder Knoten hat eine eigene Subscription – daraus entstand #022.
**Noch offen:** PBS und PMG (keine Hosts verfügbar), Uninstall auf einem echten Host. **Noch offen:** PBS und PMG – dafür ist #023 angelegt. PBS-Hosts stellt Florian bereit, allerdings nur
unlizenziert; für PMG gibt es derzeit keinen Host. Der Uninstall-Durchlauf ist inzwischen erledigt (#023).
+69
View File
@@ -0,0 +1,69 @@
# #023 Feldtest auf Proxmox Backup Server und Mail Gateway
**Status:** offen
**Meilenstein:** v1.0.0
**Labels:** agent, feldtest
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min (PBS erledigt, PMG offen)
## Ziel
Der Feldtest in #018 lief auf zwei Proxmox-VE-Hosts. Für Backup Server und Mail Gateway beruht die
Auswertung im Agent bisher nur auf Annahmen – und bei PVE hat sich genau eine solche Annahme als
falsch erwiesen (`--output-format json` gibt es dort nicht). Das gehört vor 1.0.0 geprüft.
## Ausgangslage
- **PBS:** Hosts stellt Florian bereit, allerdings **nur unlizenziert**. Damit lässt sich der Fall
„aktive Subscription“ auf PBS nicht prüfen – wohl aber Produkterkennung, Version, Server-ID und der
Zustand `notfound`, der bei PVE die meisten Überraschungen geliefert hat.
- **PMG:** derzeit kein Host verfügbar. Der Zweig bleibt ungeprüft; das ist im Handbuch zu vermerken.
## Aufgaben
- [x] `scripts/feldtest-erheben.sh` auf einem PBS-Host laufen lassen (liest nur, ändert nichts)
- [x] Prüfen, ob `proxmox-backup-manager subscription get` Text oder JSON liefert und welche Felder
ohne Subscription überhaupt erscheinen
- [x] Ausgabe von `proxmox-backup-manager version` gegen `produkt_version()` prüfen
- [x] Erhobene Ausgaben als Testfälle in `tests/agent-skript.test.ts` aufnehmen – der heutige
PBS-Testfall ist erfunden (JSON) und damit womöglich am echten Verhalten vorbei
- [x] Agent auf einem PBS-Host installieren, einen Report abwarten, wieder deinstallieren
- [ ] PMG offen lassen und die Lücke im Handbuch benennen
## Notizen
Der Cluster-Abgleich aus #022 betrifft nur PVE: PBS und PMG kennen kein `pvecm`, dort bleibt die
Mitgliederliste leer.
## Ergebnis (18.09.2026, `bup-ste-pbs-01`, PBS 4.2 auf Debian 13, ohne Subscription)
**Ein echter Fehler gefunden – und er war kein kleiner.** `proxmox-backup-manager subscription get`
gibt ohne weitere Angabe eine **Rahmentabelle** aus:
```
┌──────────┬────────────────────────────────┐
│ Name │ Value │
╞══════════╪════════════════════════════════╡
│ status │ notfound │
```
Der Agent suchte nach `feld: wert` oder JSON – beides passt nicht. Auf einem echten PBS hätte er
**alle Felder als null gemeldet, nicht einmal die Server-ID**. Der erfundene Testfall hatte genau das
verdeckt, weil er JSON unterstellte.
Behoben in drei Schritten:
1. Für PBS wird `--output-format json` verwendet. Anders als PVE **kann** PBS das (auf 4.2 geprüft).
2. Bleibt die Ausgabe leer, wird die Textform genommen und die Rahmentabelle ausgewertet
(`awk -F'│'`) – für Fassungen, die das JSON-Format nicht kennen.
3. `proxmox-backup-manager version` liefert „proxmox-backup-server 4.2.6-1 running version: 4.2.1“;
gemeldet wird jetzt die installierte Fassung ohne den Zusatz.
Beide Formen sind als Testfälle hinterlegt, mit den echten Ausgaben des Hosts.
**Nebenbefund:** Der Agent meldete „Neue Agent-Version verfügbar: 0.2.0 (installiert: 0.2.1)“ – er
verglich nur auf Ungleichheit. Jetzt wird mit `sort -V` geprüft, ob die Fassung wirklich neuer ist.
**Ende-zu-Ende geprüft:** Installation → Registrierung → erster Report → Warnungen. Im Dashboard kamen
an: Produkt `PBS`, Version `proxmox-backup-server 4.2.6-1`, Server-ID `69AED6…`, 1 Sockel, 106
ausstehende Pakete, leere Cluster-Liste (PBS kennt kein `pvecm`), Warnungen „Keine Subscription
gefunden“ und „Host ist keinem Kunden zugewiesen“. Anschließend `--uninstall`: Timer, Dienst,
`/usr/local/bin/proxledger-agent`, `/etc/proxledger` und `/var/lib/proxledger` restlos entfernt. Damit
ist auch der letzte offene Punkt aus #018 erledigt.
**Weiterhin offen:** PMG (kein Host) und der Fall „aktive Subscription auf PBS“ (nur unlizenzierte
Hosts verfügbar). Dieses Issue bleibt deshalb offen.
+34
View File
@@ -0,0 +1,34 @@
# #024 Härtung nach OWASP
**Status:** erledigt
**Meilenstein:** v0.15.0
**Labels:** security, backend
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
## Ziel
Vor der Freigabe als fertiges Produkt eine Durchsicht gegen die OWASP Top 10 – und die gefundenen
Lücken schließen, statt sie zu vermerken.
## Aufgaben
- [x] Zugriffskontrolle: jede Server Action prüft die Sitzung selbst, nicht nur der Proxy
- [x] Sitzungen widerrufbar machen (gesperrtes Konto, neues Passwort)
- [x] Login-CSRF: Herkunft der Anmeldung prüfen
- [x] Content-Security-Policy mit Nonce je Anfrage
- [x] Abhängigkeiten ohne bekannte Schwachstellen (`npm audit`)
- [x] Benutzerverwaltung auf der Kommandozeile (sperren, entsperren, Passwort)
- [x] `docs/sicherheit.md` mit der Zuordnung zu den OWASP Top 10
- [x] Tests für Sitzungsurteil und Herkunftsprüfung
## Gefunden und behoben
1. **Server Actions waren nur durch `proxy.ts` geschützt.** Keine einzige Aktion prüfte selbst. Der
Proxy greift zwar auch bei POST, aber eine einzige Schicht ist zu wenig für 27 schreibende Endpunkte.
2. **Login-CSRF möglich:** Der Anmelde-Endpunkt ist ein gewöhnliches Formular und prüfte die Herkunft
nicht. Eine fremde Seite hätte jemanden in ein untergeschobenes Konto anmelden können.
3. **Keine CSP.** Jetzt mit Nonce und `strict-dynamic`.
4. **Sitzungen ließen sich nicht beenden.** Ein gesperrtes Konto arbeitete bis zu zwölf Stunden weiter.
5. **`npm audit`: 4 hohe Meldungen** in `mysql2` und `deepmerge-ts` – beides Beiwerk von Prisma. Über
`overrides` angehoben statt Prisma zurückzustufen; danach keine Meldung mehr.
## Bewusst nicht umgesetzt
- **Rollen:** Wer angemeldet ist, darf alles. Für ein internes Werkzeug vertretbar, in `docs/sicherheit.md` vermerkt.
- **Zwei-Faktor-Anmeldung:** Wer sie braucht, nutzt OIDC.
- Die Einschränkung der OIDC-Anmeldung (erlaubte Domänen, automatisches Anlegen) kommt mit #025.
+47
View File
@@ -0,0 +1,47 @@
# #025 Einstellungen im Dashboard
**Status:** erledigt
**Meilenstein:** v0.16.0
**Labels:** backend, ui
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
## Ziel
Werte, die heute im Quelltext oder in der Umgebung stehen, sollen sich im Dashboard ändern lassen –
allen voran die Dashboard-Adresse, die im Installations-Einzeiler landet.
## Aufgaben
- [x] Typisierter Einstellungsdienst mit Vorgaben und Prüfung (zod), alles in einer JSON-Zeile
- [x] Oberfläche unter *Einstellungen → System* mit vier Bereichen
- [x] **Adresse und Agent:** Dashboard-Adresse, Melderhythmus, Streuung, Selbstaktualisierung
- [x] **Kaufmännisches:** Währung, Steuersatz, Zahlungsziel, Aufschlag
- [x] **Sicherheit:** Sitzungsdauer, Anmeldeversuche, OIDC-Domänen, Konten anlegen, Aufbewahrungsfristen
- [x] **Firma und Darstellung:** Name, Anschrift, E-Mail, Zeitzone
- [x] Werte wirksam machen (nicht nur speichern)
- [x] Nächtliches Aufräumen nach den Aufbewahrungsfristen
- [x] Tests und Handbuchkapitel
## Wo die Werte wirken
| Einstellung | Wirkt in |
|---|---|
| Dashboard-Adresse | `/install.sh`, `/agent.sh`, Benachrichtigungen |
| Rhythmus, Streuung, Selbstaktualisierung | erzeugte systemd-Timer und `agent.conf` |
| Steuersatz | neue Rechnungspositionen (Einkauf und Verkauf) |
| Zahlungsziel | vorgeschlagenes Fälligkeitsdatum |
| Aufschlag | vorgeschlagener Verkaufspreis aus dem Einkaufspreis |
| Sitzungsdauer | Gültigkeit des Tokens und Lebensdauer des Cookies |
| Anmeldeversuche | Ratenbremse der Anmeldung |
| OIDC-Domänen, Konten anlegen | `mitOidcAnmelden` – schließt die in #024 vermerkte Lücke |
| Aufbewahrungsfristen | nächtlicher Lauf im Worker (03:30) |
| Firma, Zeitzone | Kopf und Datum der PDF-Ausgaben |
## Entscheidungen
- **Eine JSON-Zeile statt vieler Schlüssel:** Speichern ist ein Schreibvorgang, fehlende Felder
bekommen ihren Vorgabewert, und eine kaputte Zeile legt den Betrieb nicht lahm – dann gelten die Vorgaben.
- **Zehn Sekunden Zwischenspeicher:** Die Werte werden bei fast jeder Anfrage gebraucht, ändern sich
aber selten. Eine Änderung ist trotzdem sofort spürbar.
- **`APP_SECRET` und `DATABASE_URL` bleiben in der Umgebung**; beide werden vor dem ersten
Datenbankzugriff gebraucht.
- **„Stumm nach X Tagen“ bewusst weggelassen:** Das steht schon als Schwellwert bei den Warnregeln.
Zwei Stellen für denselben Wert wären eine Fehlerquelle.
- **Währung und Zeitzone wirken vorerst nur serverseitig** (PDF, CSV, Benachrichtigungen). Die
Browseransicht rechnet weiterhin fest in Euro und Europe/Berlin – das nachzuziehen heißt, die
Formatierung durch alle Client-Komponenten zu reichen, und gehört in einen eigenen Schritt.
+41
View File
@@ -0,0 +1,41 @@
# #026 Auslieferung finalisieren
**Status:** erledigt
**Meilenstein:** v0.17.0
**Labels:** deployment, doku
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
## Ziel
Zwei saubere Wege, ProxLedger in Betrieb zu nehmen: als Docker-Stack und als LXC-Container nach Art
der Proxmox VE Helper-Scripts.
## Aufgaben
- [x] `deploy/update.sh`: sichern, bauen, migrieren, neu starten, Erreichbarkeit prüfen
- [x] Zustandsprüfung für den Worker in `docker-compose.yml`
- [x] `AUFRAEUM_CRON` in `.env.example` und im Compose-Dienst
- [x] `deploy/community-scripts/ct/proxledger.sh` und `install/proxledger-install.sh`
- [x] `deploy/community-scripts/README.md` mit Voraussetzungen für eine Einreichung
- [x] README auf den tatsächlichen Stand gebracht (Feldtest erledigt, Einstellungen, Sicherheit)
- [x] `docs/roadmap.md`
- [x] Docker-Stapel vollständig durchgespielt: bauen, starten, migrieren, anmelden, Agent-Skript abrufen
## Erkenntnisse zu community-scripts
- Die Skripte laden ihr Gerüst inzwischen aus einem eigenen Repository
(`community-scripts/core`, Datei `core/build.func`) – nicht mehr aus `misc/build.func`.
- **Neue Skripte gehören als Pull Request nach ProxmoxVED**, nicht nach ProxmoxVE. Direkt eingereichte
Skripte werden dort ohne Prüfung geschlossen.
- Die JSON-Metadaten für die Webseite liegen nicht mehr im Skript-Repository.
- Eine Einreichung setzt öffentlich erreichbaren Quelltext voraus (in aller Regel GitHub). Solange
ProxLedger im eigenen Gitea liegt, bleiben die Skripte intern; die Quelle lässt sich über
`PROXLEDGER_REPO` setzen.
## Beim Prüfen gefunden
Der Docker-Stapel wurde vollständig gestartet (bauen, migrieren, anmelden). Dabei zeigte sich: Nach der
Anmeldung wies die Weiterleitung auf `http://<container-id>:3000` – die **interne** Adresse des
Containers. Im Browser hinter einem Reverse Proxy wäre das eine tote Adresse gewesen. Weiterleitungen
nehmen jetzt die eingetragene Adresse, sonst `APP_URL`, sonst die Kopfzeilen des Reverse Proxy
(`src/lib/adresse.ts`, vier Tests). Im Stapel gegengeprüft: `location: http://localhost:3001/`.
## Entscheidung
Der eigene `deploy/lxc-installer.sh` (Container **mit Docker darin**) bleibt erhalten. Der neue Weg
installiert Node und PostgreSQL direkt in den Container – das ist schlanker und entspricht dem, was
Proxmox-Betreiber von Helper-Scripts erwarten. Beide Wege stehen gleichberechtigt im README.
@@ -0,0 +1,36 @@
# #027 Rollen und Benutzerverwaltung
**Status:** erledigt
**Meilenstein:** v0.18.0
**Labels:** security, backend, ui
**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min
## Ziel
Bisher durfte jeder Angemeldete alles – in `docs/sicherheit.md` als offener Punkt vermerkt. Es soll
drei Rollen geben: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Daten.
## Aufgaben
- [x] `Rolle` im Datenmodell, Kundenbindung am Benutzer
- [x] Rechte an **einer** Stelle (`src/server/auth/rollen.ts`), nicht verteilt über Seiten
- [x] `sitzungErforderlich(recht)` – jede der 28 Aktionen nennt ihr Recht
- [x] Sicht einschränken: Hosts, Kunden, Rechnungen, Kennzahlen, Suche, Jahresbahn, Dashboard
- [x] Einkaufspreise und Marge für Kundenkonten entfernen – in der Abfrage, nicht in der Anzeige
- [x] Schreibrechte für Kunden: Notizen am eigenen Host, Warnungen quittieren
- [x] Benutzerverwaltung unter *Einstellungen → Benutzer*
- [x] Menü nach Rolle
- [x] Tests
## Entscheidungen
- **Rolle und Kundenbindung kommen aus der Datenbank, nicht aus dem Token.** Eine geänderte Rolle
wirkt damit sofort statt erst nach Ablauf der Sitzung.
- **Die Abfragen holen sich die Einschränkung selbst** (`sichtbarerKunde()`), statt sie von der Seite
gereicht zu bekommen. Eine Seite kann so nicht vergessen, sie weiterzugeben – der häufigste Weg,
wie solche Sichten lecken.
- **Ein Kundenkonto ohne Kundenbindung sieht nichts** statt alles. Der Rückfall zeigt bewusst ins
Leere, nicht auf den vollen Bestand.
- **Ausgeblendete Menüpunkte sind keine Zugriffskontrolle.** Sie sind Bequemlichkeit; geprüft wird in
Seite und Aktion.
## Beim Bauen aufgefallen
Neu angelegte OIDC-Konten hätten die Vorgabe des Datenmodells geerbt – **ADMIN**. Wer sich beim
Identitätsanbieter anmelden darf, wäre damit nebenbei Administrator geworden. Jetzt gibt es dafür eine
eigene Einstellung mit der Vorgabe *Techniker*, samt Test.
+35
View File
@@ -0,0 +1,35 @@
# #028 Anmeldung mit Passkeys
**Status:** erledigt
**Meilenstein:** v0.18.0
**Labels:** security, backend, ui
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
## Ziel
Passkeys (WebAuthn) als **gleichwertiger** Anmeldeweg neben Passwort und OIDC – passwortlos, mit
Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel.
## Aufgaben
- [x] `@simplewebauthn/server` und `@simplewebauthn/browser`
- [x] Datenmodell `Passkey` (mehrere je Konto)
- [x] Registrierung und Anmeldung als Endpunkte, Aufgabe („challenge“) in einem kurzlebigen Cookie
- [x] Seite *Meine Passkeys* mit Anlegen und Entfernen
- [x] Knopf „Mit Passkey anmelden“ auf der Anmeldeseite
- [x] Hinweis, wenn die Adresse kein HTTPS hat
## Entscheidungen
- **Keine Liste erlaubter Schlüssel bei der Anmeldung.** Der Browser bietet selbst an, was zu dieser
Seite passt. Sonst müsste die Seite vorher verraten, welche Passkeys zu einem Konto gehören – das
wäre ein Weg, Konten auszuspähen.
- **Fehlermeldung bei der Anmeldung bewusst unscharf** („Anmeldung mit diesem Passkey nicht
möglich“). Ob es den Schlüssel gibt, geht einen Unbekannten nichts an.
- **Die Aufgabe liegt in einem `SameSite=strict`-Cookie mit fünf Minuten Gültigkeit**, nicht in der
Datenbank. Sie gilt nur für diesen Browser und diesen Versuch.
- **`rpID` und `origin` kommen aus der öffentlichen Adresse** (Einstellung, sonst `APP_URL`, sonst
Proxy-Kopfzeilen). Ein Passkey ist an die Domäne gebunden; steht dort die falsche Adresse, lehnt
der Browser wortlos ab.
- Passwort und OIDC bleiben bestehen. Ein Passkey, der im kaputten Telefon liegt, darf niemanden
aussperren.
## Grenze
Ein Passkey gilt nur für die Adresse, unter der er angelegt wurde. Beim Umzug von `http://server:3000`
auf `https://proxledger.firma.de` müssen alle Passkeys neu registriert werden – im Handbuch vermerkt.
+30
View File
@@ -0,0 +1,30 @@
# #029 Einrichtungsdokumentation
**Status:** erledigt
**Meilenstein:** v0.19.0
**Labels:** doku
**Geschätzt:** 3 h **Tatsächlich:** 0 h 40 min
## Ziel
Anleitungen für alles, was rund um ProxLedger eingerichtet wird: HTTPS, OIDC mit authentik, Sicherung –
und die richtige Reihenfolge, in der das geschehen muss.
## Aufgaben
- [x] `docs/betrieb/inbetriebnahme.md` – von null zum meldenden Host
- [x] `docs/betrieb/reverse-proxy.md` – nginx Proxy Manager und netbird
- [x] `docs/betrieb/authentik.md` – OIDC Schritt für Schritt, mit Fehlerbildern
- [x] `docs/betrieb/sicherung.md` – sichern und **zurückspielen**
- [x] Verweise aus README und Handbuch
## Beim Schreiben gefunden
Zwei Stellen taugten für einen Betrieb hinter einem Reverse Proxy nicht:
1. Die **OIDC-Rückkehradresse** kam allein aus `APP_URL` und übersah die Dashboard-Adresse aus den
Einstellungen. Wer nur letztere gesetzt hatte, bekam vom Anbieter einen *Redirect URI Error*.
2. Beim **Einlösen des Codes** wurde die Adresse der eingehenden Anfrage verwendet – hinter einem
Proxy also die interne. Der Anbieter hat aber die öffentliche hinterlegt; der Tausch wäre
gescheitert. Jetzt wird dieselbe öffentliche Adresse gebildet wie für die Weiterleitungen.
## Entscheidung
Die Reihenfolge steht bewusst am Anfang der Inbetriebnahme: **Adresse festlegen, dann Passkeys und
Agents.** Ein Passkey gilt nur für die Domäne, unter der er entstanden ist, und die Adresse steckt im
Installations-Einzeiler. Wer das übergeht, macht beides zweimal.
+29
View File
@@ -0,0 +1,29 @@
# #030 netbird Reverse Proxy in die Anleitung
**Status:** erledigt
**Meilenstein:** v0.19.1
**Labels:** doku
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Ziel
netbird bringt seit v0.65 einen eigenen Reverse Proxy mit (Beta), v0.72 ergänzte private Dienste.
Damit entfällt ein zusätzlicher nginx – das gehört in die Anleitung, weil es der kürzeste Weg zu
HTTPS ist, wenn netbird ohnehin im Einsatz ist.
## Aufgaben
- [x] Weg B: netbird Reverse Proxy – Dienst anlegen, Zertifikat, Einstellungen
- [x] Alter netbird-Abschnitt wird Weg C (ohne Proxy)
- [x] Entscheidungstabelle, welcher Weg wofür taugt
- [x] Beta-Einschränkungen benennen (kein Rosenpass; selbst gehostet nur mit Traefik)
## Der wichtigste Hinweis
Die Absicherung eines Dienstes (SSO, Passwort, PIN) gilt für den **ganzen** Dienst – also auch für
`/api/agent`. Die Agents weisen sich mit eigenem Token aus und können sich nirgends anmelden; eine
SSO-Hürde davor lässt alle Hosts stumm ins Leere melden, bis der Zwischenspeicher voll ist.
Zwei brauchbare Auswege stehen in der Anleitung: zwei Dienste (Oberfläche mit SSO, Agents ohne) oder
ein privater Dienst, der nur aus dem netbird-Netz erreichbar ist.
## Quellen
- <https://docs.netbird.io/manage/reverse-proxy>
- <https://netbird.io/knowledge-hub/reverse-proxy>
- <https://netbird.io/knowledge-hub/netbird-only-private-services>
@@ -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ß.
+28
View File
@@ -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.
+28
View File
@@ -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.
+22
View File
@@ -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“
+46
View File
@@ -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.
+31
View File
@@ -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.
+32
View File
@@ -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`).
+26
View File
@@ -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.
+19 -1
View File
@@ -26,4 +26,22 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min | | [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| | **Summe** | | | **119 h** | **9 h 08 min** | | [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
| [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min |
| [#027](027-rollen-benutzerverwaltung.md) | Rollen und Benutzerverwaltung | erledigt | v0.18.0 | 6 h | 1 h 20 min |
| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min |
| [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min |
| [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min |
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
| [#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** |
+72
View File
@@ -0,0 +1,72 @@
# Roadmap
Stand: 19.09.2026, Fassung 0.17.0
## Was steht
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt:
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
## Was bis 1.0.0 fehlt
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten:
| Punkt | Warum es zählt | Aufwand |
|---|---|---|
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit |
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben.
## Nach 1.0 – Vorschläge, nach Nutzen sortiert
### Lohnt sich am ehesten
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
### Sinnvoll, wenn es wächst
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt –
spart Rückfragen.
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
Lizenzjahr erzeugen.
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
ist vertretbar, aber nicht für jeden.
### Nur falls der Bedarf entsteht
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
## Was ich bewusst nicht vorschlage
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
ein IdP – dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf
jedem Proxmox-Host, auch auf einem alten.
+111
View File
@@ -0,0 +1,111 @@
# Sicherheit
Dieses Dokument hält fest, was ProxLedger gegen die üblichen Angriffe tut – und was es **nicht** tut.
Die Gliederung folgt den OWASP Top 10 (2021).
## A01 – Fehlerhafte Zugriffskontrolle
- `src/proxy.ts` schickt jede Anfrage ohne gültige Sitzung zur Anmeldung. Öffentlich sind nur die
Anmeldung, die Agent-API (eigene Tokens), die Gesundheitsprüfung, die beiden Agent-Skripte und die Symbole.
- **Jede Server Action prüft zusätzlich selbst** (`sitzungErforderlich()`). Server Actions sind eigene
Endpunkte; eine einzige Schutzschicht davor wäre zu wenig, falls der Musterausdruck im Proxy je
danebengreift.
- Die Prüfung geht in die Datenbank: Ein gesperrtes Konto oder ein Widerruf beendet laufende Sitzungen
sofort (`npm run benutzer -- sperren <email>`), statt bis zum Ablauf des Tokens zu warten.
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt;
die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden
gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge
werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML.
**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer
Installation wären ein Umbau, kein Anbau.
## A02 – Kryptografische Fehler
- Passwörter: argon2id (`@node-rs/argon2`), keine eigene Krypto.
- Sitzungen: signiertes JWT (HS256) aus `APP_SECRET`, 12 Stunden gültig, Cookie `httpOnly`, `sameSite=lax`
und `secure`, sobald `APP_URL` auf `https://` steht.
- Zugangsdaten für SMTP und Webhooks liegen AES-256-GCM-verschlüsselt in der Datenbank
(`src/lib/geheimnisse.ts`); der Schlüssel wird aus `APP_SECRET` abgeleitet.
- Agent- und Enrollment-Tokens werden nur als SHA-256-Hash gespeichert. Das ist hier richtig und nicht
zu schwach: Die Tokens sind 32 Byte Zufall, es gibt nichts zu raten.
**Wichtig:** `APP_SECRET` ist der Generalschlüssel. Geht er verloren, sind alle Sitzungen ungültig und die
gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
## A03 – Injektion
- Datenbankzugriffe ausschließlich über Prisma mit gebundenen Werten; kein `$queryRawUnsafe` im Projekt.
- Ausgaben maskiert React selbst.
- Zusätzlich greift eine Content-Security-Policy mit frischem Nonce je Anfrage und `strict-dynamic`
(`src/proxy.ts`). Eingeschleustes `<script>` – etwa über einen manipulierten Hostnamen aus einer
Proxmox-Ausgabe – läuft damit nicht.
- Der Agent baut sein JSON mit einer eigenen Maskierung; Sonderzeichen aus Proxmox-Ausgaben sind als
Testfall hinterlegt.
## A04 – Unsicheres Design
- Push-Modell: Das Dashboard braucht **keinen** Zugang zu den Proxmox-Hosts. Es gibt keine gespeicherten
root-Zugänge, die abhandenkommen könnten – die Hosts melden von sich aus.
- Der Agent sendet nur Statusangaben (Subscription, Version, Sockel, IPs). Keine Gastliste, keine Daten.
- Enrollment-Tokens sind befristet und in der Zahl der Nutzungen begrenzt.
## A05 – Sicherheitsrelevante Fehlkonfiguration
- Kopfzeilen: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
`Strict-Transport-Security` (`next.config.ts`) sowie die CSP aus dem Proxy.
- Die Anwendung startet nicht mit zu kurzem `APP_SECRET`.
- Im Container läuft die Anwendung als unprivilegierter Benutzer; Anhänge werden mit Rechten 640 abgelegt.
- Fehlermeldungen nennen keine Einzelheiten: Bei der Anmeldung steht dieselbe Meldung für „Adresse
unbekannt“ und „Passwort falsch“.
## A06 – Verwundbare Komponenten
- `npm audit --omit=dev` meldet **keine** bekannten Schwachstellen (Stand 18.09.2026).
- Zwei Fassungen werden über `overrides` angehoben: `mysql2` und `deepmerge-ts` kommen als Beiwerk von
Prisma mit, obwohl wir Postgres nutzen. Ohne die Anhebung bliebe nur ein Rückschritt auf Prisma 6.
- Die CI prüft bei jedem Push (#019).
## A07 – Fehler bei Identifizierung und Authentifizierung
- Ratenbremse: fünf Anmeldeversuche je Kennung und Viertelstunde.
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden,
mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`).
- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht
Administrator.
**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides
in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor.
## A08 – Datenintegrität
- Der Agent prüft vor dem Selbstaktualisieren, ob die geladene Datei wie ein Bash-Skript beginnt.
- Webhooks werden mit HMAC-SHA256 signiert, damit die Gegenstelle die Herkunft prüfen kann.
- Hochgeladene Rechnungen werden nicht dem gemeldeten Typ nach beurteilt, sondern am Dateianfang
geprüft (`%PDF-`), auf 10 MB begrenzt und unter ihrer Prüfsumme abgelegt.
## A09 – Protokollierung
- Audit-Log für Anmeldungen, Änderungen an Kunden, Rechnungen, Tokens, Kanälen und Warnregeln –
einsehbar unter *Einstellungen → Protokoll*.
- Der Agent protokolliert auf dem Host über `logger` ins Journal.
## A10 – Server-Side Request Forgery
- Die einzige ausgehende Verbindung, die sich einstellen lässt, ist der Benachrichtigungs-Webhook. Er
wird bewusst von einem Angemeldeten eingetragen und ist damit kein fremdgesteuerter Aufruf.
- Sonst ruft das Dashboard keine Adressen auf, die aus Daten stammen.
## Was ein Betreiber selbst tun muss
1. HTTPS davorschalten (Beispiele unter `deploy/reverse-proxy/`) und `APP_URL` auf `https://` setzen.
2. `APP_SECRET` und die Datenbank sichern (`deploy/backup.sh`).
3. Das Dashboard nicht ins offene Internet stellen, wenn es nicht sein muss. Die Agent-API muss
erreichbar sein, die Oberfläche nicht.
4. Enrollment-Tokens nach dem Ausrollen widerrufen.
+417 -86
View File
@@ -1,24 +1,26 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.19.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.19.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -33,7 +35,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
@@ -944,6 +946,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1609,6 +1617,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
@@ -1632,15 +1646,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
"integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
"integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1681,9 +1695,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
"integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1697,9 +1711,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
"integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1713,9 +1727,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
"integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1732,9 +1746,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
"integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1751,9 +1765,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
"integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1770,9 +1784,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
"integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1789,9 +1803,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
"integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1805,9 +1819,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
"integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2147,6 +2161,239 @@
"url": "https://github.com/sponsors/oxc-project" "url": "https://github.com/sponsors/oxc-project"
} }
}, },
"node_modules/@peculiar/asn1-android": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-asym-key": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz",
"integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-rsa": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pfx": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-post-quantum": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz",
"integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-asym-key": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
"integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-csr": "^2.9.4",
"@peculiar/asn1-ecc": "^2.9.4",
"@peculiar/asn1-pkcs9": "^2.9.4",
"@peculiar/asn1-rsa": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3031,6 +3278,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/browser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
"integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
"integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
"@peculiar/x509": "^2.1.0",
"reflect-metadata": "^0.2.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4339,6 +4613,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -5071,13 +5359,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"devOptional": true, "devOptional": true,
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.9.0"
} }
}, },
"node_modules/define-data-property": { "node_modules/define-data-property": {
@@ -5133,16 +5431,6 @@
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/destr": { "node_modules/destr": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -5601,13 +5889,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
"integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.3.5", "@next/eslint-plugin-next": "16.3.8",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -7820,24 +8108,25 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.15.3", "version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.1", "aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.3",
"long": "^5.2.1", "long": "^5.3.2",
"lru.min": "^1.0.0", "lru.min": "^1.1.4",
"named-placeholders": "^1.1.3", "named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5", "sql-escaper": "^1.5.1"
"sqlstring": "^2.3.2"
}, },
"engines": { "engines": {
"node": ">= 8.0" "node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
} }
}, },
"node_modules/named-placeholders": { "node_modules/named-placeholders": {
@@ -7895,12 +8184,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.5", "version": "16.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
"integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.5", "@next/env": "16.3.8",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -7914,14 +8203,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.5", "@next/swc-darwin-arm64": "16.3.8",
"@next/swc-darwin-x64": "16.3.5", "@next/swc-darwin-x64": "16.3.8",
"@next/swc-linux-arm64-gnu": "16.3.5", "@next/swc-linux-arm64-gnu": "16.3.8",
"@next/swc-linux-arm64-musl": "16.3.5", "@next/swc-linux-arm64-musl": "16.3.8",
"@next/swc-linux-x64-gnu": "16.3.5", "@next/swc-linux-x64-gnu": "16.3.8",
"@next/swc-linux-x64-musl": "16.3.5", "@next/swc-linux-x64-musl": "16.3.8",
"@next/swc-win32-arm64-msvc": "16.3.5", "@next/swc-win32-arm64-msvc": "16.3.8",
"@next/swc-win32-x64-msvc": "16.3.5", "@next/swc-win32-x64-msvc": "16.3.8",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -8666,6 +8955,24 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/queue": { "node_modules/queue": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -8748,6 +9055,12 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9059,12 +9372,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9314,14 +9621,20 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sqlstring": { "node_modules/sql-escaper": {
"version": "2.3.3", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
} }
}, },
"node_modules/stable-hash": { "node_modules/stable-hash": {
@@ -9702,6 +10015,24 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+11 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.14.0", "version": "0.23.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -26,19 +26,22 @@
"lasttest": "tsx scripts/lasttest.ts", "lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs", "screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs", "icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs" "pruefe:doku": "node scripts/pruefe-doku.mjs",
"benutzer": "tsx scripts/benutzer.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1", "croner": "^10.0.1",
"jose": "^6.2.12", "jose": "^6.2.12",
"next": "16.3.5", "next": "^16.3.8",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"react": "19.2.8", "react": "19.2.8",
@@ -53,7 +56,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.5", "eslint-config-next": "^16.3.8",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7", "prettier": "^3.9.7",
"prisma": "^7.10.0", "prisma": "^7.10.0",
@@ -65,5 +68,9 @@
"description": "Proxmox Subscription Dashboard", "description": "Proxmox Subscription Dashboard",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
},
"overrides": {
"mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0"
} }
} }
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
@@ -0,0 +1,36 @@
-- CreateEnum
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'TECHNIKER', 'KUNDE');
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "kundeId" TEXT,
ADD COLUMN "rolle" "Rolle" NOT NULL DEFAULT 'ADMIN';
-- CreateTable
CREATE TABLE "Passkey" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"zaehler" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"bezeichnung" TEXT NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztAm" TIMESTAMP(3),
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
-- CreateIndex
CREATE INDEX "Passkey_benutzerId_idx" ON "Passkey"("benutzerId");
-- CreateIndex
CREATE INDEX "Benutzer_kundeId_idx" ON "Benutzer"("kundeId");
-- AddForeignKey
ALTER TABLE "Benutzer" ADD CONSTRAINT "Benutzer_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
+42 -2
View File
@@ -72,6 +72,7 @@ model Kunde {
alerts Alert[] alerts Alert[]
@@index([name]) @@index([name])
benutzer Benutzer[]
} }
model Standort { model Standort {
@@ -260,9 +261,10 @@ model ProxmoxRechnung {
nummer String @unique nummer String @unique
datum DateTime @db.Date datum DateTime @db.Date
faelligAm 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) nettoBetrag Decimal @db.Decimal(12, 2)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2) /// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
rabatt Decimal? @db.Decimal(12, 2) ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
zahlungsart String? zahlungsart String?
bezahlt Boolean @default(false) bezahlt Boolean @default(false)
bezahltAm DateTime? @db.Date bezahltAm DateTime? @db.Date
@@ -283,7 +285,10 @@ model ProxmoxRechnungPosition {
rechnungId String rechnungId String
bezeichnung String bezeichnung String
menge Int @default(1) menge Int @default(1)
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
nettoBetrag Decimal @db.Decimal(12, 2) 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) rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
laufzeit Laufzeit? laufzeit Laufzeit?
@@ -448,18 +453,53 @@ model Benachrichtigungskanal {
// ---------------------------------------------------------------- Benutzer und Protokoll // ---------------------------------------------------------------- Benutzer und Protokoll
/// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen. /// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen.
/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027).
enum Rolle {
ADMIN
TECHNIKER
KUNDE
}
model Benutzer { model Benutzer {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
name String name String
passwortHash String? passwortHash String?
oidcSubject String? @unique oidcSubject String? @unique
/// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN.
rolle Rolle @default(ADMIN)
/// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist.
kundeId String?
aktiv Boolean @default(true) aktiv Boolean @default(true)
letzterLoginAm DateTime? letzterLoginAm DateTime?
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
sitzungenUngueltigAb DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
auditEintraege AuditEintrag[] auditEintraege AuditEintrag[]
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull)
passkeys Passkey[]
@@index([kundeId])
}
/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028).
model Passkey {
id String @id @default(cuid())
benutzerId String
/// Kennung des Schlüssels, base64url wie vom Browser geliefert.
credentialId String @unique
publicKey Bytes
zaehler BigInt @default(0)
transports String[] @default([])
bezeichnung String
erstelltAm DateTime @default(now())
zuletztAm DateTime?
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
} }
model AuditEintrag { model AuditEintrag {
+2 -1
View File
@@ -166,7 +166,6 @@ async function seedDemo() {
faelligAm: datum(vorlage.faelligAm), faelligAm: datum(vorlage.faelligAm),
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0), nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
ustSatz: vorlage.ustSatz, ustSatz: vorlage.ustSatz,
rabatt: vorlage.rabatt ?? null,
zahlungsart: vorlage.zahlungsart ?? null, zahlungsart: vorlage.zahlungsart ?? null,
bezahlt: vorlage.bezahlt, bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm), bezahltAm: datum(vorlage.bezahltAm),
@@ -175,6 +174,8 @@ async function seedDemo() {
id: `prp-${l.id}`, id: `prp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`, bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.einkauf?.nettoBetrag ?? 0, nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
rabatt: vorlage.rabatt ?? null,
})), })),
}, },
}, },
+81
View File
@@ -0,0 +1,81 @@
import "dotenv/config";
import { createInterface } from "node:readline/promises";
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Benutzerverwaltung von der Kommandozeile – solange es dafür keine Oberfläche gibt.
*
* npm run benutzer -- liste
* npm run benutzer -- sperren <email> Konto sperren und alle Sitzungen sofort beenden
* npm run benutzer -- entsperren <email>
* npm run benutzer -- passwort <email> neues Passwort setzen, beendet alle Sitzungen
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function passwortAbfragen(): Promise<string> {
const eingabe = createInterface({ input: process.stdin, output: process.stdout });
const passwort = await eingabe.question("Neues Passwort (mindestens 12 Zeichen): ");
eingabe.close();
if (passwort.length < 12) throw new Error("Zu kurz – mindestens 12 Zeichen.");
return passwort;
}
async function main() {
const [befehl, email] = process.argv.slice(2);
if (befehl === "liste") {
const alle = await prisma.benutzer.findMany({ orderBy: { email: "asc" } });
for (const b of alle) {
console.log(`${b.aktiv ? "aktiv " : "gesperrt"} ${b.email.padEnd(32)} ${b.name}`);
}
return;
}
if (!email) throw new Error("Bitte eine E-Mail-Adresse angeben.");
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer) throw new Error(`Kein Benutzer mit der Adresse ${email}.`);
switch (befehl) {
case "sperren":
// Sperren allein genügt nicht: Das Sitzungs-Token gilt sonst bis zum Ablauf weiter.
await prisma.benutzer.update({
where: { id: benutzer.id },
data: { aktiv: false, sitzungenUngueltigAb: new Date() },
});
console.log(`${benutzer.email} ist gesperrt, alle Sitzungen sind beendet.`);
break;
case "entsperren":
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: true } });
console.log(`${benutzer.email} ist wieder freigegeben.`);
break;
case "passwort": {
const passwort = await passwortAbfragen();
await prisma.benutzer.update({
where: { id: benutzer.id },
data: {
passwortHash: await hash(passwort),
sitzungenUngueltigAb: new Date(),
},
});
console.log(`Passwort gesetzt; bestehende Sitzungen von ${benutzer.email} sind beendet.`);
break;
}
default:
throw new Error("Bekannte Befehle: liste, sperren, entsperren, passwort");
}
}
main()
.catch((fehler: unknown) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+38 -5
View File
@@ -18,12 +18,21 @@ import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import { request as httpAnfrage } from "node:http"; import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import pg from "pg";
import "dotenv/config"; import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); 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 */ /** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite") .filter((wert, i) => process.argv[i - 1] === "--seite")
@@ -48,6 +57,9 @@ const SEITEN = [
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" }, { name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" }, { name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" }, { name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
]; ];
async function sitzungsToken() { async function sitzungsToken() {
@@ -56,14 +68,29 @@ async function sitzungsToken() {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder."); throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
} }
// Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
// Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
// Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
await datenbank.connect();
const { rows } = await datenbank.query(
`SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
);
await datenbank.end();
const benutzer = rows[0];
if (!benutzer) {
throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
}
return new SignJWT({ return new SignJWT({
benutzerId: "screenshots", benutzerId: benutzer.id,
email: process.env.ADMIN_EMAIL ?? "admin@example.de", email: benutzer.email,
name: process.env.ADMIN_NAME ?? "Florian", name: benutzer.name,
herkunft: "passwort", herkunft: "passwort",
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setSubject("screenshots") .setSubject(benutzer.id)
.setIssuedAt() .setIssuedAt()
.setIssuer("proxledger") .setIssuer("proxledger")
.setExpirationTime("1h") .setExpirationTime("1h")
@@ -168,6 +195,11 @@ async function main() {
console.log(` ${seite.name}.png – ${seite.titel}`); 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) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
@@ -176,7 +208,8 @@ async function main() {
console.log(` ${seite}-dunkel.png`); 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); const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse); await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`); console.log(` mobil-${seite}.png`);
+6 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular"; import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { oidcEinstellungen } from "@/server/auth/oidc"; import { oidcEinstellungen } from "@/server/auth/oidc";
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" /> <i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
Mit {oidc.bezeichnung} anmelden Mit {oidc.bezeichnung} anmelden
</Link> </Link>
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div> <div className="text-center text-body-secondary small mb-3">oder</div>
</> </>
) : null} ) : null}
<PasskeyAnmeldung ziel={ziel} />
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
<AnmeldeFormular ziel={ziel} /> <AnmeldeFormular ziel={ziel} />
</div> </div>
</div> </div>
@@ -0,0 +1,55 @@
import { BenutzerVerwaltung } from "@/components/einstellungen/BenutzerVerwaltung";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Callout } from "@/components/ui/Callout";
import { prisma } from "@/lib/db";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata = { title: "Benutzer" };
export const dynamic = "force-dynamic";
export default async function BenutzerSeite() {
const ich = await sitzungErforderlich("verwaltung");
const [benutzer, kunden] = await Promise.all([
prisma.benutzer.findMany({
orderBy: [{ rolle: "asc" }, { name: "asc" }],
include: { kunde: { select: { name: true } }, _count: { select: { passkeys: true } } },
}),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
const zeilen = benutzer.map((eintrag) => ({
id: eintrag.id,
email: eintrag.email,
name: eintrag.name,
rolle: eintrag.rolle,
kundeId: eintrag.kundeId,
kundeName: eintrag.kunde?.name ?? null,
aktiv: eintrag.aktiv,
hatPasswort: eintrag.passwortHash !== null,
hatOidc: eintrag.oidcSubject !== null,
passkeys: eintrag._count.passkeys,
letzterLoginAm: eintrag.letzterLoginAm,
}));
return (
<>
<ContentHeader
titel="Benutzer"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benutzer" }]}
/>
<div className="app-content">
<div className="container-fluid">
<Callout farbe="info" titel="Rollen">
<strong>Administrator</strong> darf alles, auch Benutzer und Systemeinstellungen.{" "}
<strong>Techniker</strong> darf alles Fachliche, aber keine Benutzer anlegen.{" "}
<strong>Kunde</strong> sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
</Callout>
<BenutzerVerwaltung benutzer={zeilen} kunden={kunden} ichId={ich.benutzerId} />
</div>
</div>
</>
);
}
@@ -0,0 +1,47 @@
import { headers } from "next/headers";
import { PasskeyVerwaltung } from "@/components/auth/PasskeyVerwaltung";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { oeffentlicheBasis } from "@/lib/adresse";
import { prisma } from "@/lib/db";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { passkeysMoeglich } from "@/server/auth/passkeys";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "Meine Passkeys" };
export const dynamic = "force-dynamic";
export default async function PasskeySeite() {
const ich = await sitzungErforderlich();
const { dashboardUrl } = await einstellungen();
const [passkeys, kopfzeilen] = await Promise.all([
prisma.passkey.findMany({
where: { benutzerId: ich.benutzerId },
orderBy: { erstelltAm: "desc" },
select: { id: true, bezeichnung: true, erstelltAm: true, zuletztAm: true },
}),
headers(),
]);
// Die Seite kennt die Anfrage nur über die Kopfzeilen; daraus entsteht dieselbe Adresse,
// die auch die Passkey-Endpunkte verwenden.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), dashboardUrl);
return (
<>
<ContentHeader
titel="Meine Passkeys"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Passkeys" }]}
/>
<div className="app-content">
<div className="container-fluid">
<PasskeyVerwaltung
passkeys={passkeys}
moeglich={passkeysMoeglich(basis.origin)}
adresse={basis.origin}
/>
</div>
</div>
</>
);
}
@@ -1,23 +1,32 @@
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "System" }; export const metadata = { title: "System" };
export default function SystemSeite() { export default async function SystemSeite() {
const werte = await einstellungen();
return ( return (
<> <>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} /> <ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary"> <SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
<Card titel="Systeminformationen" rahmen="keiner">
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt> <dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd> <dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt> <dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd> <dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt> <dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
<dd className="col-8">Konfigurierbar ab #011</dd> <dd className="col-8">
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
die Datenbank gelesen werden kann.
</dd>
</dl> </dl>
</Card> </Card>
</div> </div>
+55 -35
View File
@@ -16,7 +16,7 @@ import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige"; import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; 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 { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste"; import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts"; 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({ const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" }, 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). // 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> <h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div> <div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null} {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> </div>
<Zeile bezeichnung="Kunde"> <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="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile> <Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
<form action={hostKundeSetzen} className="mt-3"> <KundeStandortFormular
<input type="hidden" name="hostId" value={host.id} /> hostId={host.id}
<label className="form-label small" htmlFor="kundeId"> kunden={kunden}
Kunde ändern kundeId={host.kundeId}
</label> standortId={host.standortId}
<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>
</Card> </Card>
</div> </div>
@@ -239,7 +246,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: ( inhalt: (
<> <>
<h3 className="h6">Keys</h3> <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> <thead>
<tr> <tr>
<th scope="col">Key</th> <th scope="col">Key</th>
@@ -266,8 +274,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
<h3 className="h6">Server-IDs</h3> <h3 className="h6">Server-IDs</h3>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -295,6 +305,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
{host.serverIdHistorie.length > 1 ? ( {host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0"> <p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss 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", icon: "bi-receipt",
inhalt: ( inhalt: (
<div className="table-responsive"> <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> <thead>
<tr> <tr>
<th scope="col">Laufzeit</th> <th scope="col">Laufzeit</th>
@@ -369,21 +380,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
</thead> </thead>
<tbody> <tbody>
{host.laufzeiten.map((laufzeit) => { {host.laufzeiten.map((laufzeit) => {
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkauf = laufzeit.proxmoxPosition const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag) ? Number(laufzeit.proxmoxPosition.nettoBetrag) -
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null; : null;
const verkauf = laufzeit.kundenPosition const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag) ? Number(laufzeit.kundenPosition.nettoBetrag)
: null; : null;
return ( return (
<tr key={laufzeit.id}> <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)} {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.status === "VORSCHLAG" ? ( {laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span> <span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null} ) : null}
</td> </td>
<td> <td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div> <div>
<EinkaufBadge <EinkaufBadge
@@ -395,8 +413,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(einkauf)}</td> <td data-label="Einkauf">{formatEuro(einkauf)}</td>
<td> <td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} /> <CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div> <div>
<VerkaufBadge <VerkaufBadge
@@ -405,8 +423,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/> />
</div> </div>
</td> </td>
<td>{formatEuro(verkauf)}</td> <td data-label="Verkauf">{formatEuro(verkauf)}</td>
<td> <td data-label="Marge">
{einkauf !== null && verkauf !== null {einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf) ? formatEuro(verkauf - einkauf)
: "–"} : "–"}
@@ -443,6 +461,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Tokens", titel: "Tokens",
icon: "bi-key-fill", icon: "bi-key-fill",
inhalt: ( inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -490,6 +509,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
), ),
}, },
]} ]}
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format"; import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste"; import { hostListe, type HostFilter } from "@/server/queries/hostliste";
import { einstellungen } from "@/server/einstellungen";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number]; type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") { if (new URL(request.url).searchParams.get("format") === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Host-Liste", titel: "Host-Liste",
untertitel: `${alle.length} Hosts`, untertitel: `${alle.length} Hosts`,
quer: true, quer: true,
+24 -1
View File
@@ -68,6 +68,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = { const filter: HostFilter = {
q: einzeln(roh.q), q: einzeln(roh.q),
kunde: einzeln(roh.kunde), kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
status: einzeln(roh.status), status: einzeln(roh.status),
produkt: einzeln(roh.produkt), produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], 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> = { const basis: Record<string, string | undefined> = {
q: filter.q, q: filter.q,
kunde: filter.kunde, kunde: filter.kunde,
tag: filter.tag,
status: filter.status, status: filter.status,
produkt: filter.produkt, produkt: filter.produkt,
ablauf: filter.ablauf, 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"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
@@ -152,7 +154,28 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {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> <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>
<td> <td>
{host.kunde ? ( {host.kunde ? (
+16 -15
View File
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p> <p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
<div style={{ minWidth: "820px" }}> <div className="pl-timeline">
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}> <div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
{monate.map((monat) => ( {monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}> <div key={monat} style={{ width: `${100 / 12}%` }}>
{monat} {monat}
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
: "var(--pl-info)"; : "var(--pl-info)";
return ( return (
<div key={eintrag.hostId} className="d-flex align-items-center mb-1"> <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"> <Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname} {eintrag.hostname}
</Link> </Link>
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
)} )}
<p className="small text-body-secondary mb-0"> <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. unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
</p> </p>
</Card> </Card>
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
tabelle tabelle
> >
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th> <th scope="col">Host</th>
<th scope="col">Ablauf</th>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
<th scope="col">Key</th> <th scope="col">Key</th>
<th scope="col">Proxmox</th> <th scope="col">Proxmox</th>
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
<tbody> <tbody>
{naechste90Tage.map((eintrag) => ( {naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}> <tr key={eintrag.hostId}>
<td> <td className="pl-karten-kopf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} /> <Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname} {eintrag.hostname}
</Link> </Link>
</td> </td>
<td> <td data-label="Ablauf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td data-label="Kunde">
{eintrag.kunde ? ( {eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name} {eintrag.kunde.name}
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
<span className="badge pl-bg-neutral">Nicht zugeordnet</span> <span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)} )}
</td> </td>
<td> <td data-label="Key">
<CopyableCode wert={eintrag.key} /> <CopyableCode wert={eintrag.key} />
</td> </td>
<td> <td data-label="Proxmox">
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} /> <EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td> </td>
<td> <td data-label="Kundenrechnung">
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} /> <VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td> </td>
</tr> </tr>
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden"; import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const gesamt = summen(laufzeiten); const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten); const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: kunde.name, titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`, untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [ children: [
@@ -1,6 +1,7 @@
import { csvAntwort, csvDatei } from "@/lib/csv"; import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { einstellungen } from "@/server/einstellungen";
import { import {
kundenListe, kundenListe,
offeneRechnungspunkteProKunde, offeneRechnungspunkteProKunde,
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10); const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") { if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Kundenübersicht", titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`, untertitel: `${zeilen.length} Kunden`,
quer: true, quer: true,
+10 -8
View File
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
} }
> >
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
const punkte = rechnungspunkte.get(kunde.id); const punkte = rechnungspunkte.get(kunde.id);
return ( return (
<tr key={kunde.id}> <tr key={kunde.id}>
<td> <td className="pl-karten-kopf">
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none"> <Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name} {kunde.name}
</Link> </Link>
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div> <div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td> </td>
<td className="pl-mono">{kunde.kundennummer}</td> <td className="pl-mono" data-label="Kundennummer">
<td>{kunde.hosts}</td> {kunde.kundennummer}
<td>{kunde.aktiv}</td> </td>
<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} /> <AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
</td> </td>
<td> <td data-label="Warnungen">
{kunde.offeneAlerts > 0 ? ( {kunde.offeneAlerts > 0 ? (
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge> <Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
) : ( ) : (
<Badge farbe="success">keine</Badge> <Badge farbe="success">keine</Badge>
)} )}
</td> </td>
<td> <td data-label="Rechnungen">
{punkte && punkte.offen > 0 ? ( {punkte && punkte.offen > 0 ? (
<Badge farbe="danger">{punkte.offen} offen</Badge> <Badge farbe="danger">{punkte.offen} offen</Badge>
) : ( ) : (
+6 -6
View File
@@ -1,9 +1,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar"; import { Sidebar } from "@/components/layout/Sidebar";
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts"; import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
/** /**
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht. // Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value); // entsteht – und weil hier Rolle und Kundenbindung herkommen.
if (!sitzung) redirect("/login"); const sitzung = await sitzungErforderlich();
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]); const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
}))} }))}
suchEintraege={suche} suchEintraege={suche}
benutzer={sitzung.name} benutzer={sitzung.name}
rolle={sitzung.rolle}
/> />
<Sidebar <Sidebar
hostsAnzahl={zahlen.hostsGesamt} hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte} offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts} offeneWarnungen={zahlen.offeneAlerts}
benutzer={sitzung.name} benutzer={sitzung.name}
rolle={sitzung.rolle}
/> />
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
<Footer /> <Footer />
+22 -8
View File
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
<thead> <thead>
<tr> <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">Host</th>
<th scope="col">Kunde</th> <th scope="col" className="d-none d-md-table-cell">
<th scope="col">Key</th> Kunde
</th>
<th scope="col" className="d-none d-lg-table-cell">
Key
</th>
<th scope="col">Ablauf</th> <th scope="col">Ablauf</th>
<th scope="col">Proxmox</th> <th scope="col" className="d-none d-sm-table-cell">
<th scope="col">Kundenrechnung</th> Proxmox
</th>
<th scope="col" className="d-none d-sm-table-cell">
Kundenrechnung
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {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> <td className="d-none d-md-table-cell">
{host.kunde ? ( {host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name} {host.kunde.name}
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
</Link> </Link>
)} )}
</td> </td>
<td> <td className="d-none d-lg-table-cell">
<CopyableCode wert={host.subKey} /> <CopyableCode wert={host.subKey} />
</td> </td>
<td> <td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} /> <AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td> </td>
<td> <td className="d-none d-sm-table-cell">
<EinkaufBadge bezahlt={host.einkaufBezahlt} /> <EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td> </td>
<td> <td className="d-none d-sm-table-cell">
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} /> <VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td> </td>
</tr> </tr>
+36 -10
View File
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige"; import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen"; import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen"; import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -24,10 +25,19 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null; const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.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 verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`; const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return ( return (
@@ -102,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<hr /> <hr />
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt> <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> <dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd> <dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt> <dt className="col-5 fw-normal text-body-secondary">Marge</dt>
@@ -128,7 +145,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
) : ( ) : (
position.bezeichnung 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> </li>
))} ))}
</ul> </ul>
@@ -148,13 +173,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
werte={{ werte={{
nummer: einkauf?.nummer ?? null, nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null, datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null, faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag, nettoBetrag: einkaufListenpreis,
ustSatz: Number(einkauf?.ustSatz ?? 19), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined laufzeit.proxmoxPosition?.rabatt === null ||
laufzeit.proxmoxPosition?.rabatt === undefined
? null ? null
: Number(einkauf.rabatt), : Number(laufzeit.proxmoxPosition.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null, zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false, bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null, bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
@@ -179,8 +205,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
erstellt: verkauf?.erstellt ?? false, erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null, nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null, datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag, nettoBetrag: verkaufVorschlag,
ustSatz: Number(verkauf?.ustSatz ?? 19), ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
bezahlt: verkauf?.bezahlt ?? false, bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null, bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null, anhangId: verkauf?.anhangId ?? null,
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format"; import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument"; import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen"; import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre"; const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") { if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort( return pdfAntwort(
Bericht({ Bericht({
firma: firmaAngaben,
titel: "Rechnungsliste", titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`, untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true, quer: true,
+23 -12
View File
@@ -197,14 +197,14 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</form> </form>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle pl-karten-tabelle">
<thead> <thead>
<tr> <tr>
<th scope="col">Kunde</th> <th scope="col">Kunde</th>
<th scope="col" className="d-none d-md-table-cell"> <th scope="col" className="d-none d-md-table-cell">
Host Host
</th> </th>
<th scope="col" className="d-none d-lg-table-cell"> <th scope="col" className="pl-spalte-laufzeit">
Laufzeit Laufzeit
</th> </th>
<th scope="col">Proxmox-Rechnung</th> <th scope="col">Proxmox-Rechnung</th>
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute); const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return ( return (
<tr key={zeile.id}> <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 ? ( {zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name} {zeile.kunde.name}
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname} {zeile.hostname}
</Link> </Link>
</td> </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"> <Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)} {formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link> </Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null} {zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td> </td>
<td> <td data-label="Proxmox-Rechnung">
<CopyableCode wert={zeile.einkaufNummer} /> <CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? ( {zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger"> <div className="small pl-text-danger">
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div> </div>
) : null} ) : null}
</td> </td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td> <td className="d-none d-xl-table-cell" data-label="Einkauf">
<td className="text-center"> {formatEuro(zeile.einkaufBetrag)}
</td>
<td className="text-center" data-label="Einkauf bezahlt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Proxmox-Rechnung bezahlt" titel="Proxmox-Rechnung bezahlt"
/> />
</td> </td>
<td> <td data-label="Kundenrechnung">
<CopyableCode wert={zeile.verkaufNummer} /> <CopyableCode wert={zeile.verkaufNummer} />
</td> </td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td> <td className="d-none d-xl-table-cell" data-label="Verkauf">
<td className="text-center"> {formatEuro(zeile.verkaufBetrag)}
</td>
<td className="text-center" data-label="Rechnung erstellt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung erstellt" titel="Kundenrechnung erstellt"
/> />
</td> </td>
<td className="text-center"> <td className="text-center" data-label="Vom Kunden bezahlt">
<StatusHaken <StatusHaken
laufzeitId={zeile.id} laufzeitId={zeile.id}
hostId={zeile.hostId} hostId={zeile.hostId}
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
/> />
</td> </td>
<td <td
data-label="Marge"
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`} className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
> >
{zeile.marge === null ? "–" : formatEuro(zeile.marge)} {zeile.marge === null ? "–" : formatEuro(zeile.marge)}
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
})} })}
{zeilen.length === 0 ? ( {zeilen.length === 0 ? (
<tr> <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> Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */ /** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request)); const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh"); return skriptAntwort(inhalt, "proxledger-agent.sh");
} }
+42 -5
View File
@@ -1,7 +1,9 @@
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { clientIp } from "@/lib/ratelimit"; import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer"; import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,8 +11,35 @@ export const dynamic = "force-dynamic";
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die * Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort. * Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
*/ */
/**
* Prüft, ob das Formular von der eigenen Oberfläche kam.
*
* Ohne diese Prüfung könnte eine fremde Seite ein Anmeldeformular auf dieses Dashboard abschicken
* und das Opfer unbemerkt in ein untergeschobenes Konto anmelden (Login-CSRF, OWASP A01).
* Server Actions in Next.js prüfen das selbst; dieser Endpunkt ist ein gewöhnliches Formular
* und muss es daher selbst tun.
*/
export function herkunftStimmt(request: Request): boolean {
const herkunft = request.headers.get("origin");
// Formulare ohne Origin (etwa curl oder alte Browser) werden nicht ausgeschlossen –
// der Schutz richtet sich gegen fremde Seiten, die immer einen Origin senden.
if (!herkunft) return true;
const erlaubt = new Set<string>();
if (process.env.APP_URL) erlaubt.add(new URL(process.env.APP_URL).host);
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) erlaubt.add(gastgeber);
try {
return erlaubt.has(new URL(herkunft).host);
} catch {
return false;
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const formular = await request.formData(); const formular = await request.formData();
const { dashboardUrl } = await einstellungen();
const email = formular.get("email")?.toString().trim() ?? ""; const email = formular.get("email")?.toString().trim() ?? "";
const passwort = formular.get("passwort")?.toString() ?? ""; const passwort = formular.get("passwort")?.toString() ?? "";
const zielEingabe = formular.get("ziel")?.toString() || "/"; const zielEingabe = formular.get("ziel")?.toString() || "/";
@@ -18,12 +47,16 @@ export async function POST(request: Request) {
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/"; const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
function zurueckZurAnmeldung(meldung: string) { function zurueckZurAnmeldung(meldung: string) {
const adresse = new URL("/login", request.url); const adresse = eigeneAdresse(request, "/login", dashboardUrl);
adresse.searchParams.set("fehler", meldung); adresse.searchParams.set("fehler", meldung);
if (ziel !== "/") adresse.searchParams.set("ziel", ziel); if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
return Response.redirect(adresse, 303); return Response.redirect(adresse, 303);
} }
if (!herkunftStimmt(request)) {
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
}
if (!email || !passwort) { if (!email || !passwort) {
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben."); return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
} }
@@ -32,15 +65,19 @@ export async function POST(request: Request) {
const kopfzeilen = await headers(); const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen)); const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id, benutzerId: benutzer.id,
email: benutzer.email, email: benutzer.email,
name: benutzer.name, name: benutzer.name,
herkunft: "passwort", herkunft: "passwort",
}); },
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen()); sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303); return Response.redirect(eigeneAdresse(request, ziel, dashboardUrl), 303);
} catch (fehler) { } catch (fehler) {
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message); if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
console.error("Anmeldung fehlgeschlagen:", fehler); console.error("Anmeldung fehlgeschlagen:", fehler);
+20 -7
View File
@@ -1,7 +1,9 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { eigeneAdresse } from "@/lib/adresse";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung"; import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer"; import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc"; import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,25 +19,36 @@ export async function GET(request: Request) {
speicher.delete(name); speicher.delete(name);
} }
const { dashboardUrl } = await systemEinstellungen();
if (!einstellungen || !pruefwert || !zustand) { if (!einstellungen || !pruefwert || !zustand) {
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
try { try {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand); // Hinter einem Reverse Proxy ist `request.url` die interne Adresse. Der Anbieter hat aber die
// öffentliche als Rückkehradresse hinterlegt – mit der internen scheitert der Tausch des Codes.
const angekommen = new URL(request.url);
const oeffentlich = eigeneAdresse(request, `${angekommen.pathname}${angekommen.search}`, dashboardUrl);
const angaben = await anmeldungAbschliessen(einstellungen, oeffentlich, pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name); const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({ const { sitzungsdauerStunden } = await systemEinstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id, benutzerId: benutzer.id,
email: benutzer.email, email: benutzer.email,
name: benutzer.name, name: benutzer.name,
herkunft: "oidc", herkunft: "oidc",
}); },
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen()); sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302); return Response.redirect(eigeneAdresse(request, ziel.startsWith("/") ? ziel : "/", dashboardUrl), 302);
} catch (fehler) { } catch (fehler) {
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler); console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302); return Response.redirect(eigeneAdresse(request, "/login?fehler=oidc", dashboardUrl), 302);
} }
} }
@@ -0,0 +1,64 @@
import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen, ueberHttps } from "@/lib/sitzung";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { anmeldungPruefen, anmeldungVorbereiten, herkunft } from "@/server/auth/passkeys";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
const CHALLENGE_COOKIE = "pl_passkey_anmeldung";
function challengeOptionen() {
return {
httpOnly: true,
sameSite: "strict" as const,
secure: ueberHttps(),
path: "/",
maxAge: 300,
};
}
/** Schritt 1: Aufgabe für die Anmeldung erzeugen – ohne Benutzernamen, der Browser wählt den Schlüssel. */
export async function GET(request: Request) {
const optionen = await anmeldungVorbereiten(await herkunft(request));
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
return Response.json(optionen);
}
/** Schritt 2: Antwort prüfen und die Sitzung anlegen. */
export async function POST(request: Request) {
const speicher = await cookies();
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
if (!challenge) {
return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
}
try {
const antwort = await request.json();
const benutzer = await anmeldungPruefen(antwort, challenge, await herkunft(request));
const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passkey",
},
sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passkey" });
return Response.json({ erfolg: true });
} catch (fehler) {
console.error("Passkey-Anmeldung fehlgeschlagen:", fehler);
// Bewusst unscharf: Ob es den Passkey gibt, geht einen Unbekannten nichts an.
return Response.json({ fehler: "Anmeldung mit diesem Passkey nicht möglich." }, { status: 400 });
} finally {
speicher.delete(CHALLENGE_COOKIE);
}
}
@@ -0,0 +1,63 @@
import { cookies } from "next/headers";
import { ueberHttps } from "@/lib/sitzung";
import { protokolliere } from "@/server/audit";
import { angemeldet } from "@/server/auth/sitzungspflicht";
import { herkunft, registrierungAbschliessen, registrierungVorbereiten } from "@/server/auth/passkeys";
export const dynamic = "force-dynamic";
/** Die Aufgabe („challenge“) darf nur für diesen Browser und nur kurz gelten. */
const CHALLENGE_COOKIE = "pl_passkey_registrierung";
function challengeOptionen() {
return {
httpOnly: true,
sameSite: "strict" as const,
secure: ueberHttps(),
path: "/",
maxAge: 300,
};
}
/** Schritt 1: Aufgabe erzeugen. */
export async function GET(request: Request) {
const benutzer = await angemeldet();
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
const optionen = await registrierungVorbereiten(benutzer.benutzerId, await herkunft(request));
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
return Response.json(optionen);
}
/** Schritt 2: Antwort des Browsers prüfen und den Passkey hinterlegen. */
export async function POST(request: Request) {
const benutzer = await angemeldet();
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
const speicher = await cookies();
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
if (!challenge) return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
try {
const { antwort, bezeichnung } = await request.json();
const passkey = await registrierungAbschliessen(
benutzer.benutzerId,
antwort,
challenge,
await herkunft(request),
typeof bezeichnung === "string" ? bezeichnung : "Passkey",
);
await protokolliere("passkey.angelegt", "Passkey", passkey.id, { bezeichnung: passkey.bezeichnung });
return Response.json({ erfolg: true, bezeichnung: passkey.bezeichnung });
} catch (fehler) {
console.error("Passkey-Registrierung fehlgeschlagen:", fehler);
return Response.json(
{ fehler: fehler instanceof Error ? fehler.message : "Der Passkey ließ sich nicht hinterlegen." },
{ status: 400 },
);
} finally {
speicher.delete(CHALLENGE_COOKIE);
}
}
+173
View File
@@ -186,6 +186,19 @@ body {
display: none; 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 ---------- */ /* ---------- Inhaltsbereich ---------- */
.app-content-header { .app-content-header {
@@ -369,10 +382,170 @@ body {
transform: translateX(-50%); 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 ---------- */ /* ---------- Mobil ---------- */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.pl-touch-target { .pl-touch-target {
min-height: 44px; 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%;
}
} }
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */ /** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) { export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request)); const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh"); return skriptAntwort(inhalt, "install.sh");
} }
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { startAuthentication } from "@simplewebauthn/browser";
import { useRouter } from "next/navigation";
import { useState } from "react";
/**
* Anmeldung per Passkey. Der Knopf erscheint immer; ob ein Passkey vorliegt, weiß erst der Browser –
* und das darf die Seite auch nicht vorher verraten (sonst ließen sich Konten ausspähen).
*/
export function PasskeyAnmeldung({ ziel }: { ziel: string }) {
const router = useRouter();
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function anmelden() {
setLaeuft(true);
setFehler(null);
try {
const optionen = await fetch("/api/auth/passkey/anmelden").then((antwort) => antwort.json());
const antwort = await startAuthentication({ optionsJSON: optionen });
const ergebnis = await fetch("/api/auth/passkey/anmelden", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(antwort),
}).then((a) => a.json());
if (ergebnis.erfolg) {
router.push(ziel.startsWith("/") ? ziel : "/");
router.refresh();
} else {
setFehler(ergebnis.fehler ?? "Anmeldung nicht möglich.");
}
} catch (fehler) {
setFehler(
fehler instanceof Error && fehler.name === "NotAllowedError"
? "Abgebrochen."
: "Mit diesem Gerät geht es nicht. Passkeys brauchen HTTPS.",
);
} finally {
setLaeuft(false);
}
}
return (
<>
{fehler ? (
<div className="alert alert-danger py-2" role="alert">
{fehler}
</div>
) : null}
<button
type="button"
className="btn btn-outline-primary w-100 pl-touch-target mb-3"
onClick={anmelden}
disabled={laeuft}
>
<i className="bi bi-fingerprint me-1" aria-hidden="true" />
{laeuft ? "Warte auf den Browser …" : "Mit Passkey anmelden"}
</button>
</>
);
}

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