Compare commits

..
6 Commits
Author SHA1 Message Date
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
44 changed files with 470 additions and 54 deletions
+19
View File
@@ -17,6 +17,25 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [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
+12 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 16 h 08 min
**Gesamt:** 17 h 38 min
| Issue | Zeit |
|---|---|
@@ -37,6 +37,9 @@
| #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 |
| Meilenstein | Zeit |
|---|---|
@@ -59,7 +62,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.20.0
**Version:** 0.20.2
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -173,6 +176,13 @@
- 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
### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+24 -2
View File
@@ -6,6 +6,10 @@ installieren Node.js 24 und PostgreSQL 17, holen ProxLedger aus dem Git-Reposito
systemd-Dienste ein (Dashboard und Worker) und schreiben die erzeugten Zugangsdaten nach
`/opt/proxledger/.credentials`.
**Ressourcen:** Die Vorgabe ist 2 CPU-Kerne, **4 GB RAM** und 12 GB Platte. Die 4 GB braucht
`next build` während der Installation; im Betrieb genügen 2 GB, die sich im Nachhinein in Proxmox
wieder absenken lassen.
| Datei | Zweck |
|---|---|
| `ct/proxledger.sh` | Container anlegen, Ressourcen abfragen, Aktualisierung (`update_script`) |
@@ -19,8 +23,26 @@ Auf dem Proxmox-VE-Host als root:
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts/ct/proxledger.sh)"
```
Das Gerüst (`build.func`, `install.func`) lädt das Skript von den community-scripts nach – das ist
öffentlich und ohne Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git.
Das Gerüst (`build.func`) lädt das Skript von den community-scripts nach – das ist öffentlich und ohne
Konto erreichbar. Nur ProxLedger selbst kommt aus dem eigenen Git.
**Wichtig:** Ein per `curl` geladenes ct-Skript kann seine eigene Herkunft nicht ermitteln. Ohne
Angabe sucht das Gerüst `install/proxledger-install.sh` bei den community-scripts auf GitHub und
bricht mit *404 – Could not fetch install/proxledger-install.sh* ab. Das ct-Skript setzt deshalb
```bash
COMMUNITY_SCRIPTS_URL="https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts"
```
selbst. Für einen anderen Zweig oder einen Fork lässt sich die Angabe von außen überschreiben:
```bash
COMMUNITY_SCRIPTS_URL=https://git.example.de/<nutzer>/ProxLedger/raw/branch/<zweig>/deploy/community-scripts \
bash -c "$(curl -fsSL …/ct/proxledger.sh)"
```
Das Verzeichnis dahinter muss `ct/` und `install/` enthalten – deshalb zeigt die Angabe auf
`deploy/community-scripts`, nicht auf die Wurzel des Repositorys.
Zwei Umgebungsvariablen steuern die Quelle, solange das Repository nicht öffentlich ist:
+10 -3
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash
_CS_DEFAULT_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
# Woher die App-Skripte kommen. Ein per curl geladenes ct-Skript kann seine eigene Herkunft nicht
# ermitteln – ohne diese Angabe sucht das Geruest install/proxledger-install.sh bei den
# community-scripts und bricht mit 404 ab. Ueberschreibbar fuer eigene Zweige oder Forks.
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.wilde-server.de/florian.wilde/ProxLedger/raw/branch/main/deploy/community-scripts}"
export COMMUNITY_SCRIPTS_URL
_CS_DEFAULT_URL="$COMMUNITY_SCRIPTS_URL"
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 tteck
@@ -10,8 +15,10 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
APP="ProxLedger"
var_tags="${var_tags:-monitoring;licensing}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-10}"
# 4 GB fuer die Installation: `next build` braucht deutlich mehr als 2 GB und bricht sonst mit
# einem Speicherfehler ab. Nach der Installation genuegen 2 GB im Betrieb.
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-12}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_arm64="${var_arm64:-yes}"
@@ -40,7 +40,13 @@ msg_info "Setting up ProxLedger"
$STD git clone --branch "$ZWEIG" --depth 1 "$QUELLE" /opt/proxledger
cd /opt/proxledger
IP_ADRESSE=$(hostname -I | awk '{print $1}')
# Erreichbare Adresse des Containers ermitteln. `hostname -I` liefert sie in aller Regel;
# notfalls hilft die Route nach draussen. 0.0.0.0 waere eine Lausch-Adresse und im Browser nutzlos.
IP_ADRESSE=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -z "$IP_ADRESSE" ] || [ "$IP_ADRESSE" = "0.0.0.0" ]; then
IP_ADRESSE=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {print $7; exit}')
fi
[ -n "$IP_ADRESSE" ] || IP_ADRESSE="localhost"
cat <<EOF >/opt/proxledger/.env
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?schema=public"
APP_SECRET="${APP_SECRET}"
@@ -107,6 +113,8 @@ msg_ok "Created Services"
{
echo "ProxLedger"
echo "Adresse: http://${IP_ADRESSE}:3000"
echo " (Im Journal steht 0.0.0.0:3000 - das ist nur die Lausch-Adresse,"
echo " im Browser gehoert die obige IP oder der spaetere DNS-Name hinein.)"
echo "Benutzer: admin@proxledger.local"
echo "Passwort: ${ADMIN_PASS}"
echo
+5
View File
@@ -473,6 +473,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
## Anhang: dunkles Design und Mobilansicht
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
übrigen Angaben stehen auf der jeweiligen Detailseite.
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
Kopfleiste umschalten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

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: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 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: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 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: 253 KiB

After

Width:  |  Height:  |  Size: 252 KiB

@@ -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.
+4 -1
View File
@@ -35,4 +35,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#029](029-betriebsdokumentation.md) | Einrichtungsdokumentation | erledigt | v0.19.0 | 3 h | 0 h 40 min |
| [#030](030-netbird-reverse-proxy.md) | netbird Reverse Proxy in die Anleitung | erledigt | v0.19.1 | 1 h | 0 h 20 min |
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
| | **Summe** | | | **149 h** | **16 h 08 min** |
| [#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 |
| | **Summe** | | | **153 h** | **17 h 38 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.20.0",
"version": "0.20.2",
"private": true,
"scripts": {
"dev": "next dev",
+22 -8
View File
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
<table className="table table-striped align-middle">
<thead>
<tr>
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
per Seitwärtsscrollen ins Bild (#034). */}
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col" className="d-none d-md-table-cell">
Kunde
</th>
<th scope="col" className="d-none d-lg-table-cell">
Key
</th>
<th scope="col">Ablauf</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
<th scope="col" className="d-none d-sm-table-cell">
Proxmox
</th>
<th scope="col" className="d-none d-sm-table-cell">
Kundenrechnung
</th>
</tr>
</thead>
<tbody>
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
<div className="small text-body-secondary d-md-none">
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
</div>
</td>
<td>
<td className="d-none d-md-table-cell">
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
</Link>
)}
</td>
<td>
<td className="d-none d-lg-table-cell">
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td>
<td>
<td className="d-none d-sm-table-cell">
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td>
<td>
<td className="d-none d-sm-table-cell">
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td>
</tr>
+13
View File
@@ -186,6 +186,19 @@ body {
display: none;
}
/*
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
* damit sie auch per Tastatur erreichbar ist.
*/
.pl-menu-hintergrund {
position: fixed;
inset: 0;
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
padding: 0;
background-color: rgba(0, 0, 0, 0.5);
border: 0;
}
/* ---------- Inhaltsbereich ---------- */
.app-content-header {
+3 -9
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
import type { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_TEXT } from "@/server/auth/rollen";
import { menuUmschalten } from "./menuZustand";
export interface KopfleistenAlert {
id: string;
@@ -58,16 +59,9 @@ export function Header({
return () => window.removeEventListener("keydown", tasten);
}, []);
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
const sidebarUmschalten = useCallback(() => {
const body = document.body;
if (window.innerWidth < 992) {
body.classList.toggle("sidebar-open");
body.classList.remove("sidebar-collapse");
} else {
body.classList.toggle("sidebar-collapse");
body.classList.remove("sidebar-open");
}
menuUmschalten();
}, []);
function themaUmschalten() {
+38 -2
View File
@@ -2,10 +2,11 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo";
import type { Rolle } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { menuSchliessen, useMenuOffen } from "./menuZustand";
interface MenuEintrag {
href: string;
@@ -30,6 +31,21 @@ export function Sidebar({
const pathname = usePathname();
const router = useRouter();
const [suche, setSuche] = useState("");
const menuOffen = useMenuOffen();
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
useEffect(() => {
menuSchliessen();
}, [pathname]);
// Escape schließt, wie bei jedem anderen Overlay auch.
useEffect(() => {
function taste(ereignis: KeyboardEvent) {
if (ereignis.key === "Escape") menuSchliessen();
}
window.addEventListener("keydown", taste);
return () => window.removeEventListener("keydown", taste);
}, []);
const haupt: MenuEintrag[] = [
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
@@ -105,11 +121,30 @@ export function Sidebar({
}
return (
<>
{/* Fläche hinter dem Menü: antippen schließt. Ohne sie bleibt das Menü auf dem Handy stehen. */}
{menuOffen ? (
<button
type="button"
className="pl-menu-hintergrund d-lg-none"
aria-label="Menü schließen"
onClick={menuSchliessen}
/>
) : null}
<aside className="app-sidebar shadow" data-bs-theme="dark">
<div className="sidebar-brand">
<div className="sidebar-brand d-flex align-items-center justify-content-between">
<Link href="/" className="brand-link">
<LogoMitSchriftzug variante="sidebar" groesse={34} />
</Link>
<button
type="button"
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
aria-label="Menü schließen"
onClick={menuSchliessen}
>
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
</div>
<div className="pl-sidebar-user">
@@ -148,5 +183,6 @@ export function Sidebar({
</nav>
</div>
</aside>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useSyncExternalStore } from "react";
/**
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
*
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
* Kopfzeile und Menü gemeinsam hören.
*/
const SCHMAL = 992;
let offen = false;
const zuhoerer = new Set<() => void>();
function melden() {
for (const ruf of zuhoerer) ruf();
}
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
function klassenSetzen() {
const body = document.body;
body.classList.toggle("sidebar-open", offen);
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
}
export function menuUmschalten() {
if (window.innerWidth >= SCHMAL) {
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
document.body.classList.toggle("sidebar-collapse");
document.body.classList.remove("sidebar-open");
return;
}
offen = !offen;
document.body.classList.remove("sidebar-collapse");
klassenSetzen();
melden();
}
export function menuSchliessen() {
if (!offen) return;
offen = false;
klassenSetzen();
melden();
}
function abonnieren(ruf: () => void) {
zuhoerer.add(ruf);
return () => {
zuhoerer.delete(ruf);
};
}
export function useMenuOffen(): boolean {
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
return useSyncExternalStore(
abonnieren,
() => offen,
() => false,
);
}
+35 -16
View File
@@ -9,31 +9,34 @@
* sich fälschen; für eine Weiterleitung auf die eigene Seite ist das unkritisch, als erste Wahl aber
* unnötig.
*/
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
if (eingetragen) {
/**
* Lausch-Adressen taugen nicht als Ziel für einen Browser: `0.0.0.0` heißt „alle Schnittstellen“.
* Steht so etwas in der Konfiguration, wird es übergangen statt eine unerreichbare Seite zu bauen.
*/
const UNBRAUCHBAR = new Set(["0.0.0.0", "::", "[::]", "", "0"]);
function brauchbar(adresse: string | undefined): URL | null {
if (!adresse) return null;
try {
return new URL(eingetragen);
const ergebnis = new URL(adresse);
return UNBRAUCHBAR.has(ergebnis.hostname) ? null : ergebnis;
} catch {
// Unbrauchbarer Eintrag: weiter mit den übrigen Quellen.
return null;
}
}
if (process.env.APP_URL) {
try {
return new URL(process.env.APP_URL);
} catch {
// ebenso
}
}
export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
const ausEinstellung = brauchbar(eingetragen);
if (ausEinstellung) return ausEinstellung;
const ausUmgebung = brauchbar(process.env.APP_URL);
if (ausUmgebung) return ausUmgebung;
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) {
const schema = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
try {
return new URL(`${schema}://${gastgeber}`);
} catch {
// ebenso
}
const ausKopfzeile = brauchbar(`${schema}://${gastgeber}`);
if (ausKopfzeile) return ausKopfzeile;
}
return new URL(request.url);
@@ -43,3 +46,19 @@ export function oeffentlicheBasis(request: Request, eingetragen?: string): URL {
export function eigeneAdresse(request: Request, pfad: string, eingetragen?: string): URL {
return new URL(pfad, oeffentlicheBasis(request, eingetragen));
}
/**
* Dasselbe für Server Actions und Seiten, die keine `Request` zur Hand haben.
*
* Nötig, weil Next.js eine Weiterleitung aus einer Server Action serverseitig absolut macht – und
* dafür die Adresse nimmt, auf die der Server lauscht. Im Container ist das `0.0.0.0` (oder der
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
*/
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
const { headers } = await import("next/headers");
const kopfzeilen = await headers();
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
return new URL(pfad, basis).toString();
}
+5 -1
View File
@@ -2,12 +2,16 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
import { protokolliere } from "@/server/audit";
import { einstellungen } from "@/server/einstellungen";
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
export async function abmelden() {
(await cookies()).delete(SITZUNGS_COOKIE);
await protokolliere("abmeldung", "Benutzer", null, {});
redirect("/login");
const { dashboardUrl } = await einstellungen();
redirect(await eigenerPfad("/login", dashboardUrl));
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { eigenerPfad } from "@/lib/adresse";
export interface AktionsErgebnis {
fehler?: string;
@@ -79,7 +80,7 @@ export async function kundeLoeschen(formular: FormData) {
revalidatePath("/kunden");
revalidatePath("/hosts/nicht-zugeordnet");
redirect("/kunden");
redirect(await eigenerPfad("/kunden"));
}
const standortSchema = z.object({
+3 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import type { Rolle } from "@/generated/prisma/enums";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "./rollen";
@@ -68,10 +69,10 @@ export const angemeldet = cache(async (): Promise<AngemeldeterBenutzer | null> =
*/
export async function sitzungErforderlich(recht?: Recht): Promise<AngemeldeterBenutzer> {
const benutzer = await angemeldet();
if (!benutzer) redirect("/login?fehler=Bitte+anmelden.");
if (!benutzer) redirect(await eigenerPfad("/login?fehler=Bitte+anmelden."));
if (recht && !darf(benutzer.rolle, recht)) {
redirect("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung.");
redirect(await eigenerPfad("/?fehler=Für+diesen+Bereich+fehlt+die+Berechtigung."));
}
return benutzer;
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Prüft das Öffnen und Schließen des Seitenmenüs. Statt eines echten Browsers genügen hier
* Attrappen für `document.body` und `window` – geprüft wird, welche Klassen gesetzt werden.
*/
const klassen = new Set<string>();
let breite = 390;
function aufraeumen() {
klassen.clear();
breite = 390;
}
beforeEach(async () => {
aufraeumen();
vi.resetModules();
const body = {
classList: {
toggle(name: string, an?: boolean) {
const soll = an ?? !klassen.has(name);
if (soll) klassen.add(name);
else klassen.delete(name);
},
remove(name: string) {
klassen.delete(name);
},
contains: (name: string) => klassen.has(name),
},
style: { overflow: "" },
};
vi.stubGlobal("document", { body });
vi.stubGlobal("window", {
get innerWidth() {
return breite;
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Seitenmenü auf schmalen Bildschirmen", () => {
it("öffnet und schließt wieder", async () => {
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect(klassen.has("sidebar-open")).toBe(true);
menuSchliessen();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("sperrt den Inhalt hinter dem offenen Menü gegen Scrollen", async () => {
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("hidden");
menuSchliessen();
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("");
});
it("schließt auch beim zweiten Antippen des Knopfes", async () => {
const { menuUmschalten } = await import("@/components/layout/menuZustand");
menuUmschalten();
menuUmschalten();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("macht ein zweites Schließen zum Nichts", async () => {
const { menuSchliessen } = await import("@/components/layout/menuZustand");
menuSchliessen();
expect(klassen.has("sidebar-open")).toBe(false);
});
it("klappt auf breiten Bildschirmen nur ein, statt zu überlagern", async () => {
breite = 1400;
const { menuUmschalten } = await import("@/components/layout/menuZustand");
menuUmschalten();
expect(klassen.has("sidebar-collapse")).toBe(true);
expect(klassen.has("sidebar-open")).toBe(false);
});
});
+13
View File
@@ -100,6 +100,19 @@ describe("Öffentliche Adresse", () => {
expect(eigeneAdresse(anfrageMit({}), "/").host).toBe("0a5963a591df:3000");
});
it("übergeht eine Lausch-Adresse in der Konfiguration", () => {
// 0.0.0.0 heißt „alle Schnittstellen“ – als Ziel im Browser ist das unbrauchbar.
// Genau so stand es nach einer Installation in der .env und führte beim Abmelden ins Leere.
const anfrage = anfrageMit({ host: "proxledger.firma.de" });
expect(eigeneAdresse(anfrage, "/login", "http://0.0.0.0:3000").host).toBe("proxledger.firma.de");
});
it("übergeht auch eine Lausch-Adresse aus der Umgebung", () => {
vi.stubEnv("APP_URL", "http://0.0.0.0:3000");
const anfrage = anfrageMit({ host: "proxledger.firma.de" });
expect(eigeneAdresse(anfrage, "/login").host).toBe("proxledger.firma.de");
});
it("übergeht einen unbrauchbaren Eintrag, statt zu scheitern", () => {
const anfrage = anfrageMit({ "x-forwarded-host": "dashboard.kunde.de" });
expect(eigeneAdresse(anfrage, "/", "keine-adresse").host).toBe("dashboard.kunde.de");