Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af38a4efce | ||
|
|
9c0cefcfe8 | ||
|
|
a459d3f770 | ||
|
|
4423d17b15 | ||
|
|
7ff068d9b3 | ||
|
|
2c2f842d20 |
@@ -17,6 +17,25 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.12.1] – 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Symbole für Browser-Tab (SVG und ICO), iOS-Startbildschirm und Web-Manifest, erzeugt mit `npm run icons` aus den SVG-Vorlagen in `assets/` (#017)
|
||||
|
||||
## [0.12.0] – 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Handbuch mit Bildern: Installation, Agent-Rollout, Zuweisung, Rechnungen, Warnungen, Benachrichtigungen, Exporte, Sicherung und Fehlersuche (#016)
|
||||
- `npm run screenshots` erzeugt alle Bilder des Handbuchs neu, auch im dunklen Design und in der Mobilansicht (#016)
|
||||
|
||||
## [0.11.1] – 2026-09-17
|
||||
|
||||
### Behoben
|
||||
- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013)
|
||||
|
||||
### Geändert
|
||||
- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013)
|
||||
|
||||
## [0.11.0] – 2026-09-17
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 6 h 38 min
|
||||
**Gesamt:** 7 h 23 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -22,6 +22,8 @@
|
||||
| #013 Authentifizierung | 0 h 30 min |
|
||||
| #014 Deployment | 0 h 40 min |
|
||||
| #015 Feinschliff | 0 h 35 min |
|
||||
| #016 Handbuch | 0 h 30 min |
|
||||
| #017 Favicons | 0 h 15 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -38,12 +40,13 @@
|
||||
| v0.9.0 | 0 h 30 min | **abgeschlossen**
|
||||
| v0.10.0 | 0 h 40 min | **abgeschlossen**
|
||||
| v0.11.0 | 0 h 35 min | **abgeschlossen**
|
||||
| v0.12.0 | 0 h 30 min | **abgeschlossen**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.11.0
|
||||
**Version:** 0.12.1
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -84,6 +87,9 @@
|
||||
- Meilenstein v0.10.0 abgeschlossen
|
||||
- #015 abgeschlossen: Dark-Mode geprüft (Logo war unsichtbar – behoben), Mobilansicht ohne Querscrollen, Lasttest mit 200 Hosts und 365.000 Reports, Auswertung der Warnregeln von 3813 auf 293 ms beschleunigt, Sicherheitsdurchsicht
|
||||
- Als v0.11.0 veröffentlicht statt 1.0.0: Feldtest, Handbuch, Favicons, CI und Verschlüsselung der Zugangsdaten stehen noch aus
|
||||
- 0.11.1: Anmeldefehler behoben (Secure-Cookie über http)
|
||||
- #016 abgeschlossen: Handbuch mit elf Kapiteln und 16 Bildern, dazu `npm run screenshots` zum Auffrischen
|
||||
- #017 abgeschlossen: Symbole für Tab, Startbildschirm und Manifest, erzeugt mit `npm run icons` aus SVG-Vorlagen
|
||||
|
||||
### Entscheidungen
|
||||
- **Warnungen sammelweise anlegen:** Einzelne Upserts kosteten bei 737 neuen Warnungen 3,8 s; mit `createMany` sind es 0,3 s.
|
||||
@@ -109,6 +115,7 @@
|
||||
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
|
||||
|
||||
### Probleme / offene Fragen
|
||||
- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1.
|
||||
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
|
||||
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
|
||||
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
|
||||
|
||||
@@ -173,6 +173,7 @@ SMTP und Webhook liegen (noch) unverschlüsselt in der Datenbank, eine Sicherung
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- **[Handbuch mit Bildern](docs/anleitung/README.md)** – Installation, Agent-Rollout, Rechnungen, Warnungen, Fehlersuche
|
||||
- [Architektur- und Umsetzungsplan](docs/architektur.md)
|
||||
- [Issues](docs/issues/README.md)
|
||||
- [Changelog](CHANGELOG.md) · [Devlog](DEVLOG.md)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- Fassung für 16 px: nur das P. -->
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M6 2h21a14 14 0 0 1 0 28H18v16H6zM18 13h8a3 3 0 0 1 0 6h-8z" fill="#1E2A44"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Fassung für 32 px: eine Linie, kräftigere Formen. -->
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M7 3h20a13 13 0 0 1 0 26H17v16H7zM17 12h9a4 4 0 0 1 0 8h-9z" fill="#1E2A44"/>
|
||||
<path d="M24 38h19" stroke="#17A2B8" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,8 @@
|
||||
<!-- Fassung für Startbildschirm-Symbole: Monogramm auf dunklem Grund mit Rand. -->
|
||||
<svg viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="34" fill="#1E2A44"/>
|
||||
<g transform="translate(34 30) scale(2.3)">
|
||||
<path fill-rule="evenodd" d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" fill="#FFFFFF"/>
|
||||
<path d="M22 36h20M22 43h14" stroke="#17A2B8" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,5 @@
|
||||
<!-- ProxLedger-Monogramm, Standardfassung (heller Grund). Quelle für alle abgeleiteten Dateien. -->
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z" fill="#1E2A44"/>
|
||||
<path d="M22 36h20M22 43h14" stroke="#17A2B8" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1,370 @@
|
||||
# ProxLedger – Handbuch
|
||||
|
||||
ProxLedger zeigt auf einen Blick, welche Proxmox-Subscription zu welchem Host und zu welchem Kunden
|
||||
gehört, wann sie ausläuft und ob die Rechnungen dazu erledigt sind. Auf jedem Host meldet ein kleiner
|
||||
Agent einmal täglich seinen Status – das Dashboard fragt die Hosts nie aktiv ab und funktioniert deshalb
|
||||
auch mit Hosts hinter NAT.
|
||||
|
||||
**Inhalt**
|
||||
|
||||
1. [Installation](#1-installation)
|
||||
2. [Erste Anmeldung](#2-erste-anmeldung)
|
||||
3. [Agent auf einem Host ausrollen](#3-agent-auf-einem-host-ausrollen)
|
||||
4. [Hosts einem Kunden zuweisen](#4-hosts-einem-kunden-zuweisen)
|
||||
5. [Kunden und Standorte pflegen](#5-kunden-und-standorte-pflegen)
|
||||
6. [Laufzeiten und Rechnungen](#6-laufzeiten-und-rechnungen)
|
||||
7. [Warnungen](#7-warnungen)
|
||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||
9. [Exporte](#9-exporte)
|
||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||
11. [Fehlersuche](#11-fehlersuche)
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation
|
||||
|
||||
### Mit Docker
|
||||
|
||||
```bash
|
||||
git clone <repository> proxledger && cd proxledger
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
In der `.env` mindestens setzen:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `APP_SECRET` | Schlüssel für die Sitzungen, mindestens 32 Zeichen (`openssl rand -base64 48`) |
|
||||
| `POSTGRES_PASSWORD` | Passwort der mitgelieferten Datenbank |
|
||||
| `ADMIN_EMAIL`, `ADMIN_PASSWORD` | erster Zugang zum Dashboard |
|
||||
| `APP_URL` | Adresse, unter der das Dashboard erreichbar ist |
|
||||
|
||||
Dann starten:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Beim ersten Start legt der Dienst `migrate` das Datenbankschema an und erzeugt den Administrator.
|
||||
|
||||
> **Wichtig:** `APP_URL` muss zur tatsächlichen Adresse passen. Davon hängt ab, ob das Sitzungs-Cookie als
|
||||
> `Secure` markiert wird – bei falscher Angabe funktioniert die Anmeldung nicht.
|
||||
|
||||
### Auf einem Proxmox-Host als LXC-Container
|
||||
|
||||
```bash
|
||||
bash deploy/lxc-installer.sh
|
||||
```
|
||||
|
||||
Das Skript fragt Container-ID, Ressourcen und Netzwerk ab, legt einen Debian-Container an, installiert
|
||||
Docker, holt den Quelltext, erzeugt Zufallspasswörter und startet alles. Am Ende stehen Adresse,
|
||||
Benutzername und Passwort auf dem Bildschirm; das Passwort liegt zusätzlich in
|
||||
`/root/proxledger-admin-passwort.txt` im Container.
|
||||
|
||||
Für den dauerhaften Betrieb gehört ein Reverse Proxy mit HTTPS davor – Beispiele für Caddy, nginx und
|
||||
Traefik liegen in [`deploy/reverse-proxy/`](../../deploy/reverse-proxy/).
|
||||
|
||||
---
|
||||
|
||||
## 2. Erste Anmeldung
|
||||
|
||||

|
||||
|
||||
Anmelden mit der E-Mail-Adresse und dem Passwort aus der `.env`. Ist ein Anmeldedienst über OIDC
|
||||
eingerichtet (`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`), erscheint zusätzlich ein Knopf
|
||||
„Mit authentik anmelden“. Der lokale Zugang bleibt absichtlich immer nutzbar – falls der Anmeldedienst
|
||||
einmal ausfällt, kommst du trotzdem hinein.
|
||||
|
||||
Nach der Anmeldung landest du auf dem Dashboard:
|
||||
|
||||

|
||||
|
||||
Von oben nach unten:
|
||||
|
||||
- **Vier Kacheln** mit den Kennzahlen: gemeldete Hosts, aktiv lizenziert, Ablauf innerhalb von 30 Tagen,
|
||||
offene Rechnungspunkte. Jede Kachel führt auf die passende gefilterte Liste.
|
||||
- **Subscriptions nach Ablaufdatum:** die nächste Fälligkeit zuerst, mit Kunde, Key und beiden
|
||||
Rechnungsständen. Rot bedeutet weniger als 14 Tage, gelb weniger als 30.
|
||||
- **Aufgaben:** was konkret zu tun ist. Das Häkchen setzt den Rechnungsstatus direkt.
|
||||
- **Rechnungsstatus je Kunde:** wie viele Punkte je Kunde erledigt sind.
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent auf einem Host ausrollen
|
||||
|
||||
Zuerst im Dashboard unter *Einstellungen → Agent-Tokens* einen Enrollment-Token erzeugen:
|
||||
|
||||

|
||||
|
||||
- **Bezeichnung:** wofür der Token gedacht ist, etwa „Rollout Brandl“.
|
||||
- **Hosts direkt zuordnen zu:** Wird hier ein Kunde gewählt, gehören alle mit diesem Token registrierten
|
||||
Hosts sofort zu diesem Kunden – die spätere Zuweisung entfällt.
|
||||
- **Gültigkeit und Nutzungen** begrenzen den Schaden, falls der Token verloren geht.
|
||||
|
||||
Der Token wird **nur einmal angezeigt**. Direkt darunter steht der fertige Befehl. Auf dem Proxmox-Host
|
||||
als root ausführen:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://<dashboard>/install.sh | bash -s -- --token plx_e_…
|
||||
```
|
||||
|
||||
Der Installer erkennt selbst, ob es sich um Proxmox VE, Backup Server oder Mail Gateway handelt,
|
||||
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.
|
||||
|
||||
Nützliche Befehle auf dem Host:
|
||||
|
||||
| Befehl | Zweck |
|
||||
|---|---|
|
||||
| `proxledger-agent --test` | Verbindung zum Dashboard prüfen |
|
||||
| `proxledger-agent --dry-run` | Report anzeigen, ohne ihn zu senden |
|
||||
| `proxledger-agent` | Report sofort senden |
|
||||
| `journalctl -t proxledger -n 50` | Protokoll ansehen |
|
||||
| `systemctl list-timers proxledger.timer` | nächsten Lauf anzeigen |
|
||||
|
||||
Der Timer läuft täglich mit bis zu vier Stunden Zufallsversatz, damit nicht alle Hosts gleichzeitig melden.
|
||||
Ist das Dashboard nicht erreichbar, versucht es der Agent dreimal mit wachsendem Abstand und legt den
|
||||
Report danach in `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er nachgesendet.
|
||||
|
||||
**Entfernen:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://<dashboard>/install.sh | bash -s -- --uninstall
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hosts einem Kunden zuweisen
|
||||
|
||||
Neu registrierte Hosts ohne Kundenbindung erscheinen als Hinweis auf dem Dashboard und unter
|
||||
*Hosts → Nicht zugeordnete*:
|
||||
|
||||

|
||||
|
||||
Mehrere Hosts auswählen, Kunde und optional Standort wählen, zuweisen – fertig. Die zugehörige Warnung
|
||||
schließt sich dabei von selbst.
|
||||
|
||||
In der Host-Tabelle findest du jeden Host wieder:
|
||||
|
||||

|
||||
|
||||
Die Suche greift auf Hostname, FQDN, Subscription-Key, Server-ID, IP-Adresse, Kundenname **und
|
||||
Rechnungsnummer** zu. Die Eingabe von `pve1c-a6bd21fcb6` führt also direkt zum passenden Host samt Kunde –
|
||||
genau die Zuordnung, die im Proxmox-Shop fehlt.
|
||||
|
||||
Alle Filter stehen in der Adresse: Eine gefilterte Liste lässt sich als Lesezeichen speichern oder
|
||||
weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kunden und Standorte pflegen
|
||||
|
||||

|
||||
|
||||
Die Übersicht zeigt je Kunde die Anzahl der Hosts, wie viele davon aktiv lizenziert sind, den nächsten
|
||||
Ablauf, offene Warnungen und den Rechnungsstand.
|
||||
|
||||

|
||||
|
||||
In der Detailansicht stehen Stammdaten, Standorte, alle Hosts, alle Laufzeiten und die Summen pro Jahr
|
||||
mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusammen.
|
||||
|
||||
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
|
||||
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
|
||||
|
||||
---
|
||||
|
||||
## 6. Laufzeiten und Rechnungen
|
||||
|
||||
Eine **Laufzeit** ist ein Lizenzjahr eines Hosts, zum Beispiel 24.09.2026 bis 23.09.2027. Dazu gehören
|
||||
zwei Rechnungen: die **Proxmox-Rechnung** (Einkauf) und die **Kundenrechnung** (Verkauf). Die Differenz
|
||||
ist die Marge.
|
||||
|
||||
Meldet ein Agent ein neues Ablaufdatum, legt ProxLedger die passende Laufzeit automatisch als Vorschlag
|
||||
an – du trägst nur noch die Rechnungsdaten ein.
|
||||
|
||||

|
||||
|
||||
In der Tabelle lassen sich die drei Häkchen direkt setzen:
|
||||
|
||||
- **Proxmox bezahlt** – setzt zugleich das Bezahldatum.
|
||||
- **Kundenrechnung erstellt** – gibt es noch keine Rechnung, legt das Häkchen eine ohne Nummer an; Nummer
|
||||
und Betrag trägst du später nach.
|
||||
- **Vom Kunden bezahlt**
|
||||
|
||||
Für Details die Laufzeit öffnen:
|
||||
|
||||

|
||||
|
||||
Dort stehen beide Rechnungen mit Nummer, Datum, Fälligkeit, Beträgen, Zahlungsart und PDF-Anhang. Ein
|
||||
Vorschlag lässt sich hier bestätigen oder verwerfen; mit dem Erfassen einer Proxmox-Rechnung gilt er
|
||||
automatisch als bestätigt.
|
||||
|
||||
### Sammelrechnungen
|
||||
|
||||
Eine Proxmox-Bestellung umfasst oft mehrere Keys, eine Kundenrechnung oft mehrere Hosts. Dafür braucht es
|
||||
keinen eigenen Dialog: **Trägst du bei einer zweiten Laufzeit dieselbe Rechnungsnummer ein, wird sie als
|
||||
weitere Position an die bestehende Rechnung gehängt.** Der Rechnungsbetrag ist immer die Summe der
|
||||
Positionen und wird automatisch nachgeführt.
|
||||
|
||||
### Jahresbahn
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Warnungen
|
||||
|
||||

|
||||
|
||||
Zehn Regeln laufen stündlich und zusätzlich nach jedem eingehenden Report:
|
||||
|
||||
| Regel | Meldet |
|
||||
|---|---|
|
||||
| Ablauf | Subscription läuft in 30, 14 oder 7 Tagen ab |
|
||||
| Status | Subscription ist abgelaufen, ungültig, ausgesetzt oder fehlt |
|
||||
| Sockets | lizenzierte und physische Sockets weichen ab |
|
||||
| Keine Meldung | Host meldet sich seit über 48 Stunden nicht |
|
||||
| Server-ID | Server-ID hat sich geändert (Hardwaretausch oder Neuinstallation) |
|
||||
| Key mehrfach | derselbe Key wird von mehreren Hosts gemeldet |
|
||||
| Ohne Kunde | Host ist keinem Kunden zugewiesen |
|
||||
| Proxmox-Rechnung | unbezahlt und in sieben Tagen fällig oder überfällig |
|
||||
| Kundenrechnung fehlt | Einkauf erfasst, Verkauf noch nicht erstellt |
|
||||
| Kundenrechnung offen | seit über 30 Tagen nicht bezahlt |
|
||||
|
||||
Eine Warnung wird genau einmal geöffnet und **schließt sich selbst**, sobald die Ursache behoben ist.
|
||||
*Quittieren* markiert sie als gesehen, *Schließen* beendet sie von Hand – besteht die Ursache weiter,
|
||||
kommt sie beim nächsten Durchlauf zurück.
|
||||
|
||||
Schwellwerte und Ein-/Ausschalter stehen unter *Einstellungen → Warnregeln*:
|
||||
|
||||

|
||||
|
||||
Mehrere Stufen werden mit Komma angegeben, etwa `30, 14, 7`. Nach dem Speichern wird sofort neu
|
||||
ausgewertet.
|
||||
|
||||
---
|
||||
|
||||
## 8. Benachrichtigungen einrichten
|
||||
|
||||

|
||||
|
||||
**E-Mail über Microsoft 365:** Server `smtp.office365.com`, Port 587, Benutzer und Absender identisch mit
|
||||
dem Postfach. Das Passwort wird gespeichert und nie wieder angezeigt; ein leeres Feld beim Speichern lässt
|
||||
den bisherigen Wert stehen.
|
||||
|
||||
**Webhook:** beliebige Adresse, die JSON entgegennimmt – damit lassen sich Matrix, Zabbix oder Home
|
||||
Assistant anbinden. Mit hinterlegtem Geheimnis kommt die Signatur `X-ProxLedger-Signatur: sha256=…` mit,
|
||||
damit der Empfänger die Echtheit prüfen kann.
|
||||
|
||||
**Zusammenfassung:** täglich oder wöchentlich um 07:00 Uhr mit offenen Warnungen, Rechnungspunkten und
|
||||
bald ablaufenden Subscriptions.
|
||||
|
||||
Über *Test senden* lässt sich der Weg sofort prüfen; das Ergebnis steht im Versandprotokoll darunter.
|
||||
Zu jeder Warnung wird nur einmal benachrichtigt. Schlägt der Versand fehl, wird er beim nächsten Durchlauf
|
||||
erneut versucht.
|
||||
|
||||
---
|
||||
|
||||
## 9. Exporte
|
||||
|
||||
| Ansicht | CSV | PDF |
|
||||
|---|---|---|
|
||||
| Hosts | ✓ | ✓ |
|
||||
| Kunden | ✓ | ✓ |
|
||||
| Einzelner Kunde | – | ✓ (Anlage für Kundenunterlagen) |
|
||||
| Rechnungen | ✓ | ✓ |
|
||||
|
||||
Alle Exporte übernehmen die gerade aktiven Filter und enthalten sämtliche Treffer, nicht nur die
|
||||
angezeigte Seite. CSV kommt als UTF-8 mit BOM und Semikolon – Excel öffnet die Datei ohne Umwege, Umlaute
|
||||
und Beträge stimmen.
|
||||
|
||||
Die PDF-Ausgaben tragen das einfarbige Logo und eignen sich als Anlage für Kundenunterlagen oder als
|
||||
Jahresliste für die Buchhaltung.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sicherung und Aktualisierung
|
||||
|
||||
**Sichern:**
|
||||
|
||||
```bash
|
||||
./deploy/backup.sh /var/backups/proxledger
|
||||
```
|
||||
|
||||
Das Skript sichert Datenbank, hochgeladene PDFs und die `.env`. Für regelmäßige Sicherungen in die
|
||||
Crontab eintragen:
|
||||
|
||||
```
|
||||
30 2 * * * /opt/proxledger/deploy/backup.sh /var/backups/proxledger
|
||||
```
|
||||
|
||||
**Zurückspielen:**
|
||||
|
||||
```bash
|
||||
gunzip -c datenbank-20260917-023000.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
|
||||
```
|
||||
|
||||
**Aktualisieren:**
|
||||
|
||||
```bash
|
||||
cd /opt/proxledger
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Migrationen laufen dabei automatisch.
|
||||
|
||||
> `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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fehlersuche
|
||||
|
||||
**Ein Host meldet sich nicht.**
|
||||
Auf dem Host `proxledger-agent --test` ausführen. Kommt keine Antwort, prüfen, ob das Dashboard vom Host
|
||||
aus erreichbar ist (Firewall, DNS) und ob der Host-Token noch gültig ist – unter *Einstellungen →
|
||||
Agent-Tokens* steht, wann er zuletzt benutzt wurde. Liegen Dateien in `/var/lib/proxledger/spool/`,
|
||||
wartet der Agent auf eine Verbindung und sendet später nach.
|
||||
|
||||
**Nach der Anmeldung lande ich wieder auf der Anmeldeseite.**
|
||||
Dann passt `APP_URL` nicht zur tatsächlichen Adresse: Steht dort `https://`, das Dashboard läuft aber über
|
||||
`http://`, verwirft der Browser das Sitzungs-Cookie. `APP_URL` korrigieren und neu starten.
|
||||
|
||||
**Eine Warnung taucht immer wieder auf.**
|
||||
Das ist beabsichtigt: Warnungen beschreiben einen Zustand. Solange die Ursache besteht, öffnet die nächste
|
||||
Auswertung sie erneut. Passt der Schwellwert nicht, lässt er sich unter *Einstellungen → Warnregeln*
|
||||
ändern, oder die Regel wird abgeschaltet.
|
||||
|
||||
**Server-ID hat sich geändert.**
|
||||
Nach Hardwaretausch oder Neuinstallation vergibt Proxmox eine neue Server-ID. Der Key muss dann im
|
||||
Proxmox-Shop neu zugewiesen werden. ProxLedger behält den Host samt Historie – die alte und die neue
|
||||
Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
||||
|
||||
**Was ist wann passiert?**
|
||||
*Einstellungen → Protokoll* zeigt jede Änderung mit Zeitpunkt, Aktion und Benutzer.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Anhang: dunkles Design und Mobilansicht
|
||||
|
||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||
Kopfleiste umschalten.
|
||||
|
||||

|
||||
|
||||
Auf dem Handy klappt die Seitenleiste ein, und die großen Tabellen blenden gestaffelt Spalten aus, sodass
|
||||
Host, Kunde, Status und Ablauf ohne seitliches Scrollen sichtbar bleiben.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
*Die Bilder in diesem Handbuch entstehen mit `npm run screenshots -- --alle` aus einer laufenden
|
||||
Installation mit Beispieldaten und sollten vor jeder Veröffentlichung aufgefrischt werden.*
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 32 KiB |
@@ -33,3 +33,8 @@ Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen.
|
||||
freigeschaltet ist, soll auch hier arbeiten können.
|
||||
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
|
||||
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
|
||||
- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein
|
||||
produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte
|
||||
damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet
|
||||
das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine
|
||||
Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# #016 Benutzerhandbuch mit Screenshots
|
||||
**Status:** offen
|
||||
**Meilenstein:** v0.10.0
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.12.0
|
||||
**Labels:** docs
|
||||
**Geschätzt:** 5 h **Tatsächlich:** –
|
||||
**Geschätzt:** 5 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Ziel
|
||||
Bebildertes Handbuch auf Deutsch, das den kompletten Weg zeigt: Dashboard installieren, Enrollment-Token erzeugen,
|
||||
@@ -10,17 +10,17 @@ Agent auf einem Host ausrollen, Host einem Kunden zuweisen, Laufzeit und Rechnun
|
||||
Benachrichtigungen einrichten.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Gliederung `docs/anleitung/README.md` (Installation, Erste Schritte, Agent ausrollen, Kunden und Hosts, Rechnungen, Warnungen, Benachrichtigungen, Backup)
|
||||
- [ ] Screenshots automatisiert erzeugen (`npm run screenshots`): Seiten anfahren, Aufnahmen nach `docs/anleitung/bilder/` schreiben
|
||||
- [ ] Aufnahmen in Hell und Dunkel, dazu eine Mobilansicht
|
||||
- [ ] Bilder mit Bildunterschriften und Alternativtexten einbinden
|
||||
- [ ] Screenshots vor jedem Release auffrischen (Hinweis in der Release-Checkliste)
|
||||
- [ ] Verweise aus README und Dashboard-Hilfe auf das Handbuch
|
||||
- [x] Gliederung `docs/anleitung/README.md` (Installation, Erste Schritte, Agent ausrollen, Kunden und Hosts, Rechnungen, Warnungen, Benachrichtigungen, Backup)
|
||||
- [x] Screenshots automatisiert erzeugen (`npm run screenshots`): Seiten anfahren, Aufnahmen nach `docs/anleitung/bilder/` schreiben
|
||||
- [x] Aufnahmen in Hell und Dunkel, dazu eine Mobilansicht
|
||||
- [x] Bilder mit Bildunterschriften und Alternativtexten einbinden
|
||||
- [x] Screenshots vor jedem Release auffrischen (Hinweis in der Release-Checkliste)
|
||||
- [x] Verweise aus README und Dashboard-Hilfe auf das Handbuch
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Jemand ohne Vorkenntnisse kann anhand des Handbuchs Dashboard und Agent installieren
|
||||
- [ ] Jeder Arbeitsschritt hat mindestens ein Bild
|
||||
- [ ] Bilder zeigen Beispieldaten, keine echten Kundendaten
|
||||
- [x] Jemand ohne Vorkenntnisse kann anhand des Handbuchs Dashboard und Agent installieren
|
||||
- [x] Jeder Arbeitsschritt hat mindestens ein Bild
|
||||
- [x] Bilder zeigen Beispieldaten, keine echten Kundendaten
|
||||
|
||||
## Abhängigkeiten
|
||||
#014
|
||||
@@ -31,3 +31,9 @@ Benachrichtigungen einrichten.
|
||||
Das Profil braucht `user_pref("layout.css.prefers-color-scheme.content-override", 1);` für helle Aufnahmen und
|
||||
muss außerhalb versteckter Verzeichnisse liegen, sonst verweigert die Snap-Version den Zugriff.
|
||||
- Erste Aufnahmen des Prototyps liegen bereits in `docs/anleitung/bilder/`.
|
||||
- Das Handbuch liegt unter `docs/anleitung/README.md` mit elf Kapiteln und 16 Bildern.
|
||||
- `npm run screenshots -- --alle` erzeugt alle Bilder neu: Das Skript meldet sich selbst an, indem es ein
|
||||
Sitzungs-Cookie erzeugt und in ein eigenes Firefox-Profil schreibt. Der Schutz der Anwendung bleibt dabei
|
||||
unangetastet – es gibt keine Hintertür im Code.
|
||||
- Ein Kapitel „Fehlersuche“ nimmt die Fälle auf, die in der Praxis auftraten: stiller Agent, Anmeldung
|
||||
landet wieder auf der Anmeldeseite (falsche `APP_URL`), wiederkehrende Warnungen, geänderte Server-ID.
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# #017 Favicon-Dateien erzeugen
|
||||
**Status:** offen
|
||||
**Meilenstein:** v0.6.0
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.12.1
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 15 min
|
||||
|
||||
## Ziel
|
||||
Das Monogramm liegt als React-Komponente vor. Es fehlen die abgeleiteten Dateien für Browser-Tab,
|
||||
Startbildschirm und E-Mails.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] `favicon.svg` (32-px-Variante mit einer Linie) unter `src/app/icon.svg`
|
||||
- [ ] `favicon.ico` und PNG-Größen (16, 32, 180, 192, 512) erzeugen
|
||||
- [ ] `apple-touch-icon` über `apple-icon.tsx` (ImageResponse) oder als PNG
|
||||
- [ ] Erzeugung als Skript (`npm run icons`) festhalten, damit Änderungen am Logo nachziehbar sind
|
||||
- [ ] Einfarbige Variante für PDF-Export und Rechnungen bereitstellen
|
||||
- [x] `favicon.svg` (32-px-Variante mit einer Linie) unter `src/app/icon.svg`
|
||||
- [x] `favicon.ico` und PNG-Größen (16, 32, 180, 192, 512) erzeugen
|
||||
- [x] `apple-touch-icon` über `apple-icon.tsx` (ImageResponse) oder als PNG
|
||||
- [x] Erzeugung als Skript (`npm run icons`) festhalten, damit Änderungen am Logo nachziehbar sind
|
||||
- [x] Einfarbige Variante für PDF-Export und Rechnungen bereitstellen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Browser-Tab zeigt das Monogramm in Hell und Dunkel
|
||||
- [ ] Beim Ablegen auf dem iOS-Startbildschirm erscheint das Logo ohne weißen Rand
|
||||
- [x] Browser-Tab zeigt das Monogramm in Hell und Dunkel
|
||||
- [x] Beim Ablegen auf dem iOS-Startbildschirm erscheint das Logo ohne weißen Rand
|
||||
|
||||
## Abhängigkeiten
|
||||
#002
|
||||
@@ -25,3 +25,13 @@ Startbildschirm und E-Mails.
|
||||
## Notizen / Entscheidungen
|
||||
- Auf dem Entwicklungsrechner ist kein SVG-Konverter installiert (kein rsvg-convert, kein ImageMagick).
|
||||
Entweder `sharp` als Entwicklungsabhängigkeit aufnehmen oder Next.js `ImageResponse` nutzen.
|
||||
- Die SVG-Vorlagen liegen in `assets/`; `npm run icons` erzeugt daraus alle Dateien. Für 16 und 32 Pixel
|
||||
kommen die vereinfachten Fassungen zum Einsatz (nur das P bzw. eine Linie), sonst verschwimmen die feinen
|
||||
Linien.
|
||||
- `favicon.ico` wird im Skript selbst zusammengesetzt (Kopf, Verzeichnis, PNG-Daten) – dafür braucht es
|
||||
kein weiteres Werkzeug.
|
||||
- Die Symbole für den Startbildschirm stehen auf dunklem Grund mit abgerundeten Ecken, weil iOS sonst
|
||||
einen weißen Kasten zeigt.
|
||||
- Dazu ein `manifest.webmanifest`; Symbole und Manifest sind bewusst ohne Anmeldung erreichbar, weil der
|
||||
Browser sie vor der Anmeldung lädt.
|
||||
- Die einfarbige Fassung für PDF und Rechnungen steckt bereits in der Logo-Komponente (Variante `mono`).
|
||||
|
||||
@@ -19,10 +19,10 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min |
|
||||
| [#014](014-deployment.md) | Deployment und Dokumentation | erledigt | v0.10.0 | 8 h | 0 h 40 min |
|
||||
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | erledigt | v0.11.0 | 6 h | 0 h 35 min |
|
||||
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
|
||||
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – |
|
||||
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | erledigt | v0.12.0 | 5 h | 0 h 30 min |
|
||||
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | erledigt | v0.12.1 | 1 h | 0 h 15 min |
|
||||
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
|
||||
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
|
||||
| [#019](019-ci-pipeline.md) | CI-Pipeline | in Arbeit | v0.10.0 | 3 h | – |
|
||||
| [#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 | offen | v0.9.0 | 2 h | – |
|
||||
| | **Summe** | | | **116 h** | **6 h 38 min** |
|
||||
| | **Summe** | | | **116 h** | **7 h 23 min** |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxledger",
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -37,6 +37,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.7",
|
||||
"prisma": "^7.10.0",
|
||||
"sharp": "^0.35.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.1"
|
||||
@@ -1013,8 +1014,8 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -9117,8 +9118,8 @@
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -9167,8 +9168,8 @@
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -23,7 +23,9 @@
|
||||
"alerts:check": "tsx scripts/alerts-pruefen.ts",
|
||||
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
|
||||
"build:all": "npm run build && npm run build:worker",
|
||||
"lasttest": "tsx scripts/lasttest.ts"
|
||||
"lasttest": "tsx scripts/lasttest.ts",
|
||||
"screenshots": "node scripts/screenshots.mjs",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
@@ -54,6 +56,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.7",
|
||||
"prisma": "^7.10.0",
|
||||
"sharp": "^0.35.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.1"
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Erzeugt alle Symboldateien aus den SVG-Vorlagen in `assets/`.
|
||||
*
|
||||
* npm run icons
|
||||
*
|
||||
* Ergebnis:
|
||||
* src/app/icon.svg – Symbol im Browser-Tab (moderne Browser)
|
||||
* src/app/favicon.ico – Rückfall für ältere Browser (16, 32, 48 px)
|
||||
* public/icon-192.png – Startbildschirm und Fortschrittsanzeigen
|
||||
* public/icon-512.png
|
||||
* src/app/apple-icon.png – iOS-Startbildschirm (180 px, mit Hintergrund)
|
||||
*
|
||||
* Die Größen 16 und 32 nutzen vereinfachte Vorlagen, weil die feinen Linien sonst verschwimmen.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const WURZEL = process.cwd();
|
||||
const ASSETS = path.join(WURZEL, "assets");
|
||||
|
||||
async function png(vorlage, groesse) {
|
||||
const svg = await readFile(path.join(ASSETS, vorlage));
|
||||
return sharp(svg, { density: 384 }).resize(groesse, groesse).png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine ICO-Datei aus PNG-Bildern. Das Format ist schlicht: ein Kopf, je Bild ein
|
||||
* Verzeichniseintrag, danach die Bilddaten – PNG als Inhalt ist seit Vista erlaubt.
|
||||
*/
|
||||
function icoBauen(bilder) {
|
||||
const kopf = Buffer.alloc(6);
|
||||
kopf.writeUInt16LE(0, 0); // reserviert
|
||||
kopf.writeUInt16LE(1, 2); // Typ 1 = Symbol
|
||||
kopf.writeUInt16LE(bilder.length, 4);
|
||||
|
||||
let versatz = 6 + bilder.length * 16;
|
||||
const eintraege = [];
|
||||
for (const bild of bilder) {
|
||||
const eintrag = Buffer.alloc(16);
|
||||
eintrag.writeUInt8(bild.groesse >= 256 ? 0 : bild.groesse, 0); // Breite (0 = 256)
|
||||
eintrag.writeUInt8(bild.groesse >= 256 ? 0 : bild.groesse, 1); // Höhe
|
||||
eintrag.writeUInt8(0, 2); // Farben in der Palette
|
||||
eintrag.writeUInt8(0, 3); // reserviert
|
||||
eintrag.writeUInt16LE(1, 4); // Farbebenen
|
||||
eintrag.writeUInt16LE(32, 6); // Bits je Bildpunkt
|
||||
eintrag.writeUInt32LE(bild.daten.length, 8);
|
||||
eintrag.writeUInt32LE(versatz, 12);
|
||||
eintraege.push(eintrag);
|
||||
versatz += bild.daten.length;
|
||||
}
|
||||
|
||||
return Buffer.concat([kopf, ...eintraege, ...bilder.map((b) => b.daten)]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(path.join(WURZEL, "public"), { recursive: true });
|
||||
|
||||
// Tab-Symbol: das SVG selbst, damit es in jeder Größe scharf bleibt.
|
||||
const svg = await readFile(path.join(ASSETS, "logo-32.svg"));
|
||||
await writeFile(path.join(WURZEL, "src", "app", "icon.svg"), svg);
|
||||
console.log(" src/app/icon.svg");
|
||||
|
||||
const ico = icoBauen([
|
||||
{ groesse: 16, daten: await png("logo-16.svg", 16) },
|
||||
{ groesse: 32, daten: await png("logo-32.svg", 32) },
|
||||
{ groesse: 48, daten: await png("logo-32.svg", 48) },
|
||||
]);
|
||||
await writeFile(path.join(WURZEL, "src", "app", "favicon.ico"), ico);
|
||||
console.log(` src/app/favicon.ico (${(ico.length / 1024).toFixed(1)} KB)`);
|
||||
|
||||
for (const groesse of [192, 512]) {
|
||||
const daten = await png("logo-kachel.svg", groesse);
|
||||
await writeFile(path.join(WURZEL, "public", `icon-${groesse}.png`), daten);
|
||||
console.log(` public/icon-${groesse}.png`);
|
||||
}
|
||||
|
||||
const apple = await png("logo-kachel.svg", 180);
|
||||
await writeFile(path.join(WURZEL, "src", "app", "apple-icon.png"), apple);
|
||||
console.log(" src/app/apple-icon.png");
|
||||
}
|
||||
|
||||
console.log("Erzeuge Symbole aus assets/:");
|
||||
main()
|
||||
.then(() => console.log("Fertig."))
|
||||
.catch((fehler) => {
|
||||
console.error(fehler.message ?? fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
|
||||
*
|
||||
* npm run screenshots # heller Modus, Desktop
|
||||
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
|
||||
*
|
||||
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
|
||||
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
|
||||
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { SignJWT } from "jose";
|
||||
import "dotenv/config";
|
||||
|
||||
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||
const ALLE = process.argv.includes("--alle");
|
||||
|
||||
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
|
||||
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
|
||||
|
||||
const SEITEN = [
|
||||
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
|
||||
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
|
||||
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
|
||||
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
|
||||
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
|
||||
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
|
||||
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
|
||||
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
|
||||
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
|
||||
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||
];
|
||||
|
||||
async function sitzungsToken() {
|
||||
const geheimnis = process.env.APP_SECRET;
|
||||
if (!geheimnis || geheimnis.length < 32) {
|
||||
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
||||
}
|
||||
|
||||
return new SignJWT({
|
||||
benutzerId: "screenshots",
|
||||
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
|
||||
name: process.env.ADMIN_NAME ?? "Florian",
|
||||
herkunft: "passwort",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("screenshots")
|
||||
.setIssuedAt()
|
||||
.setIssuer("proxledger")
|
||||
.setExpirationTime("1h")
|
||||
.sign(new TextEncoder().encode(geheimnis));
|
||||
}
|
||||
|
||||
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */
|
||||
function profilVorbereiten(name, token, dunkel) {
|
||||
const verzeichnis = path.join(PROFIL_BASIS, name);
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
mkdirSync(verzeichnis, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
path.join(verzeichnis, "user.js"),
|
||||
[
|
||||
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
|
||||
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
|
||||
'user_pref("browser.shell.checkDefaultBrowser", false);',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const { hostname, protocol } = new URL(BASIS);
|
||||
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
|
||||
datenbank.exec(`
|
||||
CREATE TABLE moz_cookies (
|
||||
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
|
||||
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
|
||||
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
|
||||
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
|
||||
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
|
||||
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
|
||||
)`);
|
||||
|
||||
const jetzt = Date.now();
|
||||
datenbank
|
||||
.prepare(
|
||||
`INSERT INTO moz_cookies
|
||||
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
|
||||
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
|
||||
)
|
||||
.run(
|
||||
token,
|
||||
hostname,
|
||||
Math.floor(jetzt / 1000) + 3600,
|
||||
jetzt * 1000,
|
||||
jetzt * 1000,
|
||||
protocol === "https:" ? 1 : 0,
|
||||
protocol === "https:" ? 2 : 1,
|
||||
jetzt * 1000,
|
||||
);
|
||||
datenbank.close();
|
||||
|
||||
return verzeichnis;
|
||||
}
|
||||
|
||||
function aufnehmen(profil, pfad, datei, breite, hoehe) {
|
||||
execFileSync(
|
||||
"firefox",
|
||||
[
|
||||
"--profile",
|
||||
profil,
|
||||
"--headless",
|
||||
"--window-size",
|
||||
`${breite},${hoehe}`,
|
||||
"--screenshot",
|
||||
datei,
|
||||
`${BASIS}${pfad}`,
|
||||
],
|
||||
{ stdio: "ignore", timeout: 120_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
|
||||
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
|
||||
|
||||
mkdirSync(ZIEL, { recursive: true });
|
||||
const token = await sitzungsToken();
|
||||
|
||||
const hell = profilVorbereiten("hell", token, false);
|
||||
console.log(`Bilder nach ${ZIEL}`);
|
||||
|
||||
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
|
||||
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900);
|
||||
console.log(" login.png");
|
||||
|
||||
for (const seite of SEITEN) {
|
||||
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150);
|
||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||
}
|
||||
|
||||
if (ALLE) {
|
||||
const dunkel = profilVorbereiten("dunkel", token, true);
|
||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150);
|
||||
console.log(` ${seite}-dunkel.png`);
|
||||
}
|
||||
|
||||
for (const seite of ["dashboard", "hosts"]) {
|
||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500);
|
||||
console.log(` mobil-${seite}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
|
||||
console.log("Fertig.");
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error(fehler.message ?? fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -30,9 +30,7 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
||||
<div className="card-body">
|
||||
{fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{fehler === "oidc"
|
||||
? "Die Anmeldung über den Anbieter hat nicht geklappt."
|
||||
: "Anmeldung nicht möglich."}
|
||||
{fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -184,9 +184,7 @@ export default async function DashboardSeite() {
|
||||
titel="Aufgaben"
|
||||
rahmen="warning"
|
||||
werkzeuge={
|
||||
<span className="badge pl-bg-warning">
|
||||
{aufgabenDaten.gesamt + ohneKunde.length}
|
||||
</span>
|
||||
<span className="badge pl-bg-warning">{aufgabenDaten.gesamt + ohneKunde.length}</span>
|
||||
}
|
||||
>
|
||||
<TodoList aufgaben={aufgaben} />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const formular = await request.formData();
|
||||
const email = formular.get("email")?.toString().trim() ?? "";
|
||||
const passwort = formular.get("passwort")?.toString() ?? "";
|
||||
const zielEingabe = formular.get("ziel")?.toString() || "/";
|
||||
// Nur eigene Pfade zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
||||
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
|
||||
|
||||
function zurueckZurAnmeldung(meldung: string) {
|
||||
const adresse = new URL("/login", request.url);
|
||||
adresse.searchParams.set("fehler", meldung);
|
||||
if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
|
||||
return Response.redirect(adresse, 303);
|
||||
}
|
||||
|
||||
if (!email || !passwort) {
|
||||
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
||||
}
|
||||
|
||||
try {
|
||||
const kopfzeilen = await headers();
|
||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passwort",
|
||||
});
|
||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||
|
||||
return Response.redirect(new URL(ziel, request.url), 303);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
|
||||
console.error("Anmeldung fehlgeschlagen:", fehler);
|
||||
return zurueckZurAnmeldung("Anmeldung derzeit nicht möglich.");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ueberHttps } from "@/lib/sitzung";
|
||||
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
|
||||
const optionen = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<!-- Fassung für 32 px: eine Linie, kräftigere Formen. -->
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M7 3h20a13 13 0 0 1 0 26H17v16H7zM17 12h9a4 4 0 0 1 0 8h-9z" fill="#1E2A44"/>
|
||||
<path d="M24 38h19" stroke="#17A2B8" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1,19 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/** Angaben für den Startbildschirm, wenn das Dashboard als Verknüpfung abgelegt wird. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "ProxLedger – Proxmox Subscription Dashboard",
|
||||
short_name: "ProxLedger",
|
||||
description: "Subscription-Status aller Proxmox-Hosts im Blick",
|
||||
lang: "de",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F4F6F9",
|
||||
theme_color: "#1E2A44",
|
||||
icons: [
|
||||
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { anmelden } from "@/server/actions/auth";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
/**
|
||||
* Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die
|
||||
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort.
|
||||
*/
|
||||
export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<form method="post" action="/api/auth/anmelden">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{ergebnis.fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
@@ -47,8 +35,8 @@ export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Meldet an …" : "Anmelden"}
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -53,11 +53,22 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Secure` richtet sich nach der tatsächlichen Adresse, nicht nach NODE_ENV:
|
||||
* Ein produktiv gebautes Dashboard, das über http:// läuft (etwa im LAN oder beim lokalen Prüfen),
|
||||
* würde sonst ein Cookie setzen, das der Browser verwirft – die Anmeldung liefe dann ins Leere.
|
||||
*/
|
||||
export function ueberHttps(): boolean {
|
||||
const adresse = process.env.APP_URL;
|
||||
if (adresse) return adresse.startsWith("https://");
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
export function cookieOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,13 @@ const OEFFENTLICH = [
|
||||
"/api/gesundheit",
|
||||
"/install.sh",
|
||||
"/agent.sh",
|
||||
// Symbole und Manifest: Der Browser holt sie auch vor der Anmeldung.
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/apple-icon.png",
|
||||
"/icon-192.png",
|
||||
"/icon-512.png",
|
||||
"/manifest.webmanifest",
|
||||
];
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Anmeldung mit E-Mail und Passwort (Rückfallweg, wenn OIDC nicht erreichbar ist). */
|
||||
export async function anmelden(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const email = formular.get("email")?.toString().trim() ?? "";
|
||||
const passwort = formular.get("passwort")?.toString() ?? "";
|
||||
const ziel = formular.get("ziel")?.toString() || "/";
|
||||
|
||||
if (!email || !passwort) {
|
||||
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
|
||||
}
|
||||
|
||||
try {
|
||||
const kopfzeilen = await headers();
|
||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passwort",
|
||||
});
|
||||
|
||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
// Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
||||
redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/");
|
||||
}
|
||||
|
||||
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
|
||||
export async function abmelden() {
|
||||
const speicher = await cookies();
|
||||
speicher.delete(SITZUNGS_COOKIE);
|
||||
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||
await protokolliere("abmeldung", "Benutzer", null, {});
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -54,7 +54,18 @@ describe("Sitzungen", () => {
|
||||
).rejects.toThrow(/APP_SECRET/);
|
||||
});
|
||||
|
||||
it("setzt das Cookie nur über HTTPS, wenn produktiv gebaut wurde", () => {
|
||||
it("setzt Secure nur, wenn das Dashboard über HTTPS läuft", () => {
|
||||
const vorher = process.env.APP_URL;
|
||||
|
||||
process.env.APP_URL = "https://proxledger.example.de";
|
||||
expect(cookieOptionen().secure).toBe(true);
|
||||
|
||||
// Produktiv gebaut, aber über http erreichbar: sonst verwirft der Browser das Cookie
|
||||
// und die Anmeldung landet sofort wieder auf der Anmeldeseite.
|
||||
process.env.APP_URL = "http://192.168.1.50:3000";
|
||||
expect(cookieOptionen().secure).toBe(false);
|
||||
|
||||
process.env.APP_URL = vorher;
|
||||
expect(cookieOptionen().httpOnly).toBe(true);
|
||||
expect(cookieOptionen().sameSite).toBe("lax");
|
||||
});
|
||||
|
||||