Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f67a35218 | ||
|
|
eeaf3820b7 | ||
|
|
46e7eb0b59 | ||
|
|
c38b27ca85 | ||
|
|
fcf41f56fe | ||
|
|
b828f38ca5 | ||
|
|
4046b72626 | ||
|
|
d0057f038c | ||
|
|
0ec532296d | ||
|
|
75252b32b4 | ||
|
|
1064915d37 | ||
|
|
7fcdcba694 | ||
|
|
055d8caaa0 | ||
|
|
e4ff55a6c9 | ||
|
|
c8d60d8006 |
@@ -26,6 +26,46 @@ AUTH_ENABLED=false
|
||||
# grosse Werte machen die Sicherung entsprechend schwer.
|
||||
MAX_UPLOAD_MB=10
|
||||
|
||||
# Sitzungsdauer in Tagen.
|
||||
# SESSION_IDLE_DAYS — ohne Aktivitaet laeuft die Sitzung ab.
|
||||
# SESSION_MAX_DAYS — harte Obergrenze ab dem Anmelden, unabhaengig davon,
|
||||
# wie oft sie benutzt wurde.
|
||||
# OWASP empfiehlt fuer oeffentliche Anwendungen deutlich kuerzere Werte
|
||||
# (Minuten bzw. Stunden); siehe docs/sicherheit.md.
|
||||
SESSION_IDLE_DAYS=30
|
||||
SESSION_MAX_DAYS=90
|
||||
|
||||
# --- Anmeldung ueber Authentik oder einen anderen OIDC-Anbieter -------------
|
||||
# Setzt AUTH_ENABLED=true voraus. Einrichtung siehe docs/authentik.md.
|
||||
OIDC_ENABLED=false
|
||||
|
||||
# Basis-URL des Anbieters, unter der /.well-known/openid-configuration liegt.
|
||||
# Bei Authentik: https://auth.example.com/application/o/<slug>/
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# Beschriftung des Knopfs auf der Anmeldeseite.
|
||||
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||
|
||||
# Angeforderte Bereiche. "openid" ist Pflicht, "email" wird gebraucht.
|
||||
OIDC_SCOPES=openid email profile
|
||||
|
||||
# Nur noch ueber den Anbieter anmelden? Dann verschwindet das Passwortfeld.
|
||||
# Vorsicht: Faellt der Anbieter aus, kommt niemand mehr hinein — zum Loesen
|
||||
# hier auf false setzen und neu starten.
|
||||
OIDC_ONLY=false
|
||||
|
||||
# Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen.
|
||||
# VIEWER (nur lesen), EDITOR (Codes anlegen und aendern) oder ADMIN.
|
||||
OIDC_DEFAULT_ROLE=VIEWER
|
||||
|
||||
# Gruppe beim Anbieter, deren Mitglieder die Verwaltung oeffnen duerfen.
|
||||
# Leer lassen, um die Administratorrechte hier in der Anwendung zu pflegen.
|
||||
OIDC_ADMIN_GROUP=
|
||||
# Name des Anspruchs, in dem die Gruppen stehen (Authentik: groups).
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
|
||||
# --- PostgreSQL ------------------------------------------------------------
|
||||
POSTGRES_USER=qrcode
|
||||
POSTGRES_PASSWORD=bitte-aendern
|
||||
|
||||
@@ -8,6 +8,52 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
|
||||
---
|
||||
|
||||
## Was die Anwendung kann — kurz
|
||||
|
||||
- **15 QR-Typen:** Website, vCard, App-Link, Mehrere Links, Social Media,
|
||||
WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN, Feedback.
|
||||
- **Statisch oder dynamisch.** Dynamische Codes zeigen auf eine Kurz-URL:
|
||||
Ziel jederzeit änderbar, ohne neu zu drucken — mit Scan-Zähler.
|
||||
- **Eigene Seiten** für Linksammlungen, Profile, hinterlegte PDFs und Bilder
|
||||
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
||||
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
||||
ablegen, um sie wiederzufinden.
|
||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||
erhoben, ohne Name und IP-Adresse.
|
||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||
Administrator, Bearbeiter und Betrachter.
|
||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
||||
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||
Admin-Bereich einstellbar.
|
||||
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||
eigene Wahl.
|
||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||
von selbst; alles Nötige steht in einer `.env`.
|
||||
|
||||
→ Ausführlich: **[Alle Funktionen im Detail](docs/funktionen.md)**
|
||||
|
||||
---
|
||||
|
||||
## Anleitungen
|
||||
|
||||
- **[Bedienungsanleitung mit Bildern](docs/anleitung.md)** — vom ersten Code
|
||||
bis zur Auswertung von Rückmeldungen
|
||||
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||
Rollen und der Weg zurück
|
||||
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
||||
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
|
||||
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||
Härtungen und was bewusst offen bleibt
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
||||
@@ -17,10 +63,11 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
- [Konfiguration](#konfiguration)
|
||||
- [Bedienung](#bedienung)
|
||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
|
||||
- [Technischer Aufbau](#technischer-aufbau)
|
||||
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
||||
- [API](#api)
|
||||
- [Ausblick Version 2](#ausblick-version-2)
|
||||
- [Einen weiteren Typ ergänzen](#einen-weiteren-typ-ergänzen)
|
||||
|
||||
---
|
||||
|
||||
@@ -80,9 +127,25 @@ BASE_URL=http://192.168.1.50:3000
|
||||
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
|
||||
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
|
||||
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
|
||||
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die
|
||||
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht
|
||||
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
||||
> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
|
||||
> umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
||||
>
|
||||
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
|
||||
>
|
||||
> | Wer scannt | Was in `BASE_URL` gehört |
|
||||
> | --- | --- |
|
||||
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
|
||||
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
|
||||
>
|
||||
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
|
||||
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
|
||||
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
|
||||
> Hinweis darauf, woran es liegt.
|
||||
>
|
||||
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
|
||||
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
|
||||
> über einen externen Anbieter, siehe
|
||||
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||
|
||||
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
|
||||
Servicename aus der `docker-compose.yml`, nicht dein Server.
|
||||
@@ -190,7 +253,7 @@ BASE_URL=https://qr.deine-domain.de
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||
| Forward Port | `3000` |
|
||||
| Websockets Support | an |
|
||||
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
|
||||
| Block Common Exploits | an |
|
||||
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
|
||||
|
||||
@@ -205,9 +268,76 @@ Port. Danach:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung
|
||||
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
|
||||
> dynamische Codes ins interne Netz statt auf die Domain.
|
||||
> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
|
||||
> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
|
||||
> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
|
||||
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
|
||||
> scheitert (siehe unten).
|
||||
|
||||
### Was der Proxy weiterreichen muss
|
||||
|
||||
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
|
||||
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
|
||||
sie eintragen.
|
||||
|
||||
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
|
||||
| --- | --- | --- |
|
||||
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
|
||||
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
|
||||
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
|
||||
|
||||
Für ein reines nginx (ohne NPM) heißt das:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
```
|
||||
|
||||
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
|
||||
von sich aus.
|
||||
|
||||
### Größe der Uploads
|
||||
|
||||
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
||||
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
|
||||
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
|
||||
Oberfläche.
|
||||
|
||||
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
|
||||
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
|
||||
**Advanced → Custom Nginx Configuration**:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 20m;
|
||||
```
|
||||
|
||||
### Nachprüfen, ob es stimmt
|
||||
|
||||
```bash
|
||||
# Kommt die Anwendung überhaupt an?
|
||||
curl -I https://qr.deine-domain.de/
|
||||
|
||||
# Kennt der Container dieselbe Adresse wie der Browser?
|
||||
docker compose exec app env | grep BASE_URL
|
||||
```
|
||||
|
||||
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
|
||||
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
|
||||
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
|
||||
|
||||
- jeder neue dynamische Code zeigt auf die interne Adresse, und
|
||||
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
|
||||
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
|
||||
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||
|
||||
### Erreichbar für wen?
|
||||
|
||||
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
|
||||
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
|
||||
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
|
||||
öffentlich auflösbar und der Proxy von außen erreichbar sein.
|
||||
|
||||
### Mehrere Domains
|
||||
|
||||
@@ -655,6 +785,37 @@ zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
|
||||
sonst führten gedruckte Codes ins Leere.
|
||||
|
||||
---
|
||||
## Anmeldung über Authentik (OIDC)
|
||||
|
||||
Statt Passwörter hier zu pflegen, lässt sich die Anmeldung an einen
|
||||
OpenID-Connect-Anbieter abgeben — getestet mit **Authentik**, ebenso nutzbar
|
||||
mit Keycloak, Zitadel oder Entra ID.
|
||||
|
||||
```bash
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ENABLED=true
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||
OIDC_CLIENT_ID=…
|
||||
OIDC_CLIENT_SECRET=…
|
||||
```
|
||||
|
||||
Auf der Anmeldeseite erscheint dann ein Knopf für den Anbieter; das
|
||||
Passwortfeld bleibt daneben stehen, bis `OIDC_ONLY=true` gesetzt wird.
|
||||
|
||||
**Übernommen wird, wer jemand ist** — Kennung, E-Mail-Adresse, Anzeigename —
|
||||
und auf Wunsch über `OIDC_ADMIN_GROUP` das Recht zur Verwaltung. Gruppen und
|
||||
Rollen bleiben Sache dieser Anwendung: Die Gruppen im Verzeichnis bedeuten
|
||||
etwas anderes als die hier, und eine automatische Übersetzung wäre eine Quelle
|
||||
stiller Überraschungen.
|
||||
|
||||
Bestehende Konten mit Passwort werden beim ersten Anmelden anhand der
|
||||
**bestätigten** E-Mail-Adresse verknüpft, nicht verdoppelt.
|
||||
|
||||
→ **Vollständige Anleitung mit allen Feldern in Authentik, Fehlerbildern und
|
||||
dem Weg zurück: [docs/authentik.md](docs/authentik.md)**
|
||||
|
||||
---
|
||||
|
||||
## Technischer Aufbau
|
||||
|
||||
### Stack
|
||||
@@ -819,25 +980,39 @@ curl -X POST http://localhost:3000/api/codes \
|
||||
|
||||
---
|
||||
|
||||
## Ausblick Version 2
|
||||
## Einen weiteren Typ ergänzen
|
||||
|
||||
Geplant sind die übrigen 14 Typen: vCard, App-Link, Mehrere Links, Social
|
||||
Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN und
|
||||
Feedback.
|
||||
|
||||
Die Architektur ist darauf ausgelegt — ein neuer Typ besteht aus:
|
||||
Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er
|
||||
aus:
|
||||
|
||||
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
|
||||
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
|
||||
3. **je einem Eintrag** in `src/lib/qr/registry.ts` (ersetzt dort den
|
||||
`defineComingSoonType`-Platzhalter) und in
|
||||
`src/components/qr-forms/index.ts`.
|
||||
3. **je einem Eintrag** in `src/lib/qr/registry.ts` und in
|
||||
`src/components/qr-forms/index.ts`,
|
||||
4. **einer Landingpage** in `src/components/landing/`, falls er `hasLandingPage`
|
||||
meldet.
|
||||
|
||||
Am bestehenden Code aus Version 1 ist dafür nichts zu ändern: Vorschau,
|
||||
Download, Gestaltung, Speicherung dynamischer Codes und die Weiterleitung
|
||||
arbeiten für alle Typen gleich.
|
||||
Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung,
|
||||
Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen
|
||||
gleich. Die Kennung gehört zusätzlich in `QR_TYPE_IDS` in
|
||||
`src/lib/qr/type-module.ts`.
|
||||
|
||||
Typen, die statt einer Weiterleitung eine eigene Landingpage brauchen (etwa
|
||||
„Mehrere Links“ oder „Feedback“), setzen dafür `redirectTarget` auf eine
|
||||
interne Adresse — auch dafür ist keine Änderung am Datenmodell nötig, weil
|
||||
`data` als JSONB gespeichert wird.
|
||||
Für einen angekündigten, aber noch nicht gebauten Typ gibt es
|
||||
`defineComingSoonType`: Er erscheint als Kachel mit dem Badge „Bald
|
||||
verfügbar“ und lässt sich nicht öffnen.
|
||||
|
||||
Braucht der Typ eine hochgeladene Datei, meldet er ihre Ids über `uploadIds` —
|
||||
daran hängt, was beim Löschen des Codes mitgeht und was als verwaist
|
||||
weggeräumt wird.
|
||||
|
||||
### Was als Nächstes sinnvoll wäre
|
||||
|
||||
- **Gruppen aus dem Verzeichnis übernehmen.** Die OIDC-Anbindung übernimmt
|
||||
heute bewusst nur die Identität. Eine Zuordnung von Verzeichnisgruppen zu
|
||||
Gruppen dieser Anwendung wäre möglich, braucht aber eine klare Regel dafür,
|
||||
was mit von Hand vergebenen Rollen passiert.
|
||||
- **Statistik über die Zeit.** Der Zähler weiß heute nur, wie oft und wann
|
||||
zuletzt. Ein Verlauf je Tag wäre eine eigene Tabelle — und die Frage, wie
|
||||
lange man ihn aufbewahrt.
|
||||
- **Codes exportieren.** Eine CSV aller Codes einer Gruppe, für die Übergabe
|
||||
an Druckereien.
|
||||
|
||||
@@ -39,6 +39,18 @@ services:
|
||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_BUTTON_LABEL: ${OIDC_BUTTON_LABEL:-Mit Authentik anmelden}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile}
|
||||
OIDC_ONLY: ${OIDC_ONLY:-false}
|
||||
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-VIEWER}
|
||||
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
|
||||
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
|
||||
ports:
|
||||
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# Bedienungsanleitung
|
||||
|
||||
Diese Anleitung führt durch die tägliche Arbeit mit dem QR-Code-Generator —
|
||||
vom ersten Code bis zur Auswertung von Rückmeldungen. Wie der Stack
|
||||
aufgesetzt wird, steht im [README](../README.md); die Anmeldung über
|
||||
Authentik in [authentik.md](authentik.md).
|
||||
|
||||
> Die Bilder zeigen die Oberfläche mit eingeschalteter Anmeldung. Ohne
|
||||
> Anmeldung fehlen Kopfzeile rechts und der Bereich „Verwaltung“, alles
|
||||
> Übrige ist gleich.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
- [Der schnellste Weg zum Code](#der-schnellste-weg-zum-code)
|
||||
- [Statisch oder dynamisch — die wichtigste Entscheidung](#statisch-oder-dynamisch--die-wichtigste-entscheidung)
|
||||
- [Gestaltung](#gestaltung)
|
||||
- [Meine Codes](#meine-codes)
|
||||
- [Die Typen mit eigener Seite](#die-typen-mit-eigener-seite)
|
||||
- [Feedback einsammeln und auswerten](#feedback-einsammeln-und-auswerten)
|
||||
- [Verwaltung](#verwaltung)
|
||||
- [Konto](#konto)
|
||||
- [Häufige Fragen](#häufige-fragen)
|
||||
|
||||
---
|
||||
|
||||
## Der schnellste Weg zum Code
|
||||
|
||||
Die Startseite fragt „Was möchtest du erstellen?“ und zeigt alle
|
||||
freigegebenen Typen als Kacheln.
|
||||
|
||||

|
||||
|
||||
Ein Klick auf **Website**, die Adresse eintippen — die Vorschau erscheint
|
||||
sofort — und mit **PNG** oder **SVG** herunterladen. Fertig.
|
||||
|
||||

|
||||
|
||||
Links stehen Inhalt, Art des Codes und Gestaltung, rechts die Vorschau mit
|
||||
den beiden Download-Knöpfen. Die Vorschau aktualisiert sich bei jeder
|
||||
Änderung.
|
||||
|
||||
**PNG oder SVG?**
|
||||
|
||||
| | PNG | SVG |
|
||||
| --- | --- | --- |
|
||||
| Wofür | Bildschirm, Office, Web | Druck, große Formate, Schilder |
|
||||
| Größe | fest (256–2048 px) | frei skalierbar ohne Qualitätsverlust |
|
||||
| Faustregel | wenn du es einfügst | wenn es jemand druckt |
|
||||
|
||||
---
|
||||
|
||||
## Statisch oder dynamisch — die wichtigste Entscheidung
|
||||
|
||||
| | Statisch | Dynamisch |
|
||||
| --- | --- | --- |
|
||||
| Im Code steht | die Zieladresse selbst | eine Kurz-URL dieser Anwendung |
|
||||
| Ziel später änderbar | **nein** | **ja**, jederzeit |
|
||||
| Scans werden gezählt | nein | ja |
|
||||
| Braucht diesen Server | nein, nie | ja, dauerhaft |
|
||||
| Code ist | dichter (mehr Daten) | schlanker, leichter scannbar |
|
||||
|
||||
**Nimm statisch,** wenn die Adresse feststeht und nie wieder geändert wird —
|
||||
und wenn der Code auch dann noch funktionieren soll, wenn es diese Anwendung
|
||||
nicht mehr gibt.
|
||||
|
||||
**Nimm dynamisch,** wenn etwas gedruckt wird. Ein Aufsteller, ein Flyer, ein
|
||||
Schild am Werkstor: Solange der Code dynamisch ist, kannst du das Ziel
|
||||
umbiegen, ohne neu zu drucken.
|
||||
|
||||

|
||||
|
||||
Beim Umschalten auf **Dynamisch** erscheint ein Feld für den Namen und der
|
||||
Knopf **Dynamischen Code speichern**. Erst beim Speichern entsteht die
|
||||
Kurz-URL — deshalb ist der Download bis dahin gesperrt.
|
||||
|
||||
> **Der Name ist nur für dich.** Er steht nirgends im Code und sieht ihn
|
||||
> niemand, der scannt. Er hilft dir, den Code später in der Liste
|
||||
> wiederzufinden — bei vielen Projekten ist er Gold wert.
|
||||
|
||||
---
|
||||
|
||||
## Gestaltung
|
||||
|
||||
Farben, Größe, Fehlerkorrektur und Rand gelten für Vorschau und Download
|
||||
gleichermaßen.
|
||||
|
||||
**Farben.** Fünf Vorschläge stehen bereit, eigene Werte gehen über den
|
||||
Farbwähler oder als Hex-Wert. Wird der Kontrast zu schwach, warnt die
|
||||
Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem. Faustregel:
|
||||
dunkler Vordergrund auf hellem Hintergrund. Umgekehrt kommen viele
|
||||
Scanner-Apps nicht zurecht.
|
||||
|
||||
**Größe.** 256 bis 2048 Pixel, betrifft nur den PNG-Download. Für Druck lieber
|
||||
SVG nehmen und die Größe im Layoutprogramm bestimmen.
|
||||
|
||||
**Fehlerkorrektur.** Wie viel vom Code beschädigt sein darf, ohne dass er
|
||||
unlesbar wird:
|
||||
|
||||
| Stufe | Verträgt | Wofür |
|
||||
| --- | --- | --- |
|
||||
| L | ~7 % | saubere Bildschirmanzeige |
|
||||
| **M** | ~15 % | **Voreinstellung, für fast alles richtig** |
|
||||
| Q | ~25 % | Aufkleber, die abgerieben werden |
|
||||
| H | ~30 % | Außenbereich, Logo mitten im Code |
|
||||
|
||||
Höhere Stufen machen den Code dichter — er braucht mehr Fläche, um noch
|
||||
gescannt zu werden.
|
||||
|
||||
**Rand.** Die weiße Zone rundherum. Vier Module sind Standard; weniger kann
|
||||
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||
trennen kann.
|
||||
|
||||
---
|
||||
|
||||
## Meine Codes
|
||||
|
||||
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
||||
werden nicht gespeichert.
|
||||
|
||||

|
||||
|
||||
Die **Liste** ist die Voreinstellung: eine Zeile je Code, gedacht für viele
|
||||
Projekte nebeneinander.
|
||||
|
||||
- Das **Suchfeld** durchsucht Name, Ziel, Kurz-URL und Typ.
|
||||
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||
liegen.
|
||||
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||
|
||||
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||
Vorschau. Die Wahl merkt sich der Browser.
|
||||
|
||||

|
||||
|
||||
### Ziel ändern
|
||||
|
||||

|
||||
|
||||
Das ist der eigentliche Sinn dynamischer Codes: **Die Kurz-URL bleibt, der
|
||||
gedruckte Code bleibt gültig, nur das Ziel wechselt.** Die Änderung wirkt
|
||||
sofort — beim nächsten Scan.
|
||||
|
||||
### Löschen
|
||||
|
||||
Löschen fragt nach und nennt den betroffenen Code. Danach führt der gedruckte
|
||||
Code ins Leere und zeigt eine freundliche Seite „Diese Seite gibt es nicht“.
|
||||
Ein gelöschter Code lässt sich nicht wiederherstellen.
|
||||
|
||||
---
|
||||
|
||||
## Die Typen mit eigener Seite
|
||||
|
||||
Fünf Typen leiten nicht auf eine fremde Adresse weiter, sondern auf eine
|
||||
Seite, die diese Anwendung selbst ausliefert: **Mehrere Links**, **Social
|
||||
Media**, **PDF**, **Bild** und **Feedback**.
|
||||
|
||||
Diese Seiten sind öffentlich und zeigen bewusst nichts von der Anwendung —
|
||||
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer
|
||||
einen Aushang scannt, soll nur den Inhalt sehen.
|
||||
|
||||
### Mehrere Links
|
||||
|
||||

|
||||
|
||||
Eine Überschrift, ein kurzer Text und bis zu 20 beschriftete Links. Mit den
|
||||
Pfeilen im Formular lässt sich die Reihenfolge ändern — das oberste wird am
|
||||
häufigsten geklickt, also gehört das Wichtigste nach oben.
|
||||
|
||||
### Social Media
|
||||
|
||||

|
||||
|
||||
Dasselbe Prinzip, aber mit einem Katalog von Netzwerken. Als Eingabe genügt
|
||||
das Kürzel: Aus `@firma` wird beim Speichern `https://instagram.com/firma`.
|
||||
Eine vollständige Adresse bleibt unverändert, damit auch abweichende
|
||||
Profil-Adressen möglich sind. Jedes Netzwerk kann nur einmal vorkommen.
|
||||
|
||||
### PDF und Bild
|
||||
|
||||

|
||||
|
||||
Beim PDF-Typ lädst du ein Dokument hoch; die Seite zeigt Titel, Dateiname,
|
||||
Größe und zwei Knöpfe: **Dokument öffnen** und **Herunterladen**.
|
||||
|
||||
Beim Bild-Typ sind es bis zu zehn Bilder mit Bildunterschriften, untereinander
|
||||
als Galerie.
|
||||
|
||||
Beides lässt sich später austauschen, **ohne den Code neu zu drucken** — genau
|
||||
wie ein geändertes Ziel. Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF
|
||||
bis zur eingestellten Grenze (Voreinstellung 10 MB, siehe `MAX_UPLOAD_MB`).
|
||||
|
||||
> SVG wird bewusst nicht angenommen: Eine SVG-Datei darf Skripte enthalten,
|
||||
> und sie würde von derselben Adresse ausgeliefert wie die Anwendung selbst.
|
||||
|
||||
### Video
|
||||
|
||||
Der Video-Typ erkennt YouTube, Vimeo und direkte Videodateien. Auf der Seite
|
||||
erscheint ein Abspieler — der Rahmen zum Anbieter lädt aber **erst nach einem
|
||||
Klick**, und davor steht, dass dabei Daten an den Anbieter gehen.
|
||||
|
||||
Video ist der einzige dieser Typen, der auch **statisch** funktioniert: Dann
|
||||
steht die Videoadresse direkt im Code und führt ohne Umweg dorthin.
|
||||
|
||||
---
|
||||
|
||||
## Feedback einsammeln und auswerten
|
||||
|
||||

|
||||
|
||||
Der Feedback-Typ erzeugt ein Formular mit fünf Sternen oder fünf Gesichtern
|
||||
und wahlweise einem Textfeld. Alle Texte — Überschrift, Frage, Beschriftung
|
||||
des Textfelds, Dank nach dem Absenden — lassen sich frei formulieren.
|
||||
|
||||
Gespeichert werden nur die Note und der Text. **Kein Name, keine IP-Adresse,
|
||||
keine Gerätekennung.** Wer einen Aushang scannt und eine Note vergibt, bleibt
|
||||
anonym — das steht auch für die Scannenden sichtbar unter dem Formular.
|
||||
|
||||

|
||||
|
||||
Die Auswertung erreichst du in „Meine Codes“ über das Stern-Symbol in der
|
||||
Zeile. Sie zeigt den Durchschnitt, die Verteilung der Noten und alle
|
||||
Kommentare mit Datum.
|
||||
|
||||
**Ein praktischer Hinweis:** Ein Code pro Ort. Zwei Codes — einer im
|
||||
Gastraum, einer am Ausgang — sagen mehr als einer für beides, weil du die
|
||||
Auswertungen getrennt lesen kannst.
|
||||
|
||||
---
|
||||
|
||||
## Verwaltung
|
||||
|
||||
Der Bereich **Verwaltung** erscheint nur für Administratoren der Installation.
|
||||
|
||||

|
||||
|
||||
### QR-Typen
|
||||
|
||||

|
||||
|
||||
15 Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Hier lassen
|
||||
sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
||||
|
||||
**Bereits erstellte Codes eines abgeschalteten Typs funktionieren
|
||||
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||
|
||||
### Gruppen
|
||||
|
||||

|
||||
|
||||
Eine Gruppe bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
|
||||
gehört genau einer Gruppe; wer nicht in der Gruppe ist, sieht ihn nicht.
|
||||
|
||||
Die drei Rollen je Gruppe:
|
||||
|
||||
| Rolle | Darf |
|
||||
| --- | --- |
|
||||
| **Betrachter** | Codes sehen und herunterladen |
|
||||
| **Bearbeiter** | zusätzlich anlegen, ändern und löschen |
|
||||
| **Administrator** | zusätzlich Mitglieder und deren Rollen verwalten |
|
||||
|
||||
Darüber liegt der **Administrator der Installation**: Nur er legt Gruppen,
|
||||
Benutzer und Domains überhaupt an.
|
||||
|
||||
### Domains
|
||||
|
||||

|
||||
|
||||
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
|
||||
werden die Domains hier eingetragen und Gruppen zugeteilt. Eine Domain kann
|
||||
mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
||||
getrennt.
|
||||
|
||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
||||
|
||||
---
|
||||
|
||||
## Konto
|
||||
|
||||

|
||||
|
||||
Hier stehen die eigene Adresse, die Gruppen mit der jeweiligen Rolle, die
|
||||
Passwortänderung und der Weg hinaus.
|
||||
|
||||
Meldet sich das Konto über Authentik an, steht statt der Passwortänderung ein
|
||||
Hinweis darauf — das Passwort wird dann beim Anbieter geändert.
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
**Der Code funktioniert auf dem Handy nicht, am Rechner aber schon.**
|
||||
Dann zeigt die Kurz-URL auf eine Adresse, die das Handy nicht erreicht.
|
||||
`BASE_URL` in der `.env` muss die Adresse sein, unter der die Anwendung **von
|
||||
außen** erreichbar ist — nicht `localhost`.
|
||||
|
||||
**Ich habe den Code schon gedruckt und brauche ein anderes Ziel.**
|
||||
Ist er dynamisch: „Meine Codes“ → Ziel bearbeiten. Ist er statisch: Der Code
|
||||
muss neu erstellt und neu gedruckt werden. Deshalb bei allem Gedruckten
|
||||
dynamisch nehmen.
|
||||
|
||||
**Der Scan-Zähler steht auf 0, obwohl jemand gescannt hat.**
|
||||
Gezählt wird nur der Aufruf der Kurz-URL. Statische Codes zählen gar nicht,
|
||||
weil sie diesen Server nie berühren. Bei Typen mit eigener Seite zählt der
|
||||
Scan, nicht das Neuladen der Seite.
|
||||
|
||||
**Ein Kollege sieht meine Codes nicht.**
|
||||
Er ist nicht in derselben Gruppe. Verwaltung → Gruppen → Mitglied hinzufügen.
|
||||
|
||||
**Der QR-Code wird nicht erkannt.**
|
||||
Meist zu wenig Kontrast, ein zu kleiner Rand oder zu kleiner Druck.
|
||||
Faustregel für Druck: mindestens 2 × 2 cm, bei Entfernung mehr — als grober
|
||||
Anhalt ein Zehntel des Leseabstands.
|
||||
|
||||
**Kann ich ein Logo in die Mitte setzen?**
|
||||
Nicht in dieser Anwendung. Wer es im Layoutprogramm macht: SVG nehmen,
|
||||
Fehlerkorrektur **H** wählen und höchstens ein Fünftel der Fläche verdecken —
|
||||
danach unbedingt mit mehreren Geräten testen.
|
||||
@@ -0,0 +1,320 @@
|
||||
# Anmeldung über Authentik (OIDC)
|
||||
|
||||
Diese Anleitung richtet die Anmeldung am QR-Code-Generator über **Authentik**
|
||||
ein. Sie funktioniert genauso mit anderen OpenID-Connect-Anbietern — Keycloak,
|
||||
Zitadel, Entra ID —, weil die Anwendung alles Nötige aus dem
|
||||
Discovery-Dokument des Anbieters liest. Die Beispiele nennen Authentik, weil
|
||||
die Feldnamen dort so heißen.
|
||||
|
||||
**Zeitbedarf:** etwa 15 Minuten.
|
||||
|
||||
---
|
||||
|
||||
## Vorher klären
|
||||
|
||||
| Frage | Antwort für diese Anleitung |
|
||||
| --- | --- |
|
||||
| Adresse der Anwendung | `https://qr.example.com` |
|
||||
| Adresse von Authentik | `https://auth.example.com` |
|
||||
| Läuft die Anwendung über HTTPS? | Ja — siehe Hinweis unten |
|
||||
|
||||
> **HTTPS ist Pflicht, sobald es über den eigenen Rechner hinausgeht.** Beim
|
||||
> Anmelden gehen Token über die Leitung, und der Sitzungs-Cookie wird nur bei
|
||||
> `https://` in der `BASE_URL` als `Secure` gesetzt. Für einen Versuch im LAN
|
||||
> geht auch HTTP; produktiv gehört ein Reverse Proxy davor (siehe README,
|
||||
> Abschnitt „Hinter einem Reverse Proxy“).
|
||||
|
||||
Die Anwendung muss Authentik erreichen können und umgekehrt der Browser beide.
|
||||
Laufen beide in Docker auf demselben Host, genügt es meist, sie ins selbe
|
||||
Netz zu hängen — der Browser braucht aber in jedem Fall öffentlich auflösbare
|
||||
Adressen.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1 — In Authentik einen Provider anlegen
|
||||
|
||||
**Applications → Providers → Create → OAuth2/OpenID Provider**
|
||||
|
||||
| Feld | Wert |
|
||||
| --- | --- |
|
||||
| Name | `QR-Code-Generator` |
|
||||
| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit, wenn niemand zustimmen soll) |
|
||||
| Client type | **Confidential** |
|
||||
| Client ID | notieren — kommt gleich in die `.env` |
|
||||
| Client Secret | notieren — ebenfalls |
|
||||
| Redirect URIs | **deine** `BASE_URL` + `/api/auth/oidc/callback` — siehe unten |
|
||||
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
||||
|
||||
Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
|
||||
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
||||
Anmeldung mit einer entsprechenden Meldung ab.
|
||||
|
||||
### Die Redirect URI
|
||||
|
||||
Das ist der häufigste Stolperstein. Der Wert ist **kein Beispiel zum
|
||||
Abschreiben**, sondern ergibt sich aus deiner `BASE_URL`:
|
||||
|
||||
```
|
||||
{BASE_URL}/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Was dein Container als `BASE_URL` kennt, zeigt:
|
||||
|
||||
```bash
|
||||
docker compose exec app env | grep BASE_URL
|
||||
```
|
||||
|
||||
Ein paar Beispiele:
|
||||
|
||||
| `BASE_URL` | Redirect URI |
|
||||
| --- | --- |
|
||||
| `https://qr.example.com` | `https://qr.example.com/api/auth/oidc/callback` |
|
||||
| `http://10.10.10.150:3005` | `http://10.10.10.150:3005/api/auth/oidc/callback` |
|
||||
| `http://192.168.1.50:3000` | `http://192.168.1.50:3000/api/auth/oidc/callback` |
|
||||
|
||||
Zu beachten:
|
||||
|
||||
- **Kein Schrägstrich am Ende.**
|
||||
- **Schema und Port gehören dazu** — `http` ist nicht `https`, und `:3005`
|
||||
darf nicht fehlen.
|
||||
- Neben dem Feld steht in neueren Authentik-Fassungen eine Auswahl **Strict**
|
||||
oder **Regex**. Für eine feste Adresse muss dort **Strict** stehen. Bei
|
||||
`Regex` wäre jeder Punkt einer IP-Adresse ein Platzhalter und müsste
|
||||
maskiert werden.
|
||||
- Bleibt das Feld leer, lässt Authentik **gar nichts** durch.
|
||||
|
||||
> **Wenn es trotzdem klemmt:** Authentik zeigt den Fehler „Redirect URI Error“
|
||||
> noch auf seiner eigenen Seite an — die Anwendung bekommt davon nichts mit.
|
||||
> Welche Adresse tatsächlich ankam, steht zum Vergleich
|
||||
> 1. in der Adresszeile des Browsers, im Parameter `redirect_uri`, und
|
||||
> 2. in Authentik unter **Events → Logs** im Eintrag `redirect_uri_error`.
|
||||
>
|
||||
> Weicht die ankommende Adresse von deiner `BASE_URL` ab, ist nicht Authentik
|
||||
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
|
||||
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
|
||||
|
||||
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
|
||||
|
||||
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
|
||||
Anwendung tatsächlich aufruft.
|
||||
|
||||
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
|
||||
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
|
||||
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
|
||||
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
|
||||
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
|
||||
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
|
||||
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
|
||||
hat.
|
||||
|
||||
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
|
||||
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
|
||||
|
||||
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
|
||||
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
|
||||
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
|
||||
Übereinstimmung gebracht:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Aufruf im Browser | `https://qr.example.com` |
|
||||
| `BASE_URL` in der `.env` | `https://qr.example.com` |
|
||||
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
|
||||
|
||||
---
|
||||
|
||||
## Schritt 2 — Eine Application anlegen
|
||||
|
||||
**Applications → Applications → Create**
|
||||
|
||||
| Feld | Wert |
|
||||
| --- | --- |
|
||||
| Name | `QR-Code-Generator` |
|
||||
| Slug | `qrcode-generator` |
|
||||
| Provider | der eben angelegte Provider |
|
||||
| Launch URL | `https://qr.example.com` |
|
||||
|
||||
Der **Slug** steckt gleich in der Issuer-Adresse — merken.
|
||||
|
||||
Wer den Zugang einschränken will, hängt hier unter **Policy / Group / User
|
||||
Bindings** eine Gruppe ein. Das ist der empfohlene Weg: Wer sich bei Authentik
|
||||
anmelden darf, kommt sonst auch in den QR-Code-Generator.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 3 — Die Issuer-Adresse herausfinden
|
||||
|
||||
Authentik zeigt sie beim Provider unter **Provider Info** als *OpenID
|
||||
Configuration Issuer*. Sie sieht so aus:
|
||||
|
||||
```
|
||||
https://auth.example.com/application/o/qrcode-generator/
|
||||
```
|
||||
|
||||
Prüfen lässt sich das direkt:
|
||||
|
||||
```bash
|
||||
curl https://auth.example.com/application/o/qrcode-generator/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
Kommt ein JSON-Dokument mit `authorization_endpoint`, `token_endpoint` und
|
||||
`jwks_uri` zurück, stimmt die Adresse.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 4 — Die `.env` ergänzen
|
||||
|
||||
```bash
|
||||
AUTH_ENABLED=true
|
||||
|
||||
OIDC_ENABLED=true
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||
OIDC_CLIENT_ID=<Client ID aus Schritt 1>
|
||||
OIDC_CLIENT_SECRET=<Client Secret aus Schritt 1>
|
||||
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||
OIDC_SCOPES=openid email profile
|
||||
OIDC_DEFAULT_ROLE=EDITOR
|
||||
OIDC_ADMIN_GROUP=qr-admins
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_ONLY=false
|
||||
```
|
||||
|
||||
Danach neu starten:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> `OIDC_ENABLED` allein genügt nicht — **`AUTH_ENABLED` muss ebenfalls `true`
|
||||
> sein**. Ohne Anmeldung gibt es keine Benutzer, denen eine Anmeldung
|
||||
> zugeordnet werden könnte.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 5 — Ausprobieren
|
||||
|
||||
`https://qr.example.com/anmelden` aufrufen. Über dem Passwortfeld steht jetzt
|
||||
**„Mit Authentik anmelden“**. Ein Klick führt zu Authentik, nach der Anmeldung
|
||||
geht es zurück in die Anwendung.
|
||||
|
||||
Alle Anmeldenden bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
|
||||
Standardgruppe. Wer die **Verwaltung** öffnen darf, hängt davon ab, ob
|
||||
`OIDC_ADMIN_GROUP` gesetzt ist:
|
||||
|
||||
- **Leer:** Der erste Mensch, der sich anmeldet, wird Administrator dieser
|
||||
Installation — genau wie bei der Ersteinrichtung mit Passwort. Danach vergibt
|
||||
man das Recht unter Verwaltung → Benutzer.
|
||||
- **Gesetzt:** Dann entscheidet allein die Gruppe, und zwar bei **jeder**
|
||||
Anmeldung neu — auch beim allerersten Benutzer.
|
||||
|
||||
> **Achtung beim ersten Mal:** Wenn du `OIDC_ADMIN_GROUP` setzt, vergewissere
|
||||
> dich, dass dein eigenes Konto in dieser Gruppe ist. Sonst hat niemand Zugang
|
||||
> zur Verwaltung. Der Weg zurück ist einfach: die Variable leeren,
|
||||
> `docker compose up -d`, neu anmelden — dann greift wieder die Regel „der
|
||||
> erste wird Administrator“, sofern es noch gar keinen gibt.
|
||||
|
||||
---
|
||||
|
||||
## Was die Anwendung aus Authentik übernimmt — und was nicht
|
||||
|
||||
| Übernommen | Bleibt in der Anwendung |
|
||||
| --- | --- |
|
||||
| Wer jemand ist (`sub`) | Mitgliedschaft in Gruppen |
|
||||
| E-Mail-Adresse | Rolle je Gruppe (Admin/Bearbeiter/Betrachter) |
|
||||
| Anzeigename | Zuteilung von Domains |
|
||||
| Instanz-Administration, falls `OIDC_ADMIN_GROUP` gesetzt ist | alles Übrige |
|
||||
|
||||
Das ist eine bewusste Entscheidung. Die Gruppen in Authentik und die Gruppen
|
||||
hier bedeuten nicht dasselbe: dort geht es um Organisation und Zugang, hier um
|
||||
die Frage, wer welche QR-Codes sehen und ändern darf. Eine automatische
|
||||
Übersetzung wäre eine Quelle stiller Überraschungen — deshalb pflegt man
|
||||
Gruppen und Rollen weiterhin unter **Verwaltung → Gruppen**.
|
||||
|
||||
Wiedererkannt wird jemand an der **Kennung von Authentik** (`sub`), nicht an
|
||||
der E-Mail-Adresse. Ändert sich die Adresse, bleibt es dasselbe Konto.
|
||||
|
||||
### Die Admin-Gruppe
|
||||
|
||||
Steht in `OIDC_ADMIN_GROUP` ein Gruppenname, entscheidet Authentik bei **jeder
|
||||
Anmeldung** neu, wer die Verwaltung öffnen darf: Mitglieder der Gruppe
|
||||
bekommen das Recht, alle anderen verlieren es. Bleibt die Variable leer,
|
||||
rührt die Anmeldung das Recht nicht an — dann pflegt man es hier.
|
||||
|
||||
---
|
||||
|
||||
## Bestehende Konten übernehmen
|
||||
|
||||
Gibt es hier schon Konten mit Passwort, werden sie beim ersten Anmelden über
|
||||
Authentik **anhand der E-Mail-Adresse verknüpft**. Aus dem lokalen
|
||||
Administrator wird also kein zweites Konto; Gruppen, Rollen und die
|
||||
angelegten Codes bleiben.
|
||||
|
||||
Verknüpft wird nur, wenn Authentik die Adresse als **bestätigt** meldet. Sonst
|
||||
könnte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein
|
||||
hiesiges Konto übernehmen. Meldet Authentik sie als unbestätigt, bricht die
|
||||
Anmeldung mit einer Erklärung ab.
|
||||
|
||||
Ist ein Konto einmal verknüpft, gibt es dafür kein Passwort mehr: Der Bereich
|
||||
**Konto** zeigt dann statt der Passwortänderung einen Hinweis auf den
|
||||
Anbieter.
|
||||
|
||||
---
|
||||
|
||||
## Nur noch über Authentik anmelden
|
||||
|
||||
```bash
|
||||
OIDC_ONLY=true
|
||||
```
|
||||
|
||||
Danach verschwindet das Passwortfeld von der Anmeldeseite.
|
||||
|
||||
> **Vorher an den Notausgang denken.** Fällt Authentik aus oder ist der
|
||||
> Provider falsch konfiguriert, kommt niemand mehr hinein. Der Weg zurück:
|
||||
> `OIDC_ONLY=false` in der `.env`, `docker compose up -d` — dann geht die
|
||||
> Anmeldung mit Passwort wieder. Lege dir dafür ein lokales Administratorkonto
|
||||
> an, **bevor** du umstellst.
|
||||
|
||||
---
|
||||
|
||||
## Wenn etwas nicht klappt
|
||||
|
||||
| Symptom | Ursache und Abhilfe |
|
||||
| --- | --- |
|
||||
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
||||
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
||||
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
||||
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
|
||||
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
|
||||
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
|
||||
| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
|
||||
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
|
||||
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
|
||||
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
|
||||
|
||||
Ausführlichere Meldungen stehen im Protokoll:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Was technisch passiert
|
||||
|
||||
1. Klick auf den Knopf → `/api/auth/oidc/start`. Die Anwendung holt das
|
||||
Discovery-Dokument, würfelt `state`, `nonce` und ein PKCE-Geheimnis und legt
|
||||
sie in einen kurzlebigen, `httpOnly`-Cookie.
|
||||
2. Weiterleitung zu Authentik mit `code_challenge` (S256).
|
||||
3. Nach der Anmeldung kommt Authentik mit `code` und `state` zurück auf
|
||||
`/api/auth/oidc/callback`. Stimmt der `state` nicht, endet es hier.
|
||||
4. Die Anwendung tauscht den Code gegen Token — mit Client-Geheimnis **und**
|
||||
PKCE-Verifier. Ein abgefangener Code allein nützt also niemandem.
|
||||
5. Das ID-Token wird gegen den JWKS von Authentik geprüft: Signatur,
|
||||
Aussteller, Empfänger, Ablauf und `nonce`.
|
||||
6. Konto suchen (über `sub`, ersatzweise über die bestätigte Adresse), anlegen
|
||||
oder verknüpfen — und die Sitzung wie bei der Passwort-Anmeldung setzen.
|
||||
|
||||
Die Sitzung selbst ist danach dieselbe wie sonst: ein Zufallstoken im Cookie,
|
||||
in der Datenbank nur dessen SHA-256. Abmelden beendet sie hier; bei Authentik
|
||||
bleibt man angemeldet.
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,541 @@
|
||||
# Alle Funktionen im Detail
|
||||
|
||||
Die lange Fassung der Übersicht aus dem [README](../README.md). Sie
|
||||
beschreibt, was die Anwendung kann, wo die Grenzen liegen und warum manches
|
||||
so entschieden ist. Wie man es bedient, steht in der
|
||||
[Bedienungsanleitung](anleitung.md).
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
- [QR-Typen](#qr-typen)
|
||||
- [Statisch und dynamisch](#statisch-und-dynamisch)
|
||||
- [Landingpages](#landingpages)
|
||||
- [Dateien](#dateien)
|
||||
- [Feedback](#feedback)
|
||||
- [Gestaltung und Ausgabe](#gestaltung-und-ausgabe)
|
||||
- [Meine Codes](#meine-codes)
|
||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||
- [Single Sign-on](#single-sign-on)
|
||||
- [Mehrere Domains](#mehrere-domains)
|
||||
- [Erscheinungsbild](#erscheinungsbild)
|
||||
- [Verwaltung](#verwaltung)
|
||||
- [Betrieb](#betrieb)
|
||||
- [Schnittstelle](#schnittstelle)
|
||||
- [Was die Anwendung bewusst nicht tut](#was-die-anwendung-bewusst-nicht-tut)
|
||||
|
||||
---
|
||||
|
||||
## QR-Typen
|
||||
|
||||
Alle 15 vorgesehenen Typen sind gebaut.
|
||||
|
||||
| Typ | Was hineinkommt | Statisch | Dynamisch | Eigene Seite |
|
||||
| --- | --- | :-: | :-: | :-: |
|
||||
| **Website** | eine Adresse | ✓ | ✓ | — |
|
||||
| **vCard** | Name, Firma, Telefon, E-Mail, Anschrift, Web | ✓ | — | — |
|
||||
| **App-Link** | je eine Adresse für iOS, Android und Ausweichfall | — | ✓ | — |
|
||||
| **Mehrere Links** | Überschrift, Text, bis zu 20 beschriftete Links | — | ✓ | ✓ |
|
||||
| **Social Media** | Überschrift, Text, bis zu 12 Profile aus 15 Netzwerken | — | ✓ | ✓ |
|
||||
| **WhatsApp** | Rufnummer und vorbereitete Nachricht | ✓ | ✓ | — |
|
||||
| **PDF** | eine hochgeladene Datei, Titel, Text | — | ✓ | ✓ |
|
||||
| **Bild** | bis zu 10 hochgeladene Bilder mit Bildunterschriften | — | ✓ | ✓ |
|
||||
| **Video** | YouTube, Vimeo oder eine direkte Videodatei | ✓ | ✓ | ✓ |
|
||||
| **E-Mail** | Empfänger, Betreff, Text | ✓ | ✓ | — |
|
||||
| **SMS** | Rufnummer und Text | ✓ | ✓ | — |
|
||||
| **Standort** | Koordinaten oder Adresse | ✓ | ✓ | — |
|
||||
| **Zahlung** | GiroCode (EPC069-12) **oder** Zahlungslink | ✓ | nur Link | — |
|
||||
| **WLAN** | Netzwerkname, Passwort, Verschlüsselung, versteckt | ✓ | — | — |
|
||||
| **Feedback** | Frage, Skala, Textfeld, Dankestext | — | ✓ | ✓ |
|
||||
|
||||
### Warum manche Typen nur eine Betriebsart können
|
||||
|
||||
- **vCard und WLAN** wertet das Gerät direkt aus — die Kamera legt einen
|
||||
Kontakt an oder richtet das Netz ein. Über eine Kurz-URL ginge stattdessen
|
||||
nur ein Browser auf.
|
||||
- **App-Link** kann nicht statisch sein: Welches Ziel richtig ist, steht erst
|
||||
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus — iOS in den
|
||||
App Store, Android zu Google Play, alles Übrige auf die Ausweichadresse.
|
||||
(iPadOS meldet sich seit Version 13 als Macintosh und landet deshalb
|
||||
mitunter auf dem Desktop-Weg; für einen Store-Link ist das hinnehmbar.)
|
||||
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
|
||||
dynamischer Code funktioniert nur die Variante „Zahlungslink“. Die
|
||||
Anwendung weist das beim Speichern **und beim Bearbeiten** ab, statt einen
|
||||
Code entstehen zu lassen, der erst beim Scannen auffällt — also dann, wenn
|
||||
er schon gedruckt ist.
|
||||
- **Die fünf Typen mit eigener Seite** brauchen eine Seite, die die Anwendung
|
||||
ausliefert. Eine Linkliste oder ein Formular passt nicht in einen einzelnen
|
||||
Code. Video ist die Ausnahme: statisch steht dort die Videoadresse selbst.
|
||||
|
||||
### Besonderheiten einzelner Typen
|
||||
|
||||
**Social Media.** Als Eingabe genügt das Kürzel — aus `@firma` wird beim
|
||||
Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt
|
||||
unverändert, damit auch abweichende Profil-Adressen möglich sind. Jedes
|
||||
Netzwerk kann nur einmal vorkommen. Der Katalog umfasst Instagram, Facebook,
|
||||
LinkedIn, Xing, YouTube, TikTok, X, Threads, Mastodon, WhatsApp, Telegram,
|
||||
Pinterest, Snapchat, GitHub und „Website“.
|
||||
|
||||
**Video.** Erkannt werden `youtube.com/watch`, `youtu.be`, `/shorts/`,
|
||||
`/embed/`, `/live/`, Vimeo-Adressen sowie die Endungen `.mp4`, `.webm`,
|
||||
`.ogv`, `.ogg`, `.mov` und `.m4v`. Alles Übrige erscheint als schlichter Link
|
||||
— ehrlicher als ein eingebetteter Rahmen, den der Anbieter nicht erlaubt.
|
||||
|
||||
**WLAN.** Das Passwort steht im Klartext im Code; wer ihn scannt oder
|
||||
abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht Netzwerkname
|
||||
oder Passwort nur aus Hex-Ziffern, setzt die Anwendung den Wert wie in der
|
||||
Spezifikation vorgesehen in Anführungszeichen — sonst deuten manche Geräte ihn
|
||||
als Hexzahl.
|
||||
|
||||
**Standort.** Statisch entsteht ein `geo:`-Link, den Karten-Apps direkt
|
||||
öffnen. Dynamisch führt der Weg über OpenStreetMap, weil eine Kurz-URL im
|
||||
Browser landet.
|
||||
|
||||
---
|
||||
|
||||
## Statisch und dynamisch
|
||||
|
||||
| | Statisch | Dynamisch |
|
||||
| --- | --- | --- |
|
||||
| Im Code steht | die Zieladresse selbst | `{BASE_URL}/r/{slug}` |
|
||||
| Ziel später änderbar | nein | ja, jederzeit |
|
||||
| Scans gezählt | nein | ja, mit Datum des letzten |
|
||||
| Braucht diesen Server | nie | dauerhaft |
|
||||
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
|
||||
|
||||
### Ein statischer Code mit Namen
|
||||
|
||||
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
|
||||
|
||||
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
|
||||
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
|
||||
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
|
||||
wiederzufinden und erneut herunterzuladen.
|
||||
|
||||
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
|
||||
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
|
||||
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
|
||||
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
|
||||
Liste steht dort „statisch“ und ein Strich.
|
||||
|
||||
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
||||
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
||||
|
||||
### Der Kurz-Link
|
||||
|
||||
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
|
||||
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
|
||||
gelegentlich abgetippt.
|
||||
|
||||
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
|
||||
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
|
||||
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
|
||||
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
|
||||
|
||||
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
|
||||
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
|
||||
dürfen sich nicht nur in der Schreibweise unterscheiden.
|
||||
|
||||
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
|
||||
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
|
||||
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
|
||||
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
|
||||
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
|
||||
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
|
||||
ließen sich fremde gedruckte Codes umlenken.
|
||||
|
||||
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
|
||||
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
|
||||
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
|
||||
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
|
||||
weiterhin.
|
||||
|
||||
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
|
||||
zwei Domains liegen und dort auf Verschiedenes zeigen.
|
||||
|
||||
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
|
||||
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
|
||||
und genau die Änderbarkeit zunichtemachen, wegen der man dynamische Codes
|
||||
nimmt. Der Scan-Zähler wird beiläufig erhöht; scheitert er, geht die
|
||||
Weiterleitung trotzdem durch.
|
||||
|
||||
---
|
||||
|
||||
## Landingpages
|
||||
|
||||
Fünf Typen führen auf eine Seite unter `{BASE_URL}/s/{slug}`, die die
|
||||
Anwendung selbst ausliefert.
|
||||
|
||||
Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung:
|
||||
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator, und im
|
||||
Seitentitel steht der Inhalt statt des Produktnamens. Wer einen Aushang
|
||||
scannt, hat mit dem Generator nichts zu tun.
|
||||
|
||||
Technisch liegt das App-Chrome in einer Routengruppe, die Landingpages
|
||||
außerhalb — sie können es also gar nicht versehentlich erben.
|
||||
|
||||
Weitere Eigenschaften:
|
||||
|
||||
- Alle Texte kommen aus den Formulardaten und werden als **Text** ausgegeben,
|
||||
nie als HTML. Eingegebene Zeichen wie `<b>` erscheinen als solche.
|
||||
- Verlinkte Ziele bekommen `rel="noopener noreferrer"` — kein Zugriff auf das
|
||||
Fenster, aus dem sie geöffnet wurden.
|
||||
- Adressen müssen mit `http://` oder `https://` beginnen; `javascript:` und
|
||||
Ähnliches wird abgewiesen.
|
||||
- Passen die gespeicherten Daten nicht mehr zum Schema, erscheint eine
|
||||
freundliche Erklärung statt eines Fehlers.
|
||||
- Der Scan-Zähler zählt den Aufruf des **Codes**, nicht das Neuladen der
|
||||
Seite.
|
||||
|
||||
**Video-Landingpage.** Der Rahmen zu YouTube oder Vimeo lädt erst nach einem
|
||||
Klick, und davor steht, dass dabei Daten an den Anbieter übertragen werden.
|
||||
Eingebettet wird die Variante ohne Cookies (`youtube-nocookie.com`). Direkte
|
||||
Videodateien laden mit `preload="none"` — wer unterwegs scannt, zahlt das
|
||||
Volumen.
|
||||
|
||||
---
|
||||
|
||||
## Dateien
|
||||
|
||||
PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt
|
||||
es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
||||
alles. Ein zweites Volumen wird beim Sichern zu leicht vergessen.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Erlaubt | PDF; PNG, JPEG, WebP, GIF, AVIF |
|
||||
| Nicht erlaubt | SVG und alles Übrige |
|
||||
| Größe | `MAX_UPLOAD_MB`, Voreinstellung 10 MB |
|
||||
| Geprüft wird | der Dateiinhalt, nicht die Endung |
|
||||
|
||||
**Warum kein SVG:** Eine SVG-Datei darf Skripte enthalten. Ausgeliefert von
|
||||
derselben Adresse wie die Anwendung könnte sie damit auf angemeldete
|
||||
Sitzungen zugreifen.
|
||||
|
||||
**Wie geprüft wird:** Der vom Browser gemeldete Typ kommt aus der Endung und
|
||||
lässt sich beliebig setzen. Entschieden wird deshalb anhand der ersten Bytes
|
||||
der Datei. Eine Textdatei namens `harmlos.pdf` wird abgewiesen.
|
||||
|
||||
**Wie ausgeliefert wird:** mit dem erkannten Typ, `X-Content-Type-Options:
|
||||
nosniff` und einer `Content-Security-Policy: sandbox` — damit auch ein PDF mit
|
||||
eingebetteten Skripten nichts anrichten kann. Ein Anhängen von `?download`
|
||||
erzwingt das Speichern statt der Anzeige.
|
||||
|
||||
**Wem eine Datei gehört:** Sie hängt am Code. Wird er gelöscht, verschwindet
|
||||
sie mit ihm. Eine ausgetauschte Datei verliert ihren Bezug und wird nach 24
|
||||
Stunden weggeräumt — ebenso eine hochgeladene, deren Code nie gespeichert
|
||||
wurde. Eine fremde Datei lässt sich nicht an den eigenen Code hängen; sonst
|
||||
könnte man sie einem anderen Code entziehen.
|
||||
|
||||
Die Adresse einer Datei ist nicht erratbar, aber öffentlich abrufbar —
|
||||
dieselbe Überlegung wie beim Slug einer Kurz-URL. Vertrauliche Unterlagen
|
||||
gehören nicht hinter einen QR-Code, den man abfotografieren kann.
|
||||
|
||||
---
|
||||
|
||||
## Feedback
|
||||
|
||||
Ein Feedback-Code erzeugt ein Formular mit fünf **Sternen** oder fünf
|
||||
**Gesichtern** und wahlweise einem Textfeld. Überschrift, Frage, Beschriftung
|
||||
des Textfelds und der Text nach dem Absenden sind frei formulierbar.
|
||||
|
||||
Gespeichert werden **nur die Note von 1 bis 5 und der Text**. Kein Name, keine
|
||||
IP-Adresse, keine Gerätekennung — das steht auch für die Antwortenden sichtbar
|
||||
unter dem Formular.
|
||||
|
||||
Gegen Fluten gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender.
|
||||
Die Adresse dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends
|
||||
gespeichert. Rückmeldungen zu Codes, die gar kein Feedback einsammeln, werden
|
||||
abgewiesen.
|
||||
|
||||
Die **Auswertung** ist aus der Codeliste erreichbar und zeigt Durchschnitt,
|
||||
Verteilung der Noten und alle Kommentare mit Datum (die 200 jüngsten). Sie
|
||||
sieht nur, wer den Code auch sehen darf.
|
||||
|
||||
---
|
||||
|
||||
## Gestaltung und Ausgabe
|
||||
|
||||
| Einstellung | Bereich | Voreinstellung |
|
||||
| --- | --- | --- |
|
||||
| Vordergrundfarbe | frei (Hex oder Farbwähler) | `#0F172A` |
|
||||
| Hintergrundfarbe | frei | `#FFFFFF` |
|
||||
| Größe | 256–2048 px | 512 px |
|
||||
| Fehlerkorrektur | L, M, Q, H | M |
|
||||
| Rand | 0–16 Module | 4 |
|
||||
|
||||
Fünf Farbvorschläge (Klassisch, Indigo, Waldgrün, Bordeaux, Marine) und ein
|
||||
Knopf zum Tauschen von Vorder- und Hintergrund. Reicht der Kontrast nicht,
|
||||
warnt die Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem.
|
||||
|
||||
Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
||||
Änderung. Herunterladen geht als **PNG** (in der eingestellten Größe) und als
|
||||
**SVG** (frei skalierbar). Die Größe betrifft nur das PNG.
|
||||
|
||||
---
|
||||
|
||||
## Meine Codes
|
||||
|
||||
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
||||
gespeichert werden.
|
||||
|
||||
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||
- **Suche** über Name, Ziel, Kurz-URL und Typ.
|
||||
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||
es auch eine Gruppenspalte.
|
||||
- **Kurz-URL kopieren** mit einem Klick.
|
||||
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||
- **PNG und SVG** erneut herunterladen.
|
||||
- Bei Feedback-Codes ein Weg zur **Auswertung**.
|
||||
|
||||
Auf schmalen Bildschirmen weichen die hinteren Spalten; die Kurz-URL rückt
|
||||
unter den Namen, Bearbeiten und Löschen bleiben erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Anmeldung, Gruppen und Rollen
|
||||
|
||||
Die Anmeldung ist **abschaltbar** (`AUTH_ENABLED`). Bewusst über die
|
||||
Konfiguration und nicht über die Oberfläche: So kann man sich nicht
|
||||
aussperren.
|
||||
|
||||
**Ohne Anmeldung** darf jeder, der die Anwendung erreicht, alles. Für den
|
||||
Einzelplatz- oder LAN-Betrieb ist das gewollt; ins offene Internet gehört die
|
||||
Anwendung dann nicht.
|
||||
|
||||
**Mit Anmeldung** gibt es vier Ebenen:
|
||||
|
||||
1. **Benutzer** — meldet sich mit E-Mail und Passwort an.
|
||||
2. **Gruppe** — bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
|
||||
gehört genau einer Gruppe.
|
||||
3. **Rolle je Gruppe:**
|
||||
| Rolle | Darf |
|
||||
| --- | --- |
|
||||
| Betrachter | sehen und herunterladen |
|
||||
| Bearbeiter | zusätzlich anlegen, ändern, löschen |
|
||||
| Administrator | zusätzlich Mitglieder und Rollen der Gruppe verwalten |
|
||||
4. **Administrator der Installation** — legt Gruppen, Benutzer und Domains
|
||||
überhaupt erst an.
|
||||
|
||||
Zur Umsetzung:
|
||||
|
||||
- Passwörter werden mit **scrypt** gehasht (Node-Bordmittel, kein natives
|
||||
Modul). Mindestlänge 10 Zeichen.
|
||||
- Nach **8 Fehlversuchen** ist das Konto 15 Minuten gesperrt.
|
||||
- Sitzungen liegen in der **Datenbank**, nicht in einem signierten Cookie —
|
||||
so lassen sie sich serverseitig beenden, etwa beim Deaktivieren eines
|
||||
Benutzers. Im Cookie steht ein Zufallstoken, in der Datenbank nur dessen
|
||||
SHA-256. Laufzeit 30 Tage.
|
||||
- Der Cookie ist `httpOnly` und `SameSite=Lax`; `Secure` wird gesetzt, sobald
|
||||
`BASE_URL` mit `https://` beginnt.
|
||||
- Alle Berechtigungsregeln stehen an **einer** Stelle
|
||||
(`src/lib/auth/policy.ts`) — nicht verteilt über die Oberfläche.
|
||||
- Im **Konto** sieht man die eigenen Gruppen und Rollen, ändert sein Passwort
|
||||
und meldet sich ab.
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-on
|
||||
|
||||
Die Anmeldung lässt sich an einen **OpenID-Connect-Anbieter** abgeben —
|
||||
getestet mit Authentik, ebenso nutzbar mit Keycloak, Zitadel oder Entra ID.
|
||||
Alles Nötige kommt aus dem Discovery-Dokument des Anbieters.
|
||||
|
||||
- Anmeldefluss mit **PKCE** (S256), `state` und `nonce`.
|
||||
- Das ID-Token wird gegen den **JWKS** des Anbieters geprüft: Signatur,
|
||||
Aussteller, Empfänger, Ablauf und Nonce.
|
||||
- Wiedererkannt wird jemand an der **Kennung des Anbieters** (`sub`), nicht an
|
||||
der E-Mail-Adresse — Adressen ändern sich.
|
||||
- Ein bestehendes Konto mit derselben Adresse wird **verknüpft statt
|
||||
verdoppelt**, aber nur bei **bestätigter** Adresse. Sonst könnte sich beim
|
||||
Anbieter jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||
übernehmen.
|
||||
- Neue Konten bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
|
||||
Standardgruppe.
|
||||
- `OIDC_ADMIN_GROUP` hängt die Instanz-Administration an eine Gruppe beim
|
||||
Anbieter; sie wird dann bei jeder Anmeldung neu entschieden.
|
||||
- `OIDC_ONLY=true` blendet die Passwort-Anmeldung aus.
|
||||
|
||||
**Bewusst nicht übernommen** werden Gruppen und Rollen. Die Gruppen im
|
||||
Verzeichnis bedeuten etwas anderes als die hier: dort geht es um Organisation
|
||||
und Zugang, hier darum, wer welche Codes sehen und ändern darf. Eine
|
||||
automatische Übersetzung wäre eine Quelle stiller Überraschungen.
|
||||
|
||||
→ Einrichtung Feld für Feld: [authentik.md](authentik.md)
|
||||
|
||||
---
|
||||
|
||||
## Mehrere Domains
|
||||
|
||||
Kurz-URLs können unter verschiedenen Adressen laufen — etwa je Kunde eine.
|
||||
Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
||||
|
||||
- Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
||||
**prüft ihn gegen die registrierten Domains**. Ein unbekannter Hostname
|
||||
endet mit 404 — über einen gefälschten Header lässt sich also keine fremde
|
||||
Domain ansprechen.
|
||||
- Der **Slug ist je Domain eindeutig**, nicht global.
|
||||
- Eine Domain kann **mehreren Gruppen** dienen. Die Codes bleiben trotzdem
|
||||
strikt nach Gruppe getrennt.
|
||||
- Je Gruppe lässt sich eine Domain als Voreinstellung festlegen.
|
||||
- Eine Domain lässt sich abschalten, ohne sie zu löschen; ihre Codes
|
||||
antworten dann mit einer Erklärung.
|
||||
|
||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||
Anwendung leiten.
|
||||
|
||||
---
|
||||
|
||||
## Erscheinungsbild
|
||||
|
||||
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
|
||||
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
|
||||
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
|
||||
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
|
||||
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
|
||||
|
||||
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
|
||||
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
|
||||
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
|
||||
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
|
||||
|
||||
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
|
||||
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
|
||||
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
|
||||
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
|
||||
benutzbar.
|
||||
|
||||
### Hell und dunkel
|
||||
|
||||
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
|
||||
Kopfzeile steht ein Umschalter mit drei Zuständen:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
|
||||
| **Hell** | immer hell |
|
||||
| **Dunkel** | immer dunkel |
|
||||
|
||||
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
|
||||
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
|
||||
Tab zieht automatisch nach.
|
||||
|
||||
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
|
||||
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
|
||||
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
|
||||
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
|
||||
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
|
||||
sogar **ohne JavaScript**.
|
||||
|
||||
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
|
||||
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
|
||||
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
|
||||
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
|
||||
|
||||
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
|
||||
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
|
||||
aussehen.
|
||||
|
||||
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
|
||||
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
|
||||
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
|
||||
als eigener Punkt auf der [Roadmap](roadmap.md).
|
||||
|
||||
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
|
||||
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
|
||||
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
|
||||
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
|
||||
|
||||
---
|
||||
|
||||
## Verwaltung
|
||||
|
||||
| Bereich | Was dort geht |
|
||||
| --- | --- |
|
||||
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
||||
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
|
||||
| **QR-Typen** | einzelne Typen ausblenden |
|
||||
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
||||
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
|
||||
|
||||
Zu den **QR-Typen**: 15 Kacheln sind viel, wenn ihr nur drei davon braucht.
|
||||
Ein abgeschalteter Typ verschwindet aus der Auswahl; **bereits erstellte Codes
|
||||
funktionieren unverändert weiter** — sie leiten weiter, zählen Scans und
|
||||
lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
|
||||
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
|
||||
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
||||
|
||||
---
|
||||
|
||||
## Betrieb
|
||||
|
||||
- **Zwei Container:** die Next.js-Anwendung und PostgreSQL. Kein Reverse Proxy
|
||||
nötig — Port 3000 wird direkt veröffentlicht. Ein Proxy davor geht
|
||||
selbstverständlich auch.
|
||||
- **Migrationen laufen beim Start** von selbst. Es gibt keinen Extraschritt
|
||||
nach `docker compose up -d --build`.
|
||||
- Die Datenbank hat einen **Healthcheck**; die Anwendung startet erst, wenn
|
||||
sie wirklich Anfragen annimmt.
|
||||
- **Ein Volumen** für die Datenbank — dort liegen auch die hochgeladenen
|
||||
Dateien.
|
||||
- Die Konfiguration steht vollständig in einer **`.env`**; eine `.env.example`
|
||||
ist mitgeliefert und dokumentiert jeden Wert.
|
||||
- Die Anwendung liest die Werte **zur Laufzeit**, nicht beim Bauen — eine
|
||||
geänderte `BASE_URL` braucht keinen neuen Build.
|
||||
- Die Oberfläche ist durchgängig **deutsch**, hell, responsiv und ohne
|
||||
externe Schriften oder Skripte.
|
||||
|
||||
---
|
||||
|
||||
## Schnittstelle
|
||||
|
||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||
|
||||
| Route | Zweck |
|
||||
| --- | --- |
|
||||
| `POST /api/qr/preview` | Vorschau erzeugen (statisch oder dynamisch) |
|
||||
| `POST /api/qr/download` | PNG oder SVG erzeugen |
|
||||
| `GET/POST /api/codes` | dynamische Codes auflisten und anlegen |
|
||||
| `GET/PATCH/DELETE /api/codes/{id}` | einzelnen Code lesen, ändern, löschen |
|
||||
| `GET /api/codes/{id}/qr` | Bild eines gespeicherten Codes |
|
||||
| `POST /api/uploads` | Datei hochladen (`GET` nennt die Größengrenze) |
|
||||
| `GET /api/uploads/{id}` | Datei ausliefern (`?download` erzwingt Speichern) |
|
||||
| `POST /api/feedback` | Rückmeldung abgeben |
|
||||
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
|
||||
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
|
||||
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
|
||||
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
|
||||
| `GET /s/{slug}` | öffentliche Landingpage |
|
||||
|
||||
Fehler kommen als `{ "error": "…" }` mit einer deutschen Meldung, die sich
|
||||
unverändert anzeigen lässt.
|
||||
|
||||
---
|
||||
|
||||
## Was die Anwendung bewusst nicht tut
|
||||
|
||||
- **Kein Logo im QR-Code.** Wer es braucht, nimmt das SVG, setzt es im
|
||||
Layoutprogramm ein, wählt Fehlerkorrektur H und testet mit mehreren Geräten.
|
||||
- **Keine Statistik über die Zeit.** Der Zähler weiß, wie oft und wann zuletzt
|
||||
— kein Verlauf, keine Herkunft, keine Geräte. Das wäre eine eigene Tabelle
|
||||
und die Frage, wie lange man so etwas aufbewahrt.
|
||||
- **Keine Übernahme von Gruppen aus dem Verzeichnis.** Siehe oben.
|
||||
- **Keine Stapelverarbeitung.** Codes entstehen einzeln.
|
||||
- **Keine Mehrsprachigkeit.** Die Oberfläche ist deutsch.
|
||||
- **Kein Papierkorb.** Ein gelöschter Code ist weg, samt seiner Dateien und
|
||||
Rückmeldungen.
|
||||
|
||||
Was davon als Nächstes sinnvoll wäre, steht in der [Roadmap](roadmap.md).
|
||||
@@ -0,0 +1,242 @@
|
||||
# Roadmap
|
||||
|
||||
Was gebaut ist, was als Nächstes kommt und was danach denkbar wäre. Die
|
||||
Aufwände sind grobe Einschätzungen, keine Zusagen.
|
||||
|
||||
---
|
||||
|
||||
## Gebaut
|
||||
|
||||
### Version 1 — Grundgerüst (13.08.2026)
|
||||
|
||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
||||
Proxy, Migrationen beim Start
|
||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
||||
Formular, ohne Eingriff in den übrigen Code
|
||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
||||
Scan-Zähler
|
||||
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
||||
- Download als PNG und SVG, serverseitig gerendert
|
||||
- „Meine Codes“ mit Ziel ändern und Löschen
|
||||
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
||||
- Update-Skript für Linux/macOS und Windows
|
||||
|
||||
### Typen einzeln freigeben (14.08.2026)
|
||||
|
||||
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
||||
eines abgeschalteten Typs funktionieren weiter.
|
||||
|
||||
### Phase A — acht weitere Typen (14.08.2026)
|
||||
|
||||
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
||||
Zahlungslink), WLAN.
|
||||
|
||||
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
||||
|
||||
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
||||
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
||||
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
||||
|
||||
### Phase B2 — mehrere Domains (14.08.2026)
|
||||
|
||||
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
||||
Host-Header gegen die registrierten Domains geprüft.
|
||||
|
||||
### Listenansicht (14.08.2026)
|
||||
|
||||
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
||||
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
||||
|
||||
### Phase C — die letzten sechs Typen (14.08.2026)
|
||||
|
||||
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
||||
|
||||
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
||||
- Feedback mit anonymer Erhebung und Auswertung
|
||||
|
||||
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
||||
|
||||
### Single Sign-on (15.08.2026)
|
||||
|
||||
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
||||
ID-Token.
|
||||
|
||||
### Erscheinungsbild (16.08.2026)
|
||||
|
||||
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
|
||||
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
|
||||
Umbau der rund 60 eingefärbten Stellen.
|
||||
|
||||
### Dark Mode (16.08.2026)
|
||||
|
||||
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
|
||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
|
||||
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
|
||||
|
||||
### Sprechende Kurz-Links (17.08.2026)
|
||||
|
||||
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
|
||||
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
|
||||
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
|
||||
vorbehalten, per Einstellung für Bearbeiter freigebbar.
|
||||
|
||||
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
|
||||
|
||||
### Statische Codes benennen und ablegen (20.08.2026)
|
||||
|
||||
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
|
||||
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
|
||||
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
|
||||
|
||||
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
|
||||
|
||||
### Dokumentation (15.08.2026)
|
||||
|
||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
||||
|
||||
---
|
||||
|
||||
## Als Nächstes
|
||||
|
||||
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||
Person, die den Code kennt.
|
||||
|
||||
### 1. QR-Typen je Gruppe freigeben · mittel
|
||||
|
||||
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
||||
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
||||
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
||||
|
||||
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
||||
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
||||
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
||||
entscheidet die gewählte Gruppe.
|
||||
|
||||
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
||||
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
||||
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
||||
wahrscheinlich die richtige.
|
||||
|
||||
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
||||
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
||||
eine Gruppenauswahl bekommt.
|
||||
|
||||
### 2. Statistik über die Zeit · mittel
|
||||
|
||||
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||
eingekehrt?
|
||||
|
||||
Geplant: eine Tabelle mit einer Zeile je Code und Tag, dazu ein Diagramm in
|
||||
der Codeansicht und die Zahlen der letzten 7, 30 und 365 Tage.
|
||||
|
||||
Bewusst **ohne** Herkunft, Gerät oder Verweisseite. Ein reiner Tageszähler
|
||||
sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
||||
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||
|
||||
### 3. Papierkorb statt Löschen · klein
|
||||
|
||||
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||
samt seiner Dateien und Rückmeldungen.
|
||||
|
||||
Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
||||
„Meine Codes“ ein Reiter „Papierkorb“ mit Wiederherstellen. Solange ein Code
|
||||
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||
|
||||
### 4. Logo im QR-Code · mittel
|
||||
|
||||
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
||||
|
||||
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
||||
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
||||
|
||||
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
||||
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
||||
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
||||
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
||||
die verdeckte Fläche.
|
||||
|
||||
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
||||
|
||||
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||
|
||||
Geplant, in zwei Stufen:
|
||||
|
||||
- **Ablauf:** ab einem Zeitpunkt zeigt der Code eine erklärende Seite statt
|
||||
des Ziels. Wichtig ist der Text — wer davorsteht, soll erfahren, dass die
|
||||
Aktion vorbei ist, nicht dass „etwas nicht gefunden“ wurde.
|
||||
- **Zeitplan:** mehrere Ziele mit Gültigkeitsfenstern, damit derselbe
|
||||
Aufsteller montags auf die Mittagskarte und samstags auf die Veranstaltung
|
||||
zeigt.
|
||||
|
||||
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||
Fenstern geschieht.
|
||||
|
||||
---
|
||||
|
||||
## Später denkbar
|
||||
|
||||
**Das Fenster der Ersteinrichtung schließen** · klein
|
||||
Heute wird das erste Konto ohne jede Legitimation angelegt und ist gleich
|
||||
Administrator. Wer die Anwendung öffentlich stellt, bevor er sein Konto
|
||||
angelegt hat, kann sie an einen Fremden verlieren. Vorschlag: ein einmaliges
|
||||
Token, das beim ersten Start ins Protokoll geschrieben wird und für die
|
||||
Ersteinrichtung nötig ist. Klein im Aufwand — und das Erste, was zu tun ist,
|
||||
falls die Anwendung je aus dem eigenen Netz herausgeht. Siehe
|
||||
[sicherheit.md](sicherheit.md).
|
||||
|
||||
**Codes exportieren** · klein
|
||||
Eine CSV aller Codes einer Gruppe mit Name, Kurz-URL, Ziel und Scans — für
|
||||
die Übergabe an eine Druckerei oder die Ablage.
|
||||
|
||||
**Codes duplizieren** · klein
|
||||
„Wie dieser, aber mit anderem Ziel“ — bei vielen ähnlichen Projekten spart
|
||||
das jedes Mal ein paar Minuten.
|
||||
|
||||
**Stapelverarbeitung** · mittel
|
||||
Eine CSV hochladen und daraus 200 Codes erzeugen, als ZIP zurück. Sinnvoll,
|
||||
sobald jedes Produkt oder jeder Tisch einen eigenen Code bekommt.
|
||||
|
||||
**Gruppen aus dem Verzeichnis übernehmen** · mittel
|
||||
Die OIDC-Anbindung übernimmt heute bewusst nur die Identität. Eine Zuordnung
|
||||
von Verzeichnisgruppen zu Gruppen dieser Anwendung ist möglich, braucht aber
|
||||
eine klare Regel dafür, was mit von Hand vergebenen Rollen geschieht — sonst
|
||||
verliert jemand beim nächsten Anmelden stillschweigend seine Rechte.
|
||||
|
||||
**Passwortgeschützte Landingpages** · mittel
|
||||
Für Unterlagen, die nicht jeder sehen soll, der den Aushang fotografiert.
|
||||
Heute ist die Empfehlung klar: Vertrauliches gehört nicht hinter einen
|
||||
offenen QR-Code.
|
||||
|
||||
**Dateien außerhalb der Datenbank** · mittel
|
||||
Heute liegen Uploads in PostgreSQL, damit es bei einem Volumen bleibt. Wer
|
||||
sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
|
||||
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
|
||||
die Auslieferungsroute — aber auch die Sicherungsstrategie.
|
||||
|
||||
**Erscheinungsbild je Gruppe** · groß
|
||||
Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
|
||||
Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
|
||||
und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit
|
||||
eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch
|
||||
überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit
|
||||
nichts Unlesbares entsteht.
|
||||
|
||||
**Mehrsprachige Oberfläche** · groß
|
||||
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
|
||||
Landingpages. Eine zweite Sprache berührt jede Datei.
|
||||
|
||||
---
|
||||
|
||||
## Wie ein neuer Typ dazukommt
|
||||
|
||||
Falls doch noch einer fehlt: Er besteht aus einem Modul in
|
||||
`src/lib/qr/types/`, einer Formular-Komponente, je einem Registry-Eintrag und
|
||||
— wenn er eine eigene Seite braucht — einer Landingpage. Am übrigen Code
|
||||
ändert sich nichts. Näheres im [README](../README.md), Abschnitt „Einen
|
||||
weiteren Typ ergänzen“.
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Erzeugt die Bildschirmfotos für docs/anleitung.md.
|
||||
*
|
||||
* Warum als Skript und nicht von Hand: Bilder in einer Anleitung veralten
|
||||
* beim ersten Umbau. So lassen sie sich in einer Minute neu erzeugen.
|
||||
*
|
||||
* Voraussetzungen:
|
||||
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
||||
* Codes im Bild landen), erreichbar unter BASE
|
||||
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
||||
* Regelbetrieb
|
||||
* - Playwright: npm i -D playwright && npx playwright install chromium
|
||||
*
|
||||
* Aufruf:
|
||||
* node docs/screenshots.mjs http://localhost:3000
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
||||
await mkdir(ZIEL, { recursive: true });
|
||||
|
||||
const ADMIN = { email: "demo@example.com", password: "nur-fuer-die-bilder-1" };
|
||||
const DESIGN = { fgColor: "#312E81", bgColor: "#FFFFFF", size: 512, ecLevel: "M", margin: 4 };
|
||||
|
||||
/* ------------------------------------------------------------- Anmelden */
|
||||
|
||||
let cookie = null;
|
||||
|
||||
async function api(pfad, init = {}) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
if (init.json !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (cookie) headers.set("Cookie", cookie);
|
||||
const response = await fetch(BASE + pfad, {
|
||||
...init,
|
||||
headers,
|
||||
body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const gesetzt = response.headers.get("set-cookie");
|
||||
if (gesetzt) cookie = gesetzt.split(";")[0];
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
/* leerer Rumpf */
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
const zustand = await api("/api/auth");
|
||||
if (zustand.body?.authEnabled) {
|
||||
const aktion = zustand.body.setupOpen ? "setup" : "login";
|
||||
const angemeldet = await api("/api/auth", {
|
||||
method: "POST",
|
||||
json: { action: aktion, ...ADMIN, name: "Demo Betreiber" },
|
||||
});
|
||||
if (angemeldet.status >= 400) {
|
||||
console.error("Anmeldung fehlgeschlagen:", angemeldet.body);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Beispieldaten */
|
||||
|
||||
const angelegt = [];
|
||||
|
||||
async function code(type, data, label) {
|
||||
const erstellt = await api("/api/codes", {
|
||||
method: "POST",
|
||||
json: { type, data, design: DESIGN, label },
|
||||
});
|
||||
if (erstellt.status !== 201) {
|
||||
console.error(`Beispielcode „${label}“ ging nicht:`, erstellt.body);
|
||||
process.exit(1);
|
||||
}
|
||||
angelegt.push(erstellt.body.code);
|
||||
return erstellt.body.code;
|
||||
}
|
||||
|
||||
await code("website", { url: "https://example.com/nordwind" }, "Projekt Nordwind");
|
||||
await code("website", { url: "https://example.com/messe" }, "Messestand Halle 3");
|
||||
|
||||
const links = await code(
|
||||
"links",
|
||||
{
|
||||
title: "Projekt Nordwind",
|
||||
description: "Alle Unterlagen auf einen Blick.",
|
||||
links: [
|
||||
{ label: "Preisliste als PDF", url: "https://example.com/preise.pdf" },
|
||||
{ label: "Anfahrt zum Werk", url: "https://example.com/anfahrt" },
|
||||
{ label: "Ansprechpartner", url: "https://example.com/kontakt" },
|
||||
],
|
||||
},
|
||||
"Nordwind Linkseite",
|
||||
);
|
||||
|
||||
const social = await code(
|
||||
"social",
|
||||
{
|
||||
title: "Bäckerei Sonnenschein",
|
||||
description: "Folge uns für Neues aus der Backstube.",
|
||||
profiles: [
|
||||
{ network: "instagram", url: "@sonnenschein" },
|
||||
{ network: "facebook", url: "sonnenschein.backt" },
|
||||
{ network: "youtube", url: "@sonnenschein" },
|
||||
{ network: "website", url: "https://sonnenschein.example.com" },
|
||||
],
|
||||
},
|
||||
"Sonnenschein Profile",
|
||||
);
|
||||
|
||||
/* --- Eine Datei zum Vorzeigen: klein, aber gültig ------------------------ */
|
||||
|
||||
async function hochladen(kind, name, inhalt, typ) {
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", new File([inhalt], name, { type: typ }));
|
||||
const antwort = await api("/api/uploads", { method: "POST", body: form });
|
||||
if (antwort.status !== 201) {
|
||||
console.error(`Upload „${name}“ ging nicht:`, antwort.body);
|
||||
process.exit(1);
|
||||
}
|
||||
return antwort.body.upload;
|
||||
}
|
||||
|
||||
const pdfDatei = await hochladen(
|
||||
"pdf",
|
||||
"preisliste-2026.pdf",
|
||||
Buffer.from(
|
||||
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n" +
|
||||
"trailer<</Root 1 0 R>>\n%%EOF\n",
|
||||
"latin1",
|
||||
),
|
||||
"application/pdf",
|
||||
);
|
||||
|
||||
const pdf = await code(
|
||||
"pdf",
|
||||
{
|
||||
fileId: pdfDatei.id,
|
||||
filename: pdfDatei.filename,
|
||||
size: pdfDatei.size,
|
||||
title: "Preisliste 2026",
|
||||
description: "Gültig ab Januar, alle Preise ohne Mehrwertsteuer.",
|
||||
},
|
||||
"Preisliste",
|
||||
);
|
||||
|
||||
const feedback = await code(
|
||||
"feedback",
|
||||
{
|
||||
title: "Wie war dein Besuch?",
|
||||
question: "Wie zufrieden bist du?",
|
||||
scale: "stars",
|
||||
allowComment: true,
|
||||
commentLabel: "Möchtest du uns noch etwas sagen?",
|
||||
thankYou: "Danke, das hilft uns weiter!",
|
||||
},
|
||||
"Gastraum",
|
||||
);
|
||||
|
||||
// Ein paar Rückmeldungen, damit die Auswertung nicht leer ist.
|
||||
for (const [note, text] of [
|
||||
[5, "Sehr freundlich, gerne wieder."],
|
||||
[4, ""],
|
||||
[5, "Der Kuchen war hervorragend."],
|
||||
[3, "Etwas lange Wartezeit."],
|
||||
]) {
|
||||
await api("/api/feedback", {
|
||||
method: "POST",
|
||||
json: { codeId: feedback.id, rating: note, comment: text },
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Aufnehmen */
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const desktop = await browser.newContext({
|
||||
viewport: { width: 1200, height: 820 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: "de-DE",
|
||||
});
|
||||
if (cookie) {
|
||||
const [name, ...rest] = cookie.split("=");
|
||||
await desktop.addCookies([{ name, value: rest.join("="), url: BASE }]);
|
||||
}
|
||||
|
||||
const handy = await browser.newContext({
|
||||
viewport: { width: 390, height: 800 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: "de-DE",
|
||||
});
|
||||
|
||||
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||
if (vorher) await vorher(page);
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
|
||||
await page.close();
|
||||
console.log(" ✓", datei);
|
||||
}
|
||||
|
||||
console.log("Bilder werden erzeugt:");
|
||||
|
||||
await schuss(desktop, "/", "01-startseite.png");
|
||||
|
||||
await schuss(desktop, "/erstellen/website", "02-website-formular.png", {
|
||||
vorher: async (page) => {
|
||||
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||
await page.waitForTimeout(900);
|
||||
},
|
||||
});
|
||||
|
||||
await schuss(desktop, "/erstellen/website", "03-dynamisch.png", {
|
||||
vorher: async (page) => {
|
||||
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||
await page.locator('button[data-mode="dynamic"]').click();
|
||||
await page.waitForTimeout(900);
|
||||
},
|
||||
});
|
||||
|
||||
await schuss(desktop, "/meine-codes", "04-meine-codes-liste.png");
|
||||
|
||||
await schuss(desktop, "/meine-codes", "05-meine-codes-karten.png", {
|
||||
vorher: async (page) => {
|
||||
await page.locator('button[data-view="cards"]').click();
|
||||
await page.waitForTimeout(600);
|
||||
},
|
||||
});
|
||||
|
||||
await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
|
||||
ganzeSeite: false,
|
||||
vorher: async (page) => {
|
||||
await page.locator('button[data-view="list"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
||||
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
|
||||
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
|
||||
},
|
||||
});
|
||||
|
||||
await schuss(handy, `/s/${links.slug}`, "07-landing-links.png");
|
||||
await schuss(handy, `/s/${social.slug}`, "08-landing-social.png");
|
||||
await schuss(handy, `/s/${pdf.slug}`, "09-landing-pdf.png");
|
||||
await schuss(handy, `/s/${feedback.slug}`, "09b-landing-feedback.png");
|
||||
await schuss(desktop, `/meine-codes/${feedback.id}/feedback`, "10-feedback-auswertung.png");
|
||||
|
||||
await schuss(desktop, "/verwaltung", "11-verwaltung.png");
|
||||
await schuss(desktop, "/verwaltung/typen", "12-verwaltung-typen.png");
|
||||
await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
|
||||
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
|
||||
await schuss(desktop, "/konto", "15-konto.png");
|
||||
|
||||
await browser.close();
|
||||
|
||||
/* ------------------------------------------------------------ Aufräumen */
|
||||
|
||||
for (const eintrag of angelegt) {
|
||||
await api(`/api/codes/${eintrag.id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`);
|
||||
console.log(`Die Bilder liegen in ${ZIEL}.`);
|
||||
@@ -0,0 +1,285 @@
|
||||
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
||||
|
||||
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
||||
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
|
||||
|
||||
Geprüft gegen:
|
||||
|
||||
- [Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
||||
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||
- [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
|
||||
- [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
|
||||
> **Wozu das gehört.** Die Anwendung ist ein selbst gehostetes Werkzeug für
|
||||
> ein Team, nicht ein öffentlicher Dienst mit Zehntausenden Konten. Wo eine
|
||||
> Empfehlung erkennbar auf den zweiten Fall gemünzt ist, steht unten, warum
|
||||
> hier etwas anderes gilt — und wie man es strenger einstellt.
|
||||
|
||||
---
|
||||
|
||||
## Was behoben wurde
|
||||
|
||||
### 1. scrypt lag unter der empfohlenen Stärke
|
||||
|
||||
**Befund:** Die Passwörter wurden mit scrypt bei `N = 2^15` gehasht. Das
|
||||
Merkblatt nennt `N = 2^17 (128 MiB), r = 8, p = 1`.
|
||||
|
||||
**Behoben:** `N = 2^17`. Alte Hashes bleiben gültig — die Parameter stehen im
|
||||
Hash. Beim nächsten erfolgreichen Anmelden wird das Passwort still neu
|
||||
gehasht (`needsRehash()`); das ist der einzige Moment, in dem das
|
||||
Klartextpasswort vorliegt.
|
||||
|
||||
**Nebenwirkung:** Ein Anmeldevorgang kostet jetzt spürbar mehr Rechenzeit und
|
||||
rund 128 MiB Arbeitsspeicher. Genau das ist der Zweck.
|
||||
|
||||
### 2. Passwörter hatten keine Obergrenze
|
||||
|
||||
**Befund:** Die Eingabe war nach oben offen. Ein „Passwort“ von mehreren
|
||||
Megabyte hätte scrypt beschäftigt — an einem Endpunkt, der **ohne Anmeldung**
|
||||
erreichbar ist. Ein Denial-of-Service mit wenigen Anfragen.
|
||||
|
||||
**Behoben:** 128 Zeichen, geprüft vor dem Hashen, in allen drei Formularen
|
||||
(Anmelden, Ersteinrichtung, Passwortwechsel) sowie in der Benutzerverwaltung.
|
||||
|
||||
### 3. Kein CSRF-Schutz außer `SameSite`
|
||||
|
||||
**Befund:** Der Sitzungs-Cookie war `SameSite=Lax`, mehr nicht. Das Merkblatt
|
||||
sagt ausdrücklich, dass das allein nicht genügt: `SameSite` wirkt auf der
|
||||
Ebene der registrierbaren Domain, nicht des Ursprungs. Besonders heikel war
|
||||
`POST /api/uploads` — es nimmt `multipart/form-data` entgegen, und das kann
|
||||
ein Formular auf einer fremden Seite abschicken.
|
||||
|
||||
**Behoben:** Jeder schreibende Zugriff (POST, PUT, PATCH, DELETE) wird gegen
|
||||
den `Origin`-Header geprüft, verglichen mit dem Host der Anfrage — nicht mit
|
||||
`BASE_URL`, weil die Anwendung absichtlich unter mehreren Domains läuft.
|
||||
Fehlt der Header ganz, wird durchgelassen: Browser setzen ihn bei fremden
|
||||
Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
|
||||
|
||||
### 4. Keine Schutzheader
|
||||
|
||||
**Befund:** Die Anwendung setzte keinen einzigen: kein `X-Frame-Options`,
|
||||
keine `Content-Security-Policy`, kein `Referrer-Policy`,
|
||||
kein `X-Content-Type-Options`. Die Verwaltung ließ sich also in eine fremde
|
||||
Seite einbetten und per Clickjacking fernsteuern. Dazu verriet
|
||||
`X-Powered-By: Next.js`, wonach jemand Schwachstellen suchen müsste.
|
||||
|
||||
**Behoben** in `src/proxy.ts`:
|
||||
|
||||
| Header | Wert |
|
||||
| --- | --- |
|
||||
| `Content-Security-Policy` | `frame-ancestors 'none'` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | Kamera, Mikrofon, Standort, Bezahlen aus |
|
||||
|
||||
`X-Powered-By` ist abgeschaltet.
|
||||
|
||||
**Bewusst nicht gesetzt** ist eine vollständige `Content-Security-Policy` für
|
||||
Skripte. Next.js bringt eingebettete Skripte mit; dafür bräuchte es Nonces
|
||||
durch die ganze Anwendung. Eine Policy mit `unsafe-inline` sähe nach Schutz
|
||||
aus, ohne einer zu sein. Siehe [Offen](#offen).
|
||||
|
||||
### 5. Sitzungen ohne absolute Obergrenze
|
||||
|
||||
**Befund:** Die Sitzung lief 30 Tage nach der letzten Nutzung ab — und wurde
|
||||
bei jeder Nutzung verlängert. Eine einmal entwendete Sitzung hätte durch
|
||||
regelmäßige Nutzung **unbegrenzt** weitergelebt.
|
||||
|
||||
**Behoben:** Zusätzlich eine harte Grenze ab dem Anmelden. Über die
|
||||
Verlängerung hinaus wird nie verlängert.
|
||||
|
||||
| Einstellung | Voreinstellung | Empfehlung des Merkblatts |
|
||||
| --- | --- | --- |
|
||||
| `SESSION_IDLE_DAYS` | 30 Tage | 15–30 **Minuten** bei geringem Risiko |
|
||||
| `SESSION_MAX_DAYS` | 90 Tage | 4–8 **Stunden** absolut |
|
||||
|
||||
Die Voreinstellungen liegen bewusst weit darüber. Die Zahlen des Merkblatts
|
||||
zielen auf öffentlich erreichbare Anwendungen; bei einem internen Werkzeug,
|
||||
an dem man täglich arbeitet, wäre eine halbstündige Abmeldung eine Zumutung
|
||||
und würde nur dazu führen, dass jemand den Browser dauerhaft offen lässt. Wer
|
||||
strenger fahren will, setzt beide Werte in der `.env` — dafür sind sie
|
||||
einstellbar.
|
||||
|
||||
### 6. `Secure` fehlte hinter einem TLS-Proxy
|
||||
|
||||
**Befund:** Das `Secure`-Flag am Cookie hing allein an `BASE_URL`. Wer die
|
||||
Anwendung hinter einem Proxy mit HTTPS betreibt, aber `BASE_URL` auf `http`
|
||||
stehen lässt, bekam den Cookie ohne `Secure` — er wäre über eine
|
||||
unverschlüsselte Verbindung mitgegangen.
|
||||
|
||||
**Behoben:** Zusätzlich wird `X-Forwarded-Proto` ausgewertet.
|
||||
|
||||
### 7. Die Anmeldung verriet, welche Konten es gibt
|
||||
|
||||
**Befund:** Bei falschem Passwort kam immer dieselbe Meldung — richtig. Aber
|
||||
drei Fälle fielen aus der Reihe und bestätigten damit die Existenz eines
|
||||
Kontos:
|
||||
|
||||
- ein deaktiviertes Konto („Dieses Konto ist deaktiviert“),
|
||||
- ein Konto aus dem Verzeichnis („meldet sich über den externen Anbieter an“),
|
||||
- ein gesperrtes Konto (429 mit Wartezeit).
|
||||
|
||||
**Behoben** für die ersten beiden:
|
||||
|
||||
- Die Prüfung auf „deaktiviert“ steht jetzt **nach** der Passwortprüfung. Wer
|
||||
bis dahin kommt, hat das Konto nachweislich in der Hand — ihm darf man
|
||||
sagen, warum es nicht weitergeht.
|
||||
- Verzeichnis-Konten bekommen die allgemeine Meldung. Der Knopf für den
|
||||
Anbieter steht daneben.
|
||||
|
||||
**Bewusst offen gelassen:** die Sperrmeldung. Sie muss vor der Passwortprüfung
|
||||
kommen — das ist ihr Zweck. Wer sie versteckt, lässt einen rechtmäßigen
|
||||
Benutzer 15 Minuten im Dunkeln stehen. Bei einem Werkzeug, in dem die
|
||||
E-Mail-Adressen der Kollegen ohnehin bekannt sind, wiegt die Verständlichkeit
|
||||
schwerer als ein Hinweis, den ein Angreifer auch anders bekommt.
|
||||
|
||||
### 8. Kontosperre schützte nicht gegen breites Durchprobieren
|
||||
|
||||
**Befund:** Nach 8 Fehlversuchen wurde ein **Konto** 15 Minuten gesperrt. Wer
|
||||
stattdessen ein Passwort gegen viele Konten probiert, löst diese Sperre nie
|
||||
aus.
|
||||
|
||||
**Behoben:** zusätzlich eine Bremse je Absender — 20 Versuche in 5 Minuten,
|
||||
danach 429. Im Arbeitsspeicher und damit pro Container; die Adresse dient nur
|
||||
als Schlüssel und wird nirgends gespeichert.
|
||||
|
||||
### 9. Ersteinrichtung mit Wettlauf
|
||||
|
||||
**Befund:** Zwischen „gibt es schon einen Benutzer?“ und dem Anlegen lag eine
|
||||
Lücke. Zwei gleichzeitige Aufrufe auf einer frischen Installation hätten
|
||||
**zwei** Administratoren erzeugt — einer davon ungebeten.
|
||||
|
||||
**Behoben:** Prüfung und Anlegen laufen in einer serialisierbaren
|
||||
Transaktion. Ein Test schickt drei Anfragen gleichzeitig; genau eine kommt
|
||||
durch.
|
||||
|
||||
### 10. Abgelaufene Sitzungen blieben liegen
|
||||
|
||||
**Befund:** `pruneExpiredSessions()` existierte, wurde aber nirgends
|
||||
aufgerufen. Kein Sicherheitsproblem — abgelaufene Sitzungen werden bei der
|
||||
Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
|
||||
|
||||
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
||||
wird beiläufig aufgeräumt.
|
||||
|
||||
---
|
||||
|
||||
## Was schon vorher stimmte
|
||||
|
||||
| Anforderung | Umsetzung |
|
||||
| --- | --- |
|
||||
| Sitzungs-Token aus einem CSPRNG, ≥ 128 Bit | 32 Byte aus `randomBytes` |
|
||||
| Token nicht im Klartext gespeichert | in der Datenbank nur der SHA-256 |
|
||||
| `HttpOnly` | gesetzt |
|
||||
| `SameSite` | `Lax` |
|
||||
| Pfad so eng wie möglich | `/` — die Anwendung liegt an der Wurzel |
|
||||
| Neue Sitzung beim Anmelden | immer ein frisches Token; keine Sitzungsfixierung möglich |
|
||||
| Alle Sitzungen beenden beim Passwortwechsel | ja, danach sofort eine neue |
|
||||
| Sitzung entwerten beim Deaktivieren | bei jedem Zugriff wird `isActive` geprüft |
|
||||
| Passwortvergleich in konstanter Zeit | `timingSafeEqual` |
|
||||
| Gleiche Antwortzeit für unbekannte Adressen | Vergleich gegen einen Attrappen-Hash mit denselben Parametern |
|
||||
| Berechtigungen an einer Stelle | `src/lib/auth/policy.ts`; jede Route fragt sie |
|
||||
| Serverseitige Prüfung, nicht nur ausgeblendete Knöpfe | ja |
|
||||
|
||||
Zur **Anmeldung über OIDC** (siehe [authentik.md](authentik.md)): PKCE mit
|
||||
S256, `state` gegen CSRF, `nonce` gegen wiedereingereichte Token, Prüfung des
|
||||
ID-Tokens gegen den JWKS des Anbieters samt Aussteller, Empfänger und Ablauf.
|
||||
Die Rückkehradresse ist fest verdrahtet — es gibt keinen Parameter, über den
|
||||
sich eine offene Weiterleitung bauen ließe. Verknüpft wird ein bestehendes
|
||||
Konto nur bei **bestätigter** E-Mail-Adresse.
|
||||
|
||||
---
|
||||
|
||||
## Offen
|
||||
|
||||
Bewusst nicht umgesetzt, mit Begründung.
|
||||
|
||||
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||||
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||||
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||||
Administrator an, der die Adresse selbst einträgt. Eine Bestätigungsmail
|
||||
würde also belegen, dass eine Adresse existiert, aber niemanden hereinlassen
|
||||
und niemanden fernhalten.
|
||||
|
||||
Dazu kommt: Es gibt **kein „Passwort vergessen" per E-Mail**. Zurücksetzen
|
||||
kann nur ein Administrator unter Verwaltung → Benutzer. Damit ist die
|
||||
unbestätigte Adresse auch kein Übernahmeweg — bei einem Reset per Mail wäre
|
||||
sie einer. Die Adresse ist hier schlicht ein Anmeldename.
|
||||
|
||||
Nötig würde die Verifizierung in dem Moment, in dem sich Leute selbst
|
||||
registrieren dürfen. Dann allerdings zwingend, zusammen mit einem
|
||||
SMTP-Versand, Ablauffristen für die Bestätigungslinks und einem Weg für
|
||||
nicht zugestellte Mails.
|
||||
|
||||
Zwei Stellen bleiben davon ohnehin unberührt, weil dort nicht der
|
||||
Administrator die Adresse einträgt:
|
||||
|
||||
- **Die Ersteinrichtung.** Wer zuerst kommt, wird Administrator. Dagegen hilft
|
||||
keine Verifizierung, sondern nur, das Fenster zu schließen — siehe unten.
|
||||
- **Die Anmeldung über einen Anbieter.** Dort bürgt der Anbieter für die
|
||||
Adresse, und genau das prüft die Anwendung: Ein bestehendes Konto wird nur
|
||||
bei `email_verified` verknüpft.
|
||||
|
||||
**Das Zeitfenster der Ersteinrichtung.** Solange es keinen Benutzer gibt und
|
||||
die Anmeldung eingeschaltet ist, wird das erste Konto ohne jede Legitimation
|
||||
angelegt — und ist Administrator der Installation. Steht die Anwendung schon
|
||||
öffentlich, bevor der Betreiber sein Konto anlegt, kann ein Fremder sie
|
||||
übernehmen.
|
||||
|
||||
Heute ist das eine Frage der Reihenfolge: erst starten, sofort das erste
|
||||
Konto anlegen, dann erreichbar machen. Sauberer wäre ein einmaliges Token,
|
||||
das beim ersten Start ins Protokoll geschrieben wird und für die
|
||||
Ersteinrichtung nötig ist. Steht als Vorschlag in der
|
||||
[Roadmap](roadmap.md).
|
||||
|
||||
**Vollständige Content-Security-Policy für Skripte.** Bräuchte Nonces durch
|
||||
die ganze Anwendung, weil Next.js eingebettete Skripte ausliefert. Der Nutzen
|
||||
wäre echt — es ist die zweite Verteidigungslinie gegen XSS —, aber halb
|
||||
umgesetzt ist sie wertlos. Gehört als eigenes Vorhaben auf die
|
||||
[Roadmap](roadmap.md), nicht nebenbei erledigt.
|
||||
|
||||
**`__Host-` als Präfix des Cookie-Namens.** Das Merkblatt nennt es die
|
||||
stärkste Variante. Es setzt `Secure` voraus — der Name müsste sich also je
|
||||
nach Betriebsart ändern, und beim Umstellen von HTTP auf HTTPS würden alle
|
||||
Sitzungen still ungültig. Für eine Anwendung, die auch im LAN über HTTP läuft,
|
||||
ist das mehr Verwirrung als Gewinn. Unter reinem HTTPS wäre es eine sinnvolle
|
||||
Ergänzung.
|
||||
|
||||
**Abgleich gegen bekannt gewordene Passwörter.** Eine Prüfung gegen die
|
||||
Have-I-Been-Pwned-Liste (k-Anonymität, ohne das Passwort zu übertragen) wäre
|
||||
der wirksamste einzelne Schritt für die Passwortqualität. Sie setzt aber
|
||||
voraus, dass die Anwendung ins Internet telefonieren darf — bei einer selbst
|
||||
gehosteten Installation nicht selbstverständlich. Sinnvoll als abschaltbare
|
||||
Option.
|
||||
|
||||
**Zweiter Faktor.** Nicht vorhanden. Wer ihn braucht, bekommt ihn heute über
|
||||
den Umweg Authentik: Dort lässt sich TOTP oder WebAuthn erzwingen, und diese
|
||||
Anwendung übernimmt nur das Ergebnis. Das ist die bessere Arbeitsteilung als
|
||||
eine eigene halbgare Umsetzung.
|
||||
|
||||
**Protokollierung sicherheitsrelevanter Ereignisse.** Fehlversuche, Sperren
|
||||
und Rechteänderungen werden nicht eigens protokolliert. Für den Betrieb im
|
||||
Team verzichtbar; bei mehr Konten wäre es das Erste, was fehlt.
|
||||
|
||||
**Verteilte Bremse.** Die Zähler für Anmeldeversuche und Rückmeldungen liegen
|
||||
im Arbeitsspeicher und gelten damit je Container. Bei mehreren Instanzen
|
||||
hinter einem Lastverteiler bräuchte es einen gemeinsamen Speicher.
|
||||
|
||||
---
|
||||
|
||||
## Nachgeprüft
|
||||
|
||||
Die Härtungen sind durch Tests belegt (21 Prüfungen), unter anderem:
|
||||
|
||||
- Alle fünf Schutzheader sind an jeder Antwort vorhanden, `X-Powered-By` ist
|
||||
weg.
|
||||
- Ein schreibender Zugriff mit fremdem `Origin` wird abgewiesen — auch beim
|
||||
Upload; mit eigenem oder ohne `Origin` geht er durch.
|
||||
- Hochgeladene Dateien behalten ihre eigene, schärfere Sandbox-Regel.
|
||||
- Ein 5000 Zeichen langes Passwort wird abgewiesen, bevor gerechnet wird.
|
||||
- Drei gleichzeitige Ersteinrichtungen erzeugen genau einen Administrator.
|
||||
- Eine unbekannte Adresse ist an der Antwort nicht von einem falschen
|
||||
Passwort zu unterscheiden — auch nicht bei einem Verzeichnis-Konto.
|
||||
- Der Sitzungs-Cookie trägt `HttpOnly`, `SameSite=Lax`, `Path=/` und ein
|
||||
ausreichend langes Token; `Secure` erscheint nur unter HTTPS.
|
||||
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
|
||||
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
|
||||
// sollen nicht vom Bundler verarbeitet werden.
|
||||
serverExternalPackages: ["qrcode", "pg"],
|
||||
|
||||
// „X-Powered-By: Next.js" verrät nur, wonach jemand Schwachstellen suchen
|
||||
// müsste. Die Schutzheader setzt src/middleware.ts.
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"jose": "^6.2.8",
|
||||
"next": "16.3.0",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -28,6 +29,7 @@
|
||||
"dotenv": "^17.2.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"playwright": "^1.62.1",
|
||||
"prisma": "^7.9.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
@@ -5024,6 +5026,21 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -5950,6 +5967,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -7085,6 +7111,38 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"jose": "^6.2.8",
|
||||
"next": "16.3.0",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -34,6 +35,7 @@
|
||||
"dotenv": "^17.2.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"playwright": "^1.62.1",
|
||||
"prisma": "^7.9.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
@@ -44,4 +46,3 @@
|
||||
"unrs-resolver": true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Anmeldung über einen externen Anbieter (OpenID Connect).
|
||||
--
|
||||
-- `oidcSubject` ist die stabile Kennung beim Anbieter. Sie und nicht die
|
||||
-- E-Mail-Adresse entscheidet, wer wiedererkannt wird — Adressen ändern sich.
|
||||
ALTER TABLE "User" ADD COLUMN "oidcSubject" TEXT;
|
||||
CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject");
|
||||
|
||||
-- Konten, die sich nur über den Anbieter anmelden, haben kein Passwort.
|
||||
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Harte Obergrenze für Sitzungen, unabhängig von der Aktivität.
|
||||
--
|
||||
-- Bestehende Sitzungen bekommen 90 Tage ab jetzt — sie sollen durch die
|
||||
-- Änderung nicht plötzlich alle ungültig werden.
|
||||
ALTER TABLE "Session"
|
||||
ADD COLUMN "absoluteExpiresAt" TIMESTAMP(3) NOT NULL
|
||||
DEFAULT (CURRENT_TIMESTAMP + INTERVAL '90 days');
|
||||
|
||||
CREATE INDEX "Session_absoluteExpiresAt_idx" ON "Session"("absoluteExpiresAt");
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation.
|
||||
|
||||
-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an
|
||||
-- einem Code, deshalb ist die Voreinstellung richtig.
|
||||
ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code';
|
||||
CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose");
|
||||
|
||||
CREATE TABLE "Branding" (
|
||||
"id" TEXT NOT NULL DEFAULT 'branding',
|
||||
"appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator',
|
||||
"footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes',
|
||||
"accentColor" TEXT NOT NULL DEFAULT '#4F46E5',
|
||||
"logoUploadId" TEXT,
|
||||
"faviconUploadId" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Branding_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId");
|
||||
CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId");
|
||||
|
||||
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey"
|
||||
FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey"
|
||||
FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss.
|
||||
INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
|
||||
|
||||
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
|
||||
-- gedruckten Codes entwerten.
|
||||
CREATE TABLE "QrCodeAlias" (
|
||||
"id" TEXT NOT NULL,
|
||||
"domainId" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"qrCodeId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
|
||||
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
|
||||
|
||||
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
|
||||
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
|
||||
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
|
||||
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
|
||||
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
|
||||
--
|
||||
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
|
||||
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
|
||||
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
|
||||
-- bricht die Migration in dem Fall lieber ab.
|
||||
DO $$
|
||||
DECLARE kollisionen INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO kollisionen FROM (
|
||||
SELECT "domainId", LOWER("slug")
|
||||
FROM "QrCode"
|
||||
GROUP BY "domainId", LOWER("slug")
|
||||
HAVING COUNT(*) > 1
|
||||
) AS doppelt;
|
||||
|
||||
IF kollisionen > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
|
||||
kollisionen;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
|
||||
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
|
||||
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
|
||||
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
|
||||
-- koennen.
|
||||
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
|
||||
ON "QrCode"("domainId", LOWER("slug"));
|
||||
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
|
||||
ON "QrCodeAlias"("domainId", LOWER("slug"));
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||
|
||||
CREATE TABLE "InstanceSetting" (
|
||||
"id" TEXT NOT NULL DEFAULT 'instance',
|
||||
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
|
||||
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
|
||||
-- und erneut herunterladen kann.
|
||||
|
||||
-- Alles, was es bisher gibt, ist dynamisch.
|
||||
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
|
||||
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
|
||||
|
||||
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
|
||||
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
|
||||
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
|
||||
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
|
||||
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
|
||||
@@ -29,7 +29,12 @@ model User {
|
||||
email String @unique
|
||||
name String?
|
||||
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||
passwordHash String
|
||||
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
|
||||
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
|
||||
passwordHash String?
|
||||
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
|
||||
/// sich das Konto nur mit Passwort anmeldet.
|
||||
oidcSubject String? @unique
|
||||
/// Darf Gruppen, Benutzer und Domains verwalten.
|
||||
isInstanceAdmin Boolean @default(false)
|
||||
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
||||
@@ -58,12 +63,17 @@ model Session {
|
||||
tokenHash String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
|
||||
expiresAt DateTime
|
||||
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
|
||||
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
||||
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@index([absoluteExpiresAt])
|
||||
}
|
||||
|
||||
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
||||
@@ -112,6 +122,7 @@ model Domain {
|
||||
|
||||
grants DomainGrant[]
|
||||
codes QrCode[]
|
||||
aliases QrCodeAlias[]
|
||||
}
|
||||
|
||||
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
||||
@@ -151,10 +162,18 @@ model QrTypeSetting {
|
||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
model QrCode {
|
||||
id String @id @default(cuid())
|
||||
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
||||
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
|
||||
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
|
||||
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
|
||||
mode String @default("dynamic")
|
||||
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
|
||||
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||
slug String
|
||||
///
|
||||
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
|
||||
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
|
||||
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
|
||||
slug String?
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
@@ -184,11 +203,14 @@ model QrCode {
|
||||
uploads Upload[]
|
||||
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
||||
feedback Feedback[]
|
||||
/// Frühere Kurz-Links, die weiterhin hierher führen.
|
||||
aliases QrCodeAlias[]
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
@@index([domainId])
|
||||
@@index([mode])
|
||||
}
|
||||
|
||||
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||
@@ -209,14 +231,81 @@ model Upload {
|
||||
data Bytes
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
|
||||
/// erste Art wird als herrenlos weggeräumt.
|
||||
purpose String @default("code")
|
||||
|
||||
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
||||
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
||||
/// `src/lib/uploads.ts` nach einer Weile weg.
|
||||
qrCodeId String?
|
||||
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
brandingLogo Branding? @relation("BrandingLogo")
|
||||
brandingFavicon Branding? @relation("BrandingFavicon")
|
||||
|
||||
@@index([qrCodeId])
|
||||
@@index([createdAt])
|
||||
@@index([purpose])
|
||||
}
|
||||
|
||||
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||
///
|
||||
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
|
||||
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
|
||||
/// ihren Typ behalten.
|
||||
model InstanceSetting {
|
||||
id String @id @default("instance")
|
||||
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
|
||||
///
|
||||
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
|
||||
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
||||
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
||||
editorsMaySetSlug Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
|
||||
///
|
||||
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
|
||||
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
|
||||
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
|
||||
/// Tippfehler, den noch niemand gedruckt hat.
|
||||
model QrCodeAlias {
|
||||
id String @id @default(cuid())
|
||||
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
|
||||
domainId String
|
||||
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||
slug String
|
||||
qrCodeId String
|
||||
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([qrCodeId])
|
||||
}
|
||||
|
||||
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
|
||||
///
|
||||
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
|
||||
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
|
||||
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
|
||||
model Branding {
|
||||
id String @id @default("branding")
|
||||
appName String @default("QR-Code-Generator")
|
||||
/// Zeile in der Fußzeile, hinter dem Namen.
|
||||
footerText String @default("statische und dynamische QR-Codes")
|
||||
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
|
||||
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
|
||||
/// src/lib/branding-colors.ts.
|
||||
accentColor String @default("#4F46E5")
|
||||
|
||||
logoUploadId String? @unique
|
||||
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
|
||||
faviconUploadId String? @unique
|
||||
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
||||
|
||||
@@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Notice } from "@/components/ui";
|
||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SignInPage() {
|
||||
type PageProps = { searchParams: Promise<{ fehler?: string }> };
|
||||
|
||||
export default async function SignInPage({ searchParams }: PageProps) {
|
||||
// Ohne Anmeldung gibt es hier nichts zu tun.
|
||||
if (!isAuthEnabled()) redirect("/");
|
||||
|
||||
@@ -18,30 +21,89 @@ export default async function SignInPage() {
|
||||
if (actor.kind === "user") redirect("/");
|
||||
|
||||
const setup = await isSetupOpen();
|
||||
const { fehler } = await searchParams;
|
||||
|
||||
// Eine unvollständige OIDC-Konfiguration soll die Anmeldeseite nicht
|
||||
// unbenutzbar machen — dann bleibt wenigstens das Passwortfeld.
|
||||
let oidc: { label: string; nurOidc: boolean } | null = null;
|
||||
let oidcFehler: string | null = null;
|
||||
if (isOidcEnabled()) {
|
||||
try {
|
||||
const config = getOidcConfig();
|
||||
oidc = { label: config.buttonLabel, nurOidc: !config.allowLocalLogin };
|
||||
} catch (error) {
|
||||
oidcFehler =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Die Anmeldung über den externen Anbieter ist nicht eingerichtet.";
|
||||
}
|
||||
}
|
||||
|
||||
// Bei reiner Anbieter-Anmeldung gibt es keine Ersteinrichtung mit Passwort:
|
||||
// Der erste, der sich über den Anbieter anmeldet, wird Administrator.
|
||||
const zeigeFormular = !oidc?.nurOidc;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
||||
<Icon name={setup ? "lock" : "qr"} className="size-6" />
|
||||
<Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
||||
{setup ? "Ersteinrichtung" : "Anmelden"}
|
||||
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{setup
|
||||
{setup && zeigeFormular
|
||||
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
||||
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
|
||||
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
|
||||
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
|
||||
|
||||
{setup ? (
|
||||
{oidc ? (
|
||||
<a
|
||||
href="/api/auth/oidc/start"
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Icon name="lock" className="size-4" />
|
||||
{oidc.label}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{oidc && zeigeFormular ? (
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 uppercase">
|
||||
<span className="h-px flex-1 bg-slate-200" />
|
||||
oder mit Passwort
|
||||
<span className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{zeigeFormular ? (
|
||||
setup ? (
|
||||
<SetupForm minLength={MIN_PASSWORD_LENGTH} />
|
||||
) : (
|
||||
<LoginForm />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{setup && zeigeFormular ? (
|
||||
<Notice tone="info">
|
||||
Der erste Benutzer wird Administrator der Installation und darf
|
||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||
</Notice>
|
||||
) : oidc?.nurOidc ? (
|
||||
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
|
||||
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
|
||||
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
|
||||
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
|
||||
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
|
||||
setup ? (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||
|
||||
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { QrGenerator } from "@/components/qr-generator";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { getQrTypeState } from "@/lib/qr/availability";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
@@ -47,7 +50,7 @@ function Hinweis({
|
||||
}
|
||||
|
||||
export default async function CreatePage({ params }: Props) {
|
||||
await requirePageActor();
|
||||
const actor = await requirePageActor();
|
||||
const { type } = await params;
|
||||
const state = await getQrTypeState(type);
|
||||
|
||||
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} />;
|
||||
// Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
|
||||
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
|
||||
// noch einmal.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
||||
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,6 +15,15 @@ export const dynamic = "force-dynamic";
|
||||
export default async function AccountPage() {
|
||||
const actor = await requirePageActor();
|
||||
|
||||
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
|
||||
const ausVerzeichnis =
|
||||
actor.kind === "user"
|
||||
? ((await prisma.user.findUnique({
|
||||
where: { id: actor.user.id },
|
||||
select: { passwordHash: true },
|
||||
}))?.passwordHash ?? null) === null
|
||||
: false;
|
||||
|
||||
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
||||
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
||||
if (actor.kind !== "user") {
|
||||
@@ -27,11 +37,11 @@ export default async function AccountPage() {
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
||||
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>{" "}
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>{" "}
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
@@ -77,7 +87,7 @@ export default async function AccountPage() {
|
||||
<dt className="text-slate-500">Rolle:</dt>
|
||||
<dd>
|
||||
{user.isInstanceAdmin ? (
|
||||
<Badge tone="popular">Administrator der Installation</Badge>
|
||||
<Badge tone="accent">Administrator der Installation</Badge>
|
||||
) : (
|
||||
<span className="text-slate-900">Benutzer</span>
|
||||
)}
|
||||
@@ -123,7 +133,17 @@ export default async function AccountPage() {
|
||||
|
||||
{isAuthEnabled() ? (
|
||||
<>
|
||||
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
||||
{ausVerzeichnis ? (
|
||||
<Card>
|
||||
<CardTitle>Passwort</CardTitle>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Dieses Konto meldet sich über den externen Anbieter an. Ein
|
||||
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
||||
)}
|
||||
<SignOutCard />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -2,8 +2,10 @@ import Link from "next/link";
|
||||
|
||||
import { LogoutButton } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||
// werden.
|
||||
@@ -21,24 +23,43 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
const actor = await getActor();
|
||||
const signedIn = actor.kind !== "anonymous";
|
||||
const showAdmin = can(actor, "instance:manage");
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 bg-surface">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
||||
>
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
{branding.logoUploadId ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||
Datenbank ausgeliefert, nicht aus dem Dateisystem */
|
||||
<img
|
||||
src={`/api/uploads/${branding.logoUploadId}`}
|
||||
alt={branding.appName}
|
||||
className="h-9 w-auto max-w-[12rem] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
|
||||
Navigation nicht mehr in eine Zeile. */}
|
||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
||||
Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
|
||||
dieses schon den Namen. */}
|
||||
{branding.logoUploadId ? null : (
|
||||
<span className="hidden sm:inline">{branding.appName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||
<ThemeToggle />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||
/>
|
||||
{signedIn ? (
|
||||
<>
|
||||
<Link href="/" className={NAV_LINK}>
|
||||
@@ -66,7 +87,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
title={`Konto von ${actor.user.email}`}
|
||||
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
||||
{actorLabel(actor).slice(0, 2)}
|
||||
</span>
|
||||
<span className="hidden truncate sm:inline">
|
||||
@@ -106,9 +127,10 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
<footer className="border-t border-slate-200 bg-white">
|
||||
<footer className="border-t border-slate-200 bg-surface">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||
QR-Code-Generator · statische und dynamische QR-Codes
|
||||
{branding.appName}
|
||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
|
||||
{kommentare.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-900 tabular-nums">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CodesList } from "@/components/codes-list";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Codes" };
|
||||
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyCodesPage() {
|
||||
await requirePageActor();
|
||||
const actor = await requirePageActor();
|
||||
|
||||
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
|
||||
// nebeneinander zeigen können.
|
||||
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
|
||||
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
|
||||
// dann mit der Gruppe des betroffenen Codes.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodesList />
|
||||
<CodesList maySetSlug={maySetSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function HomePage() {
|
||||
<span
|
||||
className={
|
||||
usable
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
|
||||
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
||||
}
|
||||
>
|
||||
@@ -67,11 +67,16 @@ export default async function HomePage() {
|
||||
>
|
||||
{qrType.title}
|
||||
</span>
|
||||
{usable ? (
|
||||
<Badge tone="popular">Beliebt</Badge>
|
||||
) : (
|
||||
{/* Kein Abzeichen „Beliebt“ mehr: Es stand an jedem
|
||||
nutzbaren Typ und sagte damit nichts. Hier steht nur
|
||||
noch, was die Wahl beeinflusst — nämlich wenn ein Typ
|
||||
zwingend eine Kurz-URL braucht und deshalb nicht ohne
|
||||
diesen Server funktioniert. */}
|
||||
{!usable ? (
|
||||
<Badge tone="soon">Bald verfügbar</Badge>
|
||||
)}
|
||||
) : !qrType.supportsStatic ? (
|
||||
<Badge tone="neutral">nur dynamisch</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
@@ -91,7 +96,7 @@ export default async function HomePage() {
|
||||
{usable ? (
|
||||
<Link
|
||||
href={`/erstellen/${qrType.id}`}
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
{tile}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { SettingsAdmin } from "@/components/settings-admin";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Einstellungen
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { BrandingAdmin } from "@/components/branding-admin";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
export const metadata: Metadata = { title: "Erscheinungsbild" };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BrandingPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Erscheinungsbild
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
|
||||
gelten für die ganze Installation.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Die öffentlichen Landingpages übernehmen die Farbe und das Favicon,
|
||||
aber weder Logo noch Navigation — wer einen Code scannt, soll die
|
||||
Seite des Inhalts sehen und nicht die einer Verwaltungssoftware.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BrandingAdmin
|
||||
initial={{
|
||||
appName: branding.appName,
|
||||
footerText: branding.footerText,
|
||||
accentColor: branding.accentColor,
|
||||
logoUploadId: branding.logoUploadId,
|
||||
faviconUploadId: branding.faviconUploadId,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Icon, type IconName } from "@/components/icons";
|
||||
import { Notice } from "@/components/ui";
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
@@ -26,9 +28,9 @@ function Tile({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -45,12 +47,22 @@ function Tile({
|
||||
export default async function AdminPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
|
||||
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.group.count(),
|
||||
prisma.qrCode.count(),
|
||||
prisma.domain.count(),
|
||||
]);
|
||||
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
|
||||
await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.group.count(),
|
||||
prisma.qrCode.count(),
|
||||
prisma.domain.count(),
|
||||
getBranding(),
|
||||
getSettings(),
|
||||
]);
|
||||
|
||||
const eigenes = [
|
||||
branding.logoUploadId && "Logo",
|
||||
branding.faviconUploadId && "Favicon",
|
||||
].filter(Boolean);
|
||||
const brandingHint =
|
||||
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
@@ -68,11 +80,11 @@ export default async function AdminPage() {
|
||||
<Notice tone="info">
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
@@ -110,6 +122,24 @@ export default async function AdminPage() {
|
||||
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
||||
hint="15 Typen insgesamt"
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/einstellungen"
|
||||
icon="lock"
|
||||
title="Einstellungen"
|
||||
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
|
||||
hint={
|
||||
settings.editorsMaySetSlug
|
||||
? "Kurz-Links: auch Bearbeiter"
|
||||
: "Kurz-Links: nur Administratoren"
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/erscheinungsbild"
|
||||
icon="image"
|
||||
title="Erscheinungsbild"
|
||||
description="Name, Logo, Favicon und Farbe der Oberfläche."
|
||||
hint={brandingHint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { isHexColor } from "@/lib/branding-colors";
|
||||
import {
|
||||
getBranding,
|
||||
MAX_APP_NAME,
|
||||
MAX_FOOTER_TEXT,
|
||||
saveBranding,
|
||||
} from "@/lib/branding";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */
|
||||
const bildSchema = z.string().trim().min(1).max(64).nullable().optional();
|
||||
|
||||
const updateSchema = z.object({
|
||||
appName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte gib einen Namen an." })
|
||||
.max(MAX_APP_NAME, {
|
||||
error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`,
|
||||
})
|
||||
.optional(),
|
||||
footerText: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(MAX_FOOTER_TEXT, {
|
||||
error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`,
|
||||
})
|
||||
.optional(),
|
||||
accentColor: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isHexColor, {
|
||||
error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
|
||||
})
|
||||
.optional(),
|
||||
logoUploadId: bildSchema,
|
||||
faviconUploadId: bildSchema,
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
return Response.json({ branding: await getBranding() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass eine angegebene Datei existiert und wirklich zum
|
||||
* Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden
|
||||
* QR-Codes einhängen und damit dessen Anzeige zerstören.
|
||||
*/
|
||||
async function assertBrandingUpload(
|
||||
id: string | null | undefined,
|
||||
feld: string,
|
||||
): Promise<void> {
|
||||
if (!id) return;
|
||||
const upload = await prisma.upload.findUnique({
|
||||
where: { id },
|
||||
select: { purpose: true },
|
||||
});
|
||||
if (!upload || upload.purpose !== "branding") {
|
||||
throw new QrRequestError(
|
||||
`${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`,
|
||||
404,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo");
|
||||
await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon");
|
||||
|
||||
return Response.json({ branding: await saveBranding(parsed.data) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { getSettings, saveSettings } from "@/lib/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const updateSchema = z.object({
|
||||
editorsMaySetSlug: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
return Response.json({ settings: await getSettings() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
return Response.json({ settings: await saveSettings(parsed.data) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "@/lib/auth/password";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
@@ -26,6 +27,9 @@ const updateSchema = z.object({
|
||||
.min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
})
|
||||
.max(MAX_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||
})
|
||||
.optional(),
|
||||
/** Sperre nach Fehlversuchen aufheben. */
|
||||
unlock: z.boolean().optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "@/lib/auth/password";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
@@ -25,8 +26,11 @@ const createSchema = z.object({
|
||||
error: "Das ist keine gültige E-Mail-Adresse.",
|
||||
}),
|
||||
name: z.string().trim().max(120).optional(),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH, { error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, })
|
||||
.max(MAX_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||
}),
|
||||
isInstanceAdmin: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import {
|
||||
exchangeCode,
|
||||
getOidcConfig,
|
||||
isOidcEnabled,
|
||||
OIDC_FLOW_COOKIE,
|
||||
type OidcConfig,
|
||||
type OidcIdentity,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBaseUrl } from "@/lib/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
|
||||
function zurueckMitFehler(meldung: string): Response {
|
||||
const ziel = new URL("/anmelden", getBaseUrl());
|
||||
ziel.searchParams.set("fehler", meldung);
|
||||
return Response.redirect(ziel.toString(), 302);
|
||||
}
|
||||
|
||||
type FlowState = { state: string; nonce: string; codeVerifier: string };
|
||||
|
||||
/**
|
||||
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
|
||||
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
|
||||
* mehreren Bündeln und hätten dort verschiedene Identitäten.
|
||||
*/
|
||||
function istBenutzerfehler(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error as Error & { userFacing?: unknown }).userFacing === true
|
||||
);
|
||||
}
|
||||
|
||||
function readFlow(raw: string | undefined): FlowState | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const wert = JSON.parse(raw) as Partial<FlowState>;
|
||||
if (
|
||||
typeof wert.state === "string" &&
|
||||
typeof wert.nonce === "string" &&
|
||||
typeof wert.codeVerifier === "string"
|
||||
) {
|
||||
return wert as FlowState;
|
||||
}
|
||||
} catch {
|
||||
// Unbrauchbarer Cookie — behandeln wie keiner.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt das Konto an oder findet es wieder.
|
||||
*
|
||||
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
|
||||
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
|
||||
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
|
||||
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
|
||||
*/
|
||||
async function findeOderLegeAn(
|
||||
identity: OidcIdentity,
|
||||
config: OidcConfig,
|
||||
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
|
||||
const nachSubject = await prisma.user.findUnique({
|
||||
where: { oidcSubject: identity.subject },
|
||||
});
|
||||
|
||||
const istAdmin = config.adminGroup
|
||||
? identity.groups.includes(config.adminGroup)
|
||||
: undefined;
|
||||
|
||||
if (nachSubject) {
|
||||
const aktualisiert = await prisma.user.update({
|
||||
where: { id: nachSubject.id },
|
||||
data: {
|
||||
email: identity.email,
|
||||
name: identity.name ?? nachSubject.name,
|
||||
lastLoginAt: new Date(),
|
||||
failedLogins: 0,
|
||||
lockedUntil: null,
|
||||
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
|
||||
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
|
||||
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||
},
|
||||
});
|
||||
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
|
||||
}
|
||||
|
||||
const nachEmail = await prisma.user.findUnique({
|
||||
where: { email: identity.email },
|
||||
});
|
||||
|
||||
if (nachEmail) {
|
||||
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
|
||||
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||
// übernehmen.
|
||||
if (!identity.emailVerified) {
|
||||
return {
|
||||
fehler:
|
||||
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
||||
};
|
||||
}
|
||||
const verknuepft = await prisma.user.update({
|
||||
where: { id: nachEmail.id },
|
||||
data: {
|
||||
oidcSubject: identity.subject,
|
||||
name: identity.name ?? nachEmail.name,
|
||||
lastLoginAt: new Date(),
|
||||
failedLogins: 0,
|
||||
lockedUntil: null,
|
||||
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||
},
|
||||
});
|
||||
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||
}
|
||||
|
||||
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
|
||||
const standardgruppe = await prisma.group.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||
const istErster = (await prisma.user.count()) === 0;
|
||||
|
||||
const angelegt = await prisma.user.create({
|
||||
data: {
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
passwordHash: null,
|
||||
oidcSubject: identity.subject,
|
||||
isInstanceAdmin: istAdmin ?? istErster,
|
||||
lastLoginAt: new Date(),
|
||||
memberships: standardgruppe
|
||||
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const store = await cookies();
|
||||
|
||||
try {
|
||||
if (!isAuthEnabled() || !isOidcEnabled()) {
|
||||
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
|
||||
}
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
|
||||
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
|
||||
const fehler = params.get("error");
|
||||
if (fehler) {
|
||||
const beschreibung = params.get("error_description");
|
||||
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
|
||||
return zurueckMitFehler(
|
||||
fehler === "access_denied"
|
||||
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
|
||||
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||
);
|
||||
}
|
||||
|
||||
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
|
||||
if (!flow) {
|
||||
return zurueckMitFehler(
|
||||
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
|
||||
);
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
|
||||
if (!code || !state) {
|
||||
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
|
||||
}
|
||||
if (state !== flow.state) {
|
||||
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
|
||||
return zurueckMitFehler(
|
||||
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
const identity = await exchangeCode({
|
||||
config,
|
||||
code,
|
||||
codeVerifier: flow.codeVerifier,
|
||||
nonce: flow.nonce,
|
||||
});
|
||||
|
||||
const ergebnis = await findeOderLegeAn(identity, config);
|
||||
if ("fehler" in ergebnis) {
|
||||
return zurueckMitFehler(ergebnis.fehler);
|
||||
}
|
||||
if (!ergebnis.isActive) {
|
||||
return zurueckMitFehler(
|
||||
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
await createSession(ergebnis.id);
|
||||
|
||||
const ziel = new URL("/", getBaseUrl());
|
||||
return Response.redirect(ziel.toString(), 302);
|
||||
} catch (error) {
|
||||
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
|
||||
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
|
||||
// alles andere könnte Interna verraten.
|
||||
const meldung = istBenutzerfehler(error)
|
||||
? error.message
|
||||
: "Die Anmeldung ist fehlgeschlagen.";
|
||||
return zurueckMitFehler(meldung);
|
||||
} finally {
|
||||
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
|
||||
store.delete(OIDC_FLOW_COOKIE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import {
|
||||
assertHostMatchesBaseUrl,
|
||||
discover,
|
||||
getOidcConfig,
|
||||
isOidcEnabled,
|
||||
OIDC_FLOW_COOKIE,
|
||||
OIDC_FLOW_MINUTES,
|
||||
redirectUri,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function base64url(buffer: Buffer): string {
|
||||
return buffer.toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt den Browser zum Anbieter.
|
||||
*
|
||||
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
|
||||
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
||||
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
if (!isAuthEnabled()) {
|
||||
throw new QrRequestError(
|
||||
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!isOidcEnabled()) {
|
||||
throw new QrRequestError(
|
||||
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
|
||||
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
|
||||
assertHostMatchesBaseUrl(request.headers);
|
||||
|
||||
const config = getOidcConfig();
|
||||
const meta = await discover(config.issuer);
|
||||
|
||||
const state = base64url(randomBytes(32));
|
||||
const nonce = base64url(randomBytes(32));
|
||||
const codeVerifier = base64url(randomBytes(32));
|
||||
const codeChallenge = base64url(
|
||||
createHash("sha256").update(codeVerifier).digest(),
|
||||
);
|
||||
|
||||
const store = await cookies();
|
||||
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
|
||||
httpOnly: true,
|
||||
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
|
||||
// Cookie mitgeschickt.
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
||||
maxAge: OIDC_FLOW_MINUTES * 60,
|
||||
});
|
||||
|
||||
const ziel = new URL(meta.authorization_endpoint);
|
||||
ziel.searchParams.set("response_type", "code");
|
||||
ziel.searchParams.set("client_id", config.clientId);
|
||||
ziel.searchParams.set("redirect_uri", redirectUri());
|
||||
ziel.searchParams.set("scope", config.scopes);
|
||||
ziel.searchParams.set("state", state);
|
||||
ziel.searchParams.set("nonce", nonce);
|
||||
ziel.searchParams.set("code_challenge", codeChallenge);
|
||||
ziel.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return Response.redirect(ziel.toString(), 302);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
needsRehash,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth/password";
|
||||
import {
|
||||
@@ -13,6 +15,11 @@ import {
|
||||
destroyAllSessions,
|
||||
destroySession,
|
||||
} from "@/lib/auth/session";
|
||||
import {
|
||||
clearAttempts,
|
||||
requestOrigin,
|
||||
tooManyAttempts,
|
||||
} from "@/lib/auth/throttle";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
@@ -23,6 +30,13 @@ export const dynamic = "force-dynamic";
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
||||
* Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen
|
||||
* denen echter Hashes — sonst wäre die Rechnung zu schnell.
|
||||
*/
|
||||
const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA";
|
||||
|
||||
const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -36,28 +50,42 @@ const emailSchema = z
|
||||
const loginSchema = z.object({
|
||||
action: z.literal("login"),
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { error: "Bitte gib dein Passwort ein." })
|
||||
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
action: z.literal("setup"),
|
||||
email: emailSchema,
|
||||
name: z.string().trim().max(120).optional(),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
})
|
||||
.max(MAX_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||
}),
|
||||
});
|
||||
|
||||
const logoutSchema = z.object({ action: z.literal("logout") });
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
action: z.literal("change-password"),
|
||||
currentPassword: z.string().min(1, {
|
||||
error: "Bitte gib dein bisheriges Passwort ein.",
|
||||
}),
|
||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
currentPassword: z
|
||||
.string()
|
||||
.min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
|
||||
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
})
|
||||
.max(MAX_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||
}),
|
||||
});
|
||||
|
||||
const bodySchema = z.discriminatedUnion("action", [
|
||||
@@ -117,7 +145,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
if (parsed.data.action === "setup") {
|
||||
return await handleSetup(parsed.data);
|
||||
}
|
||||
return await handleLogin(parsed.data);
|
||||
return await handleLogin(parsed.data, requestOrigin(request.headers));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
@@ -140,6 +168,15 @@ async function handleChangePassword(
|
||||
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
||||
}
|
||||
|
||||
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
|
||||
// wird beim Anbieter gewechselt, nicht hier.
|
||||
if (!user.passwordHash) {
|
||||
throw new QrRequestError(
|
||||
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
||||
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
||||
}
|
||||
@@ -179,14 +216,31 @@ async function handleSetup(
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name && input.name.length > 0 ? input.name : null,
|
||||
passwordHash: await hashPassword(input.password),
|
||||
isInstanceAdmin: true,
|
||||
// Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
// Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
|
||||
// dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
|
||||
// gäbe es zwei Administratoren, von denen einer ungebeten ist.
|
||||
const user = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
if ((await tx.user.count()) > 0) {
|
||||
throw new QrRequestError(
|
||||
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name && input.name.length > 0 ? input.name : null,
|
||||
passwordHash,
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
||||
@@ -194,7 +248,18 @@ async function handleSetup(
|
||||
|
||||
async function handleLogin(
|
||||
input: z.infer<typeof loginSchema>,
|
||||
absender: string,
|
||||
): Promise<Response> {
|
||||
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
||||
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||
if (wartezeit !== null) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: input.email } });
|
||||
|
||||
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
|
||||
@@ -206,17 +271,10 @@ async function handleLogin(
|
||||
|
||||
if (!user) {
|
||||
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
||||
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
|
||||
await verifyPassword(input.password, DUMMY_HASH);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new QrRequestError(
|
||||
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutes = Math.ceil(
|
||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||
@@ -227,6 +285,14 @@ async function handleLogin(
|
||||
);
|
||||
}
|
||||
|
||||
// Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe
|
||||
// Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten,
|
||||
// dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben.
|
||||
if (!user.passwordHash) {
|
||||
await verifyPassword(input.password, DUMMY_HASH);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||
const failedLogins = user.failedLogins + 1;
|
||||
await prisma.user.update({
|
||||
@@ -242,11 +308,32 @@ async function handleLogin(
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
// Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto
|
||||
// nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht.
|
||||
if (!user.isActive) {
|
||||
throw new QrRequestError(
|
||||
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern.
|
||||
// Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt.
|
||||
const passwordHash = needsRehash(user.passwordHash)
|
||||
? await hashPassword(input.password)
|
||||
: undefined;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
data: {
|
||||
failedLogins: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: new Date(),
|
||||
...(passwordHash ? { passwordHash } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
clearAttempts(`login:${absender}`);
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shortUrl } from "@/lib/env";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
@@ -49,7 +50,12 @@ export async function GET(
|
||||
margin: code.margin,
|
||||
});
|
||||
|
||||
const content = shortUrl(code.slug, code.domain.hostname);
|
||||
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||
const content =
|
||||
code.mode === "static" || !code.slug
|
||||
? requireImplementedQrType(code.type).encode(code.data)
|
||||
: shortUrl(code.slug, code.domain.hostname);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
|
||||
@@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
} as const;
|
||||
|
||||
const updateSchema = z.object({
|
||||
@@ -32,6 +36,17 @@ const updateSchema = z.object({
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.nullable()
|
||||
.optional(),
|
||||
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
|
||||
slug: z.string().trim().max(120).optional(),
|
||||
/**
|
||||
* Welche früheren Adressen weiterhin gelten sollen.
|
||||
*
|
||||
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
|
||||
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
|
||||
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
|
||||
* freigegeben — und ist damit sofort tot.
|
||||
*/
|
||||
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
|
||||
});
|
||||
|
||||
async function loadCode(id: string) {
|
||||
@@ -81,6 +96,15 @@ export async function PATCH(
|
||||
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||
let neueDateien: string[] | null = null;
|
||||
|
||||
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
|
||||
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
|
||||
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
|
||||
if (parsed.data.data !== undefined && existing.mode === "static") {
|
||||
return jsonError(
|
||||
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.data !== undefined) {
|
||||
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
||||
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
||||
@@ -109,14 +133,93 @@ export async function PATCH(
|
||||
Object.assign(update, parsed.data.design);
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
/* ------------------------------------------------------- Kurz-Link */
|
||||
|
||||
const neuerSlug = parsed.data.slug?.trim();
|
||||
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
|
||||
// selbst. Ein Umbenennen waere gegenstandslos.
|
||||
if (existing.mode === "static" && neuerSlug) {
|
||||
return jsonError(
|
||||
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||
);
|
||||
}
|
||||
const umbenannt =
|
||||
neuerSlug !== undefined &&
|
||||
existing.slug !== null &&
|
||||
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
||||
|
||||
/**
|
||||
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
|
||||
* es ist; steht es da, beschreibt es den gewünschten Zustand.
|
||||
*/
|
||||
const behalten =
|
||||
parsed.data.aliases === undefined
|
||||
? null
|
||||
: new Set(parsed.data.aliases.map(normalizeSlug));
|
||||
|
||||
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
|
||||
where: { qrCodeId: existing.id },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const freigeben = behalten
|
||||
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
|
||||
: [];
|
||||
|
||||
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
|
||||
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
|
||||
// mit — unverändert soll das kein zusätzliches Recht verlangen.
|
||||
if (umbenannt || freigeben.length > 0) {
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
requirePermission(actor, "codes:slug", {
|
||||
groupId: existing.groupId,
|
||||
editorsMaySetSlug,
|
||||
});
|
||||
}
|
||||
|
||||
if (umbenannt) {
|
||||
const problem = describeSlugProblem(neuerSlug);
|
||||
if (problem) return jsonError(problem);
|
||||
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
|
||||
update.slug = normalizeSlug(neuerSlug);
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(update).length === 0 &&
|
||||
!umbenannt &&
|
||||
freigeben.length === 0
|
||||
) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
const updated = await prisma.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
|
||||
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
|
||||
// ist, oder eine freigegebene Adresse bliebe hängen.
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (freigeben.length > 0) {
|
||||
await tx.qrCodeAlias.deleteMany({
|
||||
where: { id: { in: freigeben.map((a) => a.id) } },
|
||||
});
|
||||
}
|
||||
|
||||
if (umbenannt) {
|
||||
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
|
||||
// Codes. Freigeben lässt er sich danach einzeln.
|
||||
await tx.qrCodeAlias.create({
|
||||
data: {
|
||||
domainId: existing.domainId,
|
||||
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
|
||||
slug: existing.slug as string,
|
||||
qrCodeId: existing.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
});
|
||||
|
||||
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
||||
|
||||
@@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
|
||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -26,6 +28,17 @@ const createSchema = z.object({
|
||||
.trim()
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.optional(),
|
||||
/**
|
||||
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
||||
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
||||
*/
|
||||
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
||||
.default("dynamic"),
|
||||
/**
|
||||
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
||||
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
||||
*/
|
||||
slug: z.string().trim().max(120).optional(),
|
||||
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||
groupId: z.string().trim().min(1).optional(),
|
||||
/** Unter welcher Domain die Kurz-URL entsteht. */
|
||||
@@ -57,6 +70,7 @@ export async function GET(): Promise<Response> {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,15 +107,69 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError(validated.message);
|
||||
}
|
||||
|
||||
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||
assertDynamicPossible(qrType, validated.data);
|
||||
const istStatisch = parsed.data.mode === "static";
|
||||
|
||||
if (istStatisch) {
|
||||
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
|
||||
// können, sonst gäbe es nichts zu kodieren.
|
||||
if (!qrType.supportsStatic) {
|
||||
return jsonError(
|
||||
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
|
||||
);
|
||||
}
|
||||
if (parsed.data.slug) {
|
||||
return jsonError(
|
||||
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||
assertDynamicPossible(qrType, validated.data);
|
||||
}
|
||||
|
||||
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
||||
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
||||
const gewuenscht = parsed.data.slug?.trim();
|
||||
if (gewuenscht) {
|
||||
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||
|
||||
const problem = describeSlugProblem(gewuenscht);
|
||||
if (problem) return jsonError(problem);
|
||||
await assertSlugAvailable(domainId, gewuenscht);
|
||||
}
|
||||
|
||||
if (istStatisch) {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
mode: "static",
|
||||
slug: null,
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
groupId,
|
||||
domainId,
|
||||
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||
...design,
|
||||
},
|
||||
include: {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
}
|
||||
|
||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
slug: generateSlug(),
|
||||
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
@@ -114,6 +182,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||
@@ -122,6 +191,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
} catch (error) {
|
||||
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
|
||||
// käme immer wieder auf dieselbe Adresse.
|
||||
if (gewuenscht && isUniqueViolation(error)) {
|
||||
return jsonError(
|
||||
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,8 @@ export async function GET(
|
||||
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
|
||||
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
|
||||
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
|
||||
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { NextRequest } from "next/server";
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, jsonError } from "@/lib/http";
|
||||
import { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
|
||||
import {
|
||||
cleanupOrphanedUploads,
|
||||
isUploadKind,
|
||||
maxUploadBytes,
|
||||
storeUpload,
|
||||
uploadPurposeOf,
|
||||
} from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -22,11 +23,11 @@ export const dynamic = "force-dynamic";
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
// Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
|
||||
// die Herkunft deshalb selbst.
|
||||
assertSameOrigin(request.headers);
|
||||
|
||||
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
|
||||
const groupId = await resolveTargetGroup(actor, undefined);
|
||||
requirePermission(actor, "codes:create", { groupId });
|
||||
const actor = await getActor();
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
@@ -40,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError("Unbekannte Dateiart.");
|
||||
}
|
||||
|
||||
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
|
||||
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
|
||||
if (uploadPurposeOf(kind) === "branding") {
|
||||
requirePermission(actor, "instance:manage");
|
||||
} else {
|
||||
const groupId = await resolveTargetGroup(actor, undefined);
|
||||
requirePermission(actor, "codes:create", { groupId });
|
||||
}
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError("Es wurde keine Datei mitgeschickt.");
|
||||
|
||||
@@ -9,10 +9,81 @@
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Dark Mode
|
||||
|
||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
|
||||
|
||||
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
|
||||
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
|
||||
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
|
||||
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
|
||||
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
|
||||
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
|
||||
entscheidet sich hier.
|
||||
|
||||
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
|
||||
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
|
||||
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
|
||||
|
||||
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
|
||||
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
|
||||
demselben leicht blauen Ton wie die helle Reihe.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
|
||||
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
|
||||
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
|
||||
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
|
||||
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
|
||||
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
|
||||
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
|
||||
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
|
||||
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
|
||||
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
|
||||
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
|
||||
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
|
||||
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
|
||||
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
|
||||
|
||||
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
|
||||
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
|
||||
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
|
||||
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
|
||||
|
||||
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
|
||||
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
|
||||
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
|
||||
--color-accent-text: light-dark(
|
||||
var(--color-indigo-600),
|
||||
var(--color-indigo-400)
|
||||
);
|
||||
--color-accent-soft: light-dark(
|
||||
var(--color-indigo-50),
|
||||
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
|
||||
);
|
||||
--color-accent-soft-fg: light-dark(
|
||||
var(--color-indigo-700),
|
||||
var(--color-indigo-300)
|
||||
);
|
||||
|
||||
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
|
||||
der Grund einer Videovorschau. */
|
||||
--color-scrim: oklch(20.8% 0.042 265.755);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-slate-50);
|
||||
color: var(--color-slate-900);
|
||||
@@ -22,7 +93,7 @@ body {
|
||||
|
||||
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-indigo-600);
|
||||
outline: 2px solid var(--color-accent-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,73 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||
import { accentCss } from "@/lib/branding-colors";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "QR-Code-Generator",
|
||||
template: "%s · QR-Code-Generator",
|
||||
},
|
||||
description:
|
||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||
};
|
||||
// Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
|
||||
// vorgerendert werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await getBranding();
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: branding.appName,
|
||||
template: `%s · ${branding.appName}`,
|
||||
},
|
||||
description:
|
||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
|
||||
...(branding.faviconUploadId
|
||||
? {
|
||||
icons: {
|
||||
icon: {
|
||||
url: `/api/uploads/${branding.faviconUploadId}`,
|
||||
type: branding.faviconMimeType ?? undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wurzel-Layout — bewusst nur `html` und `body`.
|
||||
* Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
|
||||
*
|
||||
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
|
||||
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
|
||||
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
|
||||
* „Meine Codes“ nichts zu suchen.
|
||||
*
|
||||
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
|
||||
*/
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="flex min-h-full flex-col">{children}</body>
|
||||
<body className="flex min-h-full flex-col">
|
||||
{/*
|
||||
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
|
||||
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||
`light-dark()` im CSS von allein.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
{/*
|
||||
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
|
||||
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
|
||||
die Voreinstellung zurück.
|
||||
*/}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||
/>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
|
||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { isValidSlug } from "@/lib/slug";
|
||||
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ slug: string }> };
|
||||
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
|
||||
// Sammelroute: Next liefert die Abschnitte dann als Liste.
|
||||
type Context = { params: Promise<{ slug: string[] }> };
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return new Response(
|
||||
@@ -44,7 +47,8 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
const { slug } = await context.params;
|
||||
const abschnitte = (await context.params).slug;
|
||||
const slug = (abschnitte ?? []).join("/");
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||
@@ -65,9 +69,8 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const code = await prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
||||
});
|
||||
const gefunden = await findCodeBySlug(domain.id, slug);
|
||||
const code = gefunden?.code;
|
||||
|
||||
if (!code) {
|
||||
return notFound(
|
||||
@@ -85,7 +88,7 @@ export async function GET(
|
||||
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
||||
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
||||
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
||||
target = `/s/${slug}`;
|
||||
target = `/s/${code.slug}`;
|
||||
} else {
|
||||
try {
|
||||
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
||||
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
|
||||
|
||||
import { qrLandings } from "@/components/landing";
|
||||
import { LandingNotice } from "@/components/landing/shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { isValidSlug } from "@/lib/slug";
|
||||
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ slug: string }> };
|
||||
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
|
||||
type PageProps = { params: Promise<{ slug: string[] }> };
|
||||
|
||||
/**
|
||||
* Öffentliche Landingpage eines dynamischen Codes.
|
||||
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
|
||||
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
|
||||
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
|
||||
*/
|
||||
async function loadCode(slug: string) {
|
||||
async function loadCode(abschnitte: string[] | undefined) {
|
||||
const slug = (abschnitte ?? []).join("/");
|
||||
if (!isValidSlug(slug)) return null;
|
||||
|
||||
const matched = await resolveRequestDomain(await headers());
|
||||
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
|
||||
if (!domain.isActive) return null;
|
||||
|
||||
return prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
||||
});
|
||||
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
|
||||
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
|
||||
if (!code.slug) {
|
||||
return (
|
||||
<LandingNotice title="Diese Seite gibt es nicht">
|
||||
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
|
||||
der ihn erstellt hat.
|
||||
</LandingNotice>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Landing
|
||||
data={code.data}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardTitle,
|
||||
Field,
|
||||
Notice,
|
||||
TextInput,
|
||||
} from "@/components/ui";
|
||||
import { buildRamp, isHexColor } from "@/lib/branding-colors";
|
||||
import { UPLOAD_KINDS, formatBytes } from "@/lib/uploads.shared";
|
||||
|
||||
export type BrandingDto = {
|
||||
appName: string;
|
||||
footerText: string;
|
||||
accentColor: string;
|
||||
logoUploadId: string | null;
|
||||
faviconUploadId: string | null;
|
||||
};
|
||||
|
||||
/** Vorschläge, damit man nicht mit einem Farbwähler anfangen muss. */
|
||||
const FARBEN = [
|
||||
{ label: "Indigo", value: "#4F46E5" },
|
||||
{ label: "Blau", value: "#1D4ED8" },
|
||||
{ label: "Türkis", value: "#0D9488" },
|
||||
{ label: "Grün", value: "#15803D" },
|
||||
{ label: "Bernstein", value: "#B45309" },
|
||||
{ label: "Rot", value: "#DC2626" },
|
||||
{ label: "Pink", value: "#DB2777" },
|
||||
{ label: "Violett", value: "#7C3AED" },
|
||||
{ label: "Schiefer", value: "#475569" },
|
||||
] as const;
|
||||
|
||||
type BildFeld = "logoUploadId" | "faviconUploadId";
|
||||
|
||||
export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
|
||||
const [wert, setWert] = useState<BrandingDto>(initial);
|
||||
const [gespeichert, setGespeichert] = useState<BrandingDto>(initial);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
const logoInput = useRef<HTMLInputElement>(null);
|
||||
const faviconInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const geaendert = JSON.stringify(wert) !== JSON.stringify(gespeichert);
|
||||
const farbeGueltig = isHexColor(wert.accentColor);
|
||||
|
||||
async function hochladen(datei: File, kind: "logo" | "favicon", feld: BildFeld) {
|
||||
setFehler(null);
|
||||
setLaeuft(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", datei);
|
||||
const antwort = await fetch("/api/uploads", { method: "POST", body: form });
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Die Datei konnte nicht abgelegt werden.");
|
||||
}
|
||||
setWert((alt) => ({ ...alt, [feld]: rumpf.upload.id }));
|
||||
setHinweis(null);
|
||||
} catch (error) {
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
setLaeuft(true);
|
||||
try {
|
||||
const antwort = await fetch("/api/admin/branding", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(wert),
|
||||
});
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||
}
|
||||
setGespeichert(rumpf.branding);
|
||||
setWert(rumpf.branding);
|
||||
setHinweis(
|
||||
"Gespeichert. Die Kopfzeile zeigt es beim nächsten Seitenwechsel.",
|
||||
);
|
||||
} catch (error) {
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ramp = buildRamp(farbeGueltig ? wert.accentColor : "#4F46E5");
|
||||
const farbe = (stop: number) =>
|
||||
ramp.find((eintrag) => eintrag.stop === stop)?.value ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Steht in der Kopfzeile, im Titel des Browser-Tabs und in der Fußzeile.">
|
||||
Name
|
||||
</CardTitle>
|
||||
<div className="space-y-4">
|
||||
<Field label="Name der Anwendung" htmlFor="branding-name">
|
||||
<TextInput
|
||||
id="branding-name"
|
||||
value={wert.appName}
|
||||
maxLength={60}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, appName: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Zusatz in der Fußzeile"
|
||||
htmlFor="branding-footer"
|
||||
hint="Steht hinter dem Namen. Leer lassen, wenn nur der Name erscheinen soll."
|
||||
>
|
||||
<TextInput
|
||||
id="branding-footer"
|
||||
value={wert.footerText}
|
||||
maxLength={120}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, footerText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Übernommen werden Farbton und Sättigung. Die Helligkeitsstufen bleiben, wie sie sind — sonst würde weiße Schrift auf hellem Grund unlesbar.">
|
||||
Akzentfarbe
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FARBEN.map((eintrag) => (
|
||||
<button
|
||||
key={eintrag.value}
|
||||
type="button"
|
||||
disabled={laeuft}
|
||||
onClick={() => setWert({ ...wert, accentColor: eintrag.value })}
|
||||
aria-label={eintrag.label}
|
||||
aria-pressed={
|
||||
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||
}
|
||||
title={eintrag.label}
|
||||
className={`size-9 rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||
? "border-slate-900"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: eintrag.value }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end gap-4">
|
||||
<Field label="Eigener Wert" htmlFor="branding-accent">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Farbe wählen"
|
||||
value={farbeGueltig ? wert.accentColor : "#4F46E5"}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, accentColor: event.target.value.toUpperCase() })
|
||||
}
|
||||
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||
/>
|
||||
<TextInput
|
||||
id="branding-accent"
|
||||
value={wert.accentColor}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
disabled={laeuft}
|
||||
invalid={!farbeGueltig}
|
||||
className="w-32 font-mono"
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, accentColor: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
So sieht die Abstufung aus
|
||||
</span>
|
||||
<div className="flex overflow-hidden rounded-lg border border-slate-200">
|
||||
{ramp.map((eintrag) => (
|
||||
<span
|
||||
key={eintrag.stop}
|
||||
title={String(eintrag.stop)}
|
||||
className="size-7"
|
||||
style={{ backgroundColor: eintrag.value }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!farbeGueltig ? (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
Bitte einen Hex-Wert wie #4F46E5 angeben.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Zeigt, was die Farbe an den auffälligsten Stellen bewirkt. */}
|
||||
<div className="mt-5 rounded-xl border border-slate-200 p-4">
|
||||
<span className="mb-3 block text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Vorschau
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className="flex size-9 items-center justify-center rounded-lg text-white"
|
||||
style={{ backgroundColor: farbe(600) }}
|
||||
>
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
<span
|
||||
className="rounded-lg px-4 py-2.5 text-sm font-medium text-white"
|
||||
style={{ backgroundColor: farbe(600) }}
|
||||
>
|
||||
Dynamischen Code speichern
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: farbe(100), color: farbe(700) }}
|
||||
>
|
||||
Abzeichen
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: farbe(600) }}>
|
||||
Ein Link
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Ohne eigenes Logo erscheint das Zeichen der Anwendung samt Name.">
|
||||
Logo
|
||||
</CardTitle>
|
||||
<BildFeldZeile
|
||||
vorhanden={wert.logoUploadId}
|
||||
beschreibung={`PNG, JPEG, WebP oder AVIF, bis ${formatBytes(2 * 1024 * 1024)}. Wird auf 36 px Höhe gezeigt.`}
|
||||
accept={UPLOAD_KINDS.logo.accept}
|
||||
inputRef={logoInput}
|
||||
laeuft={laeuft}
|
||||
hintergrund="bg-surface"
|
||||
onWaehlen={(datei) => hochladen(datei, "logo", "logoUploadId")}
|
||||
onEntfernen={() => setWert({ ...wert, logoUploadId: null })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Das kleine Bild im Browser-Tab und in den Lesezeichen.">
|
||||
Favicon
|
||||
</CardTitle>
|
||||
<BildFeldZeile
|
||||
vorhanden={wert.faviconUploadId}
|
||||
beschreibung={`PNG, ICO oder WebP, bis ${formatBytes(512 * 1024)}. Am besten quadratisch, mindestens 32 × 32 px.`}
|
||||
accept={UPLOAD_KINDS.favicon.accept}
|
||||
inputRef={faviconInput}
|
||||
laeuft={laeuft}
|
||||
hintergrund="qr-canvas"
|
||||
onWaehlen={(datei) => hochladen(datei, "favicon", "faviconUploadId")}
|
||||
onEntfernen={() => setWert({ ...wert, faviconUploadId: null })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void speichern()} disabled={laeuft || !geaendert || !farbeGueltig}>
|
||||
{laeuft ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
{geaendert ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setWert(gespeichert);
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
}}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">Keine Änderungen offen.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BildFeldZeile({
|
||||
vorhanden,
|
||||
beschreibung,
|
||||
accept,
|
||||
inputRef,
|
||||
laeuft,
|
||||
hintergrund,
|
||||
onWaehlen,
|
||||
onEntfernen,
|
||||
}: {
|
||||
vorhanden: string | null;
|
||||
beschreibung: string;
|
||||
accept: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
laeuft: boolean;
|
||||
hintergrund: string;
|
||||
onWaehlen: (datei: File) => void;
|
||||
onEntfernen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span
|
||||
className={`flex h-16 w-24 shrink-0 items-center justify-center rounded-lg border border-slate-200 p-2 ${hintergrund}`}
|
||||
>
|
||||
{vorhanden ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||
Datenbank ausgeliefert */
|
||||
<img
|
||||
src={`/api/uploads/${vorhanden}`}
|
||||
alt="Vorschau"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">keins</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-600">{beschreibung}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={laeuft}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{vorhanden ? "Ersetzen" : "Datei wählen"}
|
||||
</Button>
|
||||
{vorhanden ? (
|
||||
<Button variant="ghost" size="sm" disabled={laeuft} onClick={onEntfernen}>
|
||||
Entfernen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const datei = event.target.files?.[0];
|
||||
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
|
||||
event.target.value = "";
|
||||
if (datei) onWaehlen(datei);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
import type { QrCodeDto } from "@/lib/dto";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import { describeSlugProblem } from "@/lib/slug";
|
||||
import type { QrFormData } from "@/lib/qr/type-module";
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
|
||||
return code.label?.trim() || code.summary;
|
||||
}
|
||||
|
||||
export function CodesList() {
|
||||
export function CodesList({
|
||||
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
|
||||
maySetSlug = true,
|
||||
}: {
|
||||
maySetSlug?: boolean;
|
||||
}) {
|
||||
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<QrCodeDto | null>(null);
|
||||
const [editSlug, setEditSlug] = useState("");
|
||||
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
|
||||
const [editAliases, setEditAliases] = useState<string[]>([]);
|
||||
const [editData, setEditData] = useState<QrFormData>({});
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
@@ -124,9 +133,21 @@ export function CodesList() {
|
||||
setEditing(code);
|
||||
setEditData(code.data);
|
||||
setEditLabel(code.label ?? "");
|
||||
setEditSlug(code.slug ?? "");
|
||||
setEditAliases(code.aliases);
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const slugProblem =
|
||||
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||
const slugGeaendert =
|
||||
editing !== null &&
|
||||
editing.slug !== null &&
|
||||
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||
const freigegeben = editing
|
||||
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||
: [];
|
||||
|
||||
const EditForm = editing ? qrForms[editing.type] : undefined;
|
||||
const editValidation = useMemo(
|
||||
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
||||
@@ -139,12 +160,29 @@ export function CodesList() {
|
||||
setEditError(editValidation.message);
|
||||
return;
|
||||
}
|
||||
if (slugProblem) {
|
||||
setEditError(slugProblem);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
json: { data: editValidation.data, label: editLabel.trim() || null },
|
||||
json: {
|
||||
label: editLabel.trim() || null,
|
||||
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
|
||||
// gehören nicht in die Anfrage.
|
||||
...(editing.mode === "static"
|
||||
? {}
|
||||
: {
|
||||
data: editValidation.data,
|
||||
// Nur mitschicken, wenn es bearbeitet werden durfte.
|
||||
...(maySetSlug
|
||||
? { slug: editSlug.trim(), aliases: editAliases }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
setEditing(null);
|
||||
await load();
|
||||
@@ -179,6 +217,7 @@ export function CodesList() {
|
||||
};
|
||||
|
||||
const copy = async (code: QrCodeDto) => {
|
||||
if (!code.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.shortUrl);
|
||||
setCopiedId(code.id);
|
||||
@@ -297,7 +336,7 @@ export function CodesList() {
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Ansicht"
|
||||
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5"
|
||||
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
@@ -316,7 +355,7 @@ export function CodesList() {
|
||||
className={cx(
|
||||
"rounded-md p-2 transition-colors",
|
||||
view === value
|
||||
? "bg-indigo-50 text-indigo-700"
|
||||
? "bg-accent-soft text-accent-soft-fg"
|
||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
||||
)}
|
||||
>
|
||||
@@ -374,7 +413,7 @@ export function CodesList() {
|
||||
setQuery("");
|
||||
setGroupFilter("alle");
|
||||
}}
|
||||
className="font-medium text-indigo-600 underline"
|
||||
className="font-medium text-accent-text underline"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
@@ -384,7 +423,7 @@ export function CodesList() {
|
||||
|
||||
{/* ------------------------------------------------------ Listenansicht */}
|
||||
{view === "list" && visible.length > 0 ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
|
||||
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
|
||||
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
|
||||
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
|
||||
@@ -449,9 +488,18 @@ export function CodesList() {
|
||||
<div className="truncate text-xs text-slate-500">
|
||||
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
||||
gleich für alle Zeilen, der Slug unterscheidet. */}
|
||||
<span className="font-mono md:hidden" title={code.shortUrl}>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
{code.shortUrl ? (
|
||||
<span
|
||||
className="font-mono md:hidden"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="md:hidden">
|
||||
{qrTypes[code.type]?.title ?? code.type} · statisch
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden md:inline">
|
||||
{qrTypes[code.type]?.title ?? code.type}
|
||||
</span>
|
||||
@@ -461,28 +509,34 @@ export function CodesList() {
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-indigo-600 hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
|
||||
// steckt in ihm selbst.
|
||||
<Badge tone="neutral">statisch</Badge>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 sm:table-cell">
|
||||
@@ -497,7 +551,13 @@ export function CodesList() {
|
||||
{code.target.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block truncate text-red-600" title={code.summary}>
|
||||
<span
|
||||
className={cx(
|
||||
"block truncate",
|
||||
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||
)}
|
||||
title={code.summary}
|
||||
>
|
||||
{code.summary}
|
||||
</span>
|
||||
)}
|
||||
@@ -512,7 +572,13 @@ export function CodesList() {
|
||||
) : null}
|
||||
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.scans}
|
||||
{code.mode === "static" ? (
|
||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td
|
||||
@@ -539,8 +605,8 @@ export function CodesList() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(code)}
|
||||
title="Ziel bearbeiten"
|
||||
aria-label={`Ziel von ${displayName(code)} bearbeiten`}
|
||||
title="Bearbeiten"
|
||||
aria-label={`${displayName(code)} bearbeiten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="pencil" className="size-4" />
|
||||
@@ -610,30 +676,41 @@ export function CodesList() {
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-indigo-600 hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
) : (
|
||||
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
|
||||
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Art:</dt>
|
||||
<dd className="text-slate-800">
|
||||
statisch — Inhalt steckt im Code, nicht änderbar
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Ziel:</dt>
|
||||
@@ -670,7 +747,7 @@ export function CodesList() {
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="star" className="size-3.5" />
|
||||
Rückmeldungen
|
||||
@@ -683,18 +760,18 @@ export function CodesList() {
|
||||
onClick={() => openEdit(code)}
|
||||
>
|
||||
<Icon name="pencil" className="size-3.5" />
|
||||
Ziel bearbeiten
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
PNG
|
||||
</a>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
SVG
|
||||
@@ -721,7 +798,7 @@ export function CodesList() {
|
||||
{/* ------------------------------------------------------ Bearbeiten */}
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
title="Ziel bearbeiten"
|
||||
title="Code bearbeiten"
|
||||
onClose={() => setEditing(null)}
|
||||
footer={
|
||||
<>
|
||||
@@ -742,15 +819,12 @@ export function CodesList() {
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
|
||||
auf{" "}
|
||||
<code className="font-mono text-xs break-all">
|
||||
{editing.shortUrl}
|
||||
</code>
|
||||
.
|
||||
{editing.mode === "static"
|
||||
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||
</p>
|
||||
|
||||
{EditForm ? (
|
||||
{editing.mode === "static" ? null : EditForm ? (
|
||||
<EditForm
|
||||
value={editData}
|
||||
onChange={(value) => {
|
||||
@@ -766,6 +840,95 @@ export function CodesList() {
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{maySetSlug && editing.mode !== "static" ? (
|
||||
<>
|
||||
<Field
|
||||
label="Kurz-Link"
|
||||
htmlFor="edit-slug"
|
||||
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||
>
|
||||
<TextInput
|
||||
id="edit-slug"
|
||||
value={editSlug}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
invalid={Boolean(slugProblem)}
|
||||
disabled={saving}
|
||||
onChange={(event) => {
|
||||
setEditSlug(event.target.value);
|
||||
setEditError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{slugProblem ? (
|
||||
<Notice tone="error">{slugProblem}</Notice>
|
||||
) : slugGeaendert ? (
|
||||
<Notice tone="info">
|
||||
Der bisherige Kurz-Link{" "}
|
||||
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
|
||||
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
|
||||
lässt er sich später freigeben.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{editing.aliases.length > 0 ? (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
Frühere Kurz-Links
|
||||
</span>
|
||||
<p className="mb-2 text-sm text-slate-500">
|
||||
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
|
||||
nichts gedruckt ist — die Adresse ist danach sofort tot.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{editing.aliases.map((alias) => {
|
||||
const behalten = editAliases.includes(alias);
|
||||
return (
|
||||
<li
|
||||
key={alias}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
|
||||
>
|
||||
<code
|
||||
className={
|
||||
behalten
|
||||
? "min-w-0 font-mono text-xs break-all text-slate-700"
|
||||
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
|
||||
}
|
||||
>
|
||||
/r/{alias}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant={behalten ? "ghost" : "secondary"}
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setEditAliases((alt) =>
|
||||
behalten
|
||||
? alt.filter((eintrag) => eintrag !== alias)
|
||||
: [...alt, alias],
|
||||
)
|
||||
}
|
||||
>
|
||||
{behalten ? "Freigeben" : "Doch behalten"}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{freigegeben.length > 0 ? (
|
||||
<Notice tone="warning">
|
||||
{freigegeben.length === 1
|
||||
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
|
||||
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
|
||||
</Notice>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Field label="Name (optional)" htmlFor="edit-label">
|
||||
<TextInput
|
||||
id="edit-label"
|
||||
|
||||
@@ -40,7 +40,7 @@ function ColorField({
|
||||
aria-label={`${label} auswählen`}
|
||||
value={valid ? value : "#000000"}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
|
||||
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||
/>
|
||||
<TextInput
|
||||
id={id}
|
||||
@@ -95,7 +95,7 @@ export function DesignPanel({
|
||||
onClick={() =>
|
||||
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
|
||||
}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded border border-slate-300"
|
||||
@@ -111,7 +111,7 @@ export function DesignPanel({
|
||||
onClick={() =>
|
||||
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
|
||||
}
|
||||
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Farben tauschen
|
||||
</button>
|
||||
@@ -158,7 +158,7 @@ export function DesignPanel({
|
||||
if (Number.isNaN(raw)) return;
|
||||
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
|
||||
}}
|
||||
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
||||
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -182,8 +182,8 @@ export function DesignPanel({
|
||||
className={cx(
|
||||
"rounded-lg border py-2 text-sm font-medium transition-colors",
|
||||
design.ecLevel === level
|
||||
? "border-indigo-600 bg-indigo-50 text-indigo-700"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
|
||||
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
{domain.hostname}
|
||||
</h2>
|
||||
{domain.hostname === baseUrlHostname ? (
|
||||
<Badge tone="popular">aus BASE_URL</Badge>
|
||||
<Badge tone="accent">aus BASE_URL</Badge>
|
||||
) : null}
|
||||
{!domain.isActive ? (
|
||||
<Badge tone="soon">abgeschaltet</Badge>
|
||||
|
||||
@@ -214,6 +214,24 @@ const PATHS = {
|
||||
<path d="M4 12h16" />
|
||||
</>
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
|
||||
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
|
||||
</>
|
||||
),
|
||||
moon: (
|
||||
<>
|
||||
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
|
||||
</>
|
||||
),
|
||||
monitor: (
|
||||
<>
|
||||
<rect x="2.5" y="4" width="19" height="13" rx="2" />
|
||||
<path d="M9 21h6M12 17v4" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
|
||||
|
||||
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
|
||||
<fieldset>
|
||||
<legend className="text-center text-base font-medium text-slate-900">
|
||||
{question}
|
||||
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
value={kommentar}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => setKommentar(event.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
|
||||
{images.map((image, index) => (
|
||||
<figure
|
||||
key={image.fileId}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
||||
|
||||
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
|
||||
href={link.url}
|
||||
// Fremde Ziele: kein Zugriff auf dieses Fenster.
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 font-medium break-words">
|
||||
{link.label}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
|
||||
title={title || filename || "PDF-Dokument"}
|
||||
subtitle={description || null}
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
||||
<Icon name="document" className="size-6" />
|
||||
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
|
||||
</a>
|
||||
<a
|
||||
href={`${href}?download`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-4" />
|
||||
Herunterladen
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
|
||||
<a
|
||||
href={profile.url}
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
|
||||
<a
|
||||
href={url}
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
|
||||
<Icon name="video" className="size-5" />
|
||||
|
||||
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGestartet(true)}
|
||||
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
|
||||
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-scrim px-6 text-center shadow-sm transition-colors hover:brightness-125"
|
||||
>
|
||||
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
|
||||
<Icon name="video" className="size-8" />
|
||||
</span>
|
||||
<span className="font-medium text-white">Video abspielen</span>
|
||||
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
|
||||
<span className="max-w-xs text-xs leading-relaxed text-white/75">
|
||||
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
|
||||
werden Daten an den Anbieter übertragen.
|
||||
</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Modal({
|
||||
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
||||
if (event.target === ref.current) onClose();
|
||||
}}
|
||||
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40"
|
||||
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
<img
|
||||
src={`/api/uploads/${image.fileId}`}
|
||||
alt=""
|
||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
|
||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="truncate text-sm text-slate-600">
|
||||
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`Bild ${index + 1} nach oben`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||
</button>
|
||||
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === images.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`Bild ${index + 1} nach unten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||
</button>
|
||||
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Bild ${index + 1} entfernen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`Link ${index + 1} nach oben`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||
</button>
|
||||
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === links.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`Link ${index + 1} nach unten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||
</button>
|
||||
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || links.length === 1}
|
||||
onClick={() => setLinks(links.filter((_, i) => i !== index))}
|
||||
aria-label={`Link ${index + 1} entfernen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
onChange({ ...value, fileId: "", filename: "", size: 0 })
|
||||
}
|
||||
aria-label="Datei entfernen"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
setProfiles(profiles.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Profil ${index + 1} entfernen`}
|
||||
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
onClick={() =>
|
||||
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
|
||||
}
|
||||
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="mt-2 rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
https:// voranstellen
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
|
||||
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
|
||||
import { describeSlugProblem } from "@/lib/slug";
|
||||
|
||||
type PreviewState = { dataUrl: string; content: string };
|
||||
|
||||
@@ -42,7 +43,14 @@ function sameData(a: unknown, b: unknown): boolean {
|
||||
return stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
|
||||
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
export function QrGenerator({
|
||||
typeId,
|
||||
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||
maySetSlug = true,
|
||||
}: {
|
||||
typeId: QrTypeId;
|
||||
maySetSlug?: boolean;
|
||||
}) {
|
||||
const qrType = qrTypes[typeId];
|
||||
const Form = qrForms[typeId];
|
||||
|
||||
@@ -51,6 +59,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
|
||||
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
|
||||
const [label, setLabel] = useState("");
|
||||
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
|
||||
const [wunschSlug, setWunschSlug] = useState("");
|
||||
|
||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -74,11 +84,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
const dataError = !validation.ok && touched ? validation.message : null;
|
||||
|
||||
const mode = isDynamic ? "dynamic" : "static";
|
||||
const slug = isDynamic ? saved?.slug : undefined;
|
||||
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
|
||||
|
||||
const requestBody = useMemo(
|
||||
() => ({ type: typeId, data, design, mode, slug }),
|
||||
[typeId, data, design, mode, slug],
|
||||
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
|
||||
// Herunterladen. Auf die Vorschau hat er keinen Einfluss.
|
||||
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
|
||||
[typeId, data, design, mode, slug, label],
|
||||
);
|
||||
|
||||
const ready = validation.ok && designValid;
|
||||
@@ -164,6 +176,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
|
||||
/* ----------------------------------------------- Dynamischen Code speichern */
|
||||
|
||||
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
|
||||
// ein Zeichen nicht erlaubt ist.
|
||||
const slugProblem =
|
||||
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
|
||||
|
||||
const hasUnsavedChanges =
|
||||
saved !== null &&
|
||||
(!sameData(saved.data, validation.ok ? validation.data : data) ||
|
||||
@@ -171,7 +188,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
(saved.label ?? "") !== label.trim());
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!validation.ok || !designValid) return;
|
||||
if (!validation.ok || !designValid || slugProblem) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
@@ -179,7 +196,10 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
type: typeId,
|
||||
data: validation.data,
|
||||
design,
|
||||
mode,
|
||||
label: label.trim() || undefined,
|
||||
// Ein statischer Code hat keinen Kurz-Link.
|
||||
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||
};
|
||||
const result = saved
|
||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||
@@ -200,10 +220,22 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [validation, designValid, typeId, design, label, saved]);
|
||||
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
|
||||
|
||||
/**
|
||||
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
|
||||
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
|
||||
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
|
||||
* dazu, dass die Oberfläche den falschen Zustand zeigt.
|
||||
*/
|
||||
const wechsleModus = useCallback((dynamisch: boolean) => {
|
||||
setIsDynamic(dynamisch);
|
||||
setSaved(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!saved) return;
|
||||
if (!saved?.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(saved.shortUrl);
|
||||
setCopied(true);
|
||||
@@ -226,7 +258,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</Link>
|
||||
|
||||
<div className="mb-7 flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||
<Icon name={qrType.icon} className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
@@ -286,12 +318,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
data-mode="static"
|
||||
aria-pressed={!isDynamic}
|
||||
disabled={!qrType.supportsStatic}
|
||||
onClick={() => setIsDynamic(false)}
|
||||
onClick={() => wechsleModus(false)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
? "border-indigo-600 bg-accent-soft/60"
|
||||
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
@@ -310,12 +342,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
data-mode="dynamic"
|
||||
aria-pressed={isDynamic}
|
||||
disabled={!qrType.supportsDynamic}
|
||||
onClick={() => setIsDynamic(true)}
|
||||
onClick={() => wechsleModus(true)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
? "border-indigo-600 bg-accent-soft/60"
|
||||
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
@@ -331,12 +363,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
htmlFor="code-label"
|
||||
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||
hint={
|
||||
isDynamic
|
||||
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
id="code-label"
|
||||
@@ -347,13 +382,63 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{saved ? (
|
||||
{!isDynamic || saved || !maySetSlug ? null : (
|
||||
<Field
|
||||
label="Kurz-Link (optional)"
|
||||
htmlFor="code-slug"
|
||||
error={slugProblem}
|
||||
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||
>
|
||||
<TextInput
|
||||
id="code-slug"
|
||||
value={wunschSlug}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
invalid={Boolean(slugProblem)}
|
||||
placeholder="z. B. sommeraktion oder kunde-a/flyer"
|
||||
onChange={(event) => setWunschSlug(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isDynamic ? (
|
||||
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
|
||||
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
|
||||
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
|
||||
von vorher; dann wird sie wieder angeboten. */
|
||||
saved && !hasUnsavedChanges ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Abgelegt.</span> Du findest
|
||||
den Code jetzt unter „Meine Codes“ und kannst ihn dort
|
||||
erneut herunterladen. Der Code selbst ändert sich dadurch
|
||||
nicht — sein Inhalt steckt in ihm.
|
||||
</Notice>
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||
>
|
||||
Zu „Meine Codes“
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
)
|
||||
) : saved ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Code gespeichert.</span> Der
|
||||
QR-Code zeigt dauerhaft auf diese Kurz-URL:
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
|
||||
<code className="rounded bg-surface/70 px-2 py-1 font-mono text-xs break-all">
|
||||
{saved.shortUrl}
|
||||
</code>
|
||||
<Button
|
||||
@@ -383,7 +468,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
) : null}
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||
>
|
||||
Zu „Meine Codes“
|
||||
<Icon name="external" className="size-3.5" />
|
||||
@@ -408,7 +493,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
disabled={saving || !ready || Boolean(slugProblem)}
|
||||
>
|
||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||
</Button>
|
||||
@@ -416,8 +501,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
)}
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function QrPreview({
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<span className="absolute top-3 right-3 rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
||||
<span className="absolute top-3 right-3 rounded-full bg-surface/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
||||
aktualisiert …
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Card, CardTitle, Checkbox, Notice } from "@/components/ui";
|
||||
|
||||
export type SettingsDto = {
|
||||
editorsMaySetSlug: boolean;
|
||||
};
|
||||
|
||||
export function SettingsAdmin({ initial }: { initial: SettingsDto }) {
|
||||
const [wert, setWert] = useState(initial);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
// Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem
|
||||
// zweiten Klick wirkt, ist eine Stolperfalle.
|
||||
const umschalten = async (aktiv: boolean) => {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
const vorher = wert;
|
||||
setWert({ ...wert, editorsMaySetSlug: aktiv });
|
||||
try {
|
||||
const antwort = await fetch("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ editorsMaySetSlug: aktiv }),
|
||||
});
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||
}
|
||||
setWert(rumpf.settings);
|
||||
setHinweis(
|
||||
aktiv
|
||||
? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern."
|
||||
: "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.",
|
||||
);
|
||||
} catch (error) {
|
||||
setWert(vorher);
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
|
||||
Kurz-Links
|
||||
</CardTitle>
|
||||
|
||||
<Checkbox
|
||||
id="editors-may-set-slug"
|
||||
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
|
||||
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
|
||||
checked={wert.editorsMaySetSlug}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => void umschalten(event.target.checked)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-slate-600">
|
||||
<p>
|
||||
Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten
|
||||
Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten —
|
||||
die vorherige Adresse bleibt zwar gültig, lässt sich aber auch
|
||||
wieder freigeben.
|
||||
</p>
|
||||
<p>
|
||||
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
|
||||
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
|
||||
die Freigabe unbedenklich.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { Icon, type IconName } from "@/components/icons";
|
||||
|
||||
/**
|
||||
* Umschalter für das Farbthema.
|
||||
*
|
||||
* Drei Zustände statt zwei: Wer „System“ wählt, folgt weiterhin dem
|
||||
* Betriebssystem — abends dunkel, morgens hell, ohne Zutun. Ein reiner
|
||||
* Ein/Aus-Schalter kann das nicht abbilden, weil er nach dem ersten Klick
|
||||
* für immer eine feste Wahl bedeutet.
|
||||
*
|
||||
* Umgesetzt wird die Wahl allein über das Attribut `data-theme` am
|
||||
* <html>-Element; die Farben stecken in `globals.css`. Ohne Attribut gilt die
|
||||
* Systemeinstellung — deshalb wird bei „System“ nicht etwa „light“ gesetzt,
|
||||
* sondern das Attribut entfernt.
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = "qrgen.theme";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
|
||||
const OPTIONEN: { wert: Theme; icon: IconName; label: string }[] = [
|
||||
{ wert: "system", icon: "monitor", label: "System" },
|
||||
{ wert: "light", icon: "sun", label: "Hell" },
|
||||
{ wert: "dark", icon: "moon", label: "Dunkel" },
|
||||
];
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (theme === "system") {
|
||||
delete document.documentElement.dataset.theme;
|
||||
} else {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft vor dem ersten Zeichnen und verhindert das kurze Aufblitzen der
|
||||
* hellen Fassung bei jemandem, der ausdrücklich „Dunkel“ gewählt hat.
|
||||
*
|
||||
* Bewusst winzig und ohne Abhängigkeiten: Alles, was hier steht, blockiert
|
||||
* den Seitenaufbau. Wer der Systemeinstellung folgt, braucht das Skript gar
|
||||
* nicht — dafür sorgt schon das CSS.
|
||||
*/
|
||||
export const THEME_INIT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
THEME_STORAGE_KEY,
|
||||
)});if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`;
|
||||
|
||||
/**
|
||||
* Die Wahl liegt im `localStorage` — also außerhalb von React. Dafür ist
|
||||
* `useSyncExternalStore` gedacht: Es liest den Wert beim Einhängen, ohne dass
|
||||
* der Server-Aufbau davon abweicht, und benachrichtigt alle Stellen, wenn er
|
||||
* sich ändert. Nebenbei zieht ein zweiter Tab automatisch nach.
|
||||
*/
|
||||
const zuhoerer = new Set<() => void>();
|
||||
|
||||
function abonnieren(melden: () => void): () => void {
|
||||
zuhoerer.add(melden);
|
||||
window.addEventListener("storage", melden);
|
||||
return () => {
|
||||
zuhoerer.delete(melden);
|
||||
window.removeEventListener("storage", melden);
|
||||
};
|
||||
}
|
||||
|
||||
function lesen(): Theme {
|
||||
try {
|
||||
const wert = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return wert === "dark" || wert === "light" ? wert : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
/** Auf dem Server ist nichts gespeichert — dort gilt die Systemeinstellung. */
|
||||
function aufDemServer(): Theme {
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(abonnieren, lesen, aufDemServer);
|
||||
|
||||
const waehlen = (wert: Theme) => {
|
||||
applyTheme(wert);
|
||||
try {
|
||||
if (wert === "system") localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
else localStorage.setItem(THEME_STORAGE_KEY, wert);
|
||||
} catch {
|
||||
// Privater Modus ohne Speicher — die Wahl gilt dann bis zum Neuladen.
|
||||
}
|
||||
for (const melden of zuhoerer) melden();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Farbthema"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5"
|
||||
>
|
||||
{OPTIONEN.map((option) => {
|
||||
const aktiv = theme === option.wert;
|
||||
return (
|
||||
<button
|
||||
key={option.wert}
|
||||
type="button"
|
||||
data-theme-option={option.wert}
|
||||
aria-pressed={aktiv}
|
||||
title={`Farbthema: ${option.label}`}
|
||||
aria-label={`Farbthema: ${option.label}`}
|
||||
onClick={() => waehlen(option.wert)}
|
||||
className={
|
||||
aktiv
|
||||
? "rounded-md bg-accent-soft p-1.5 text-accent-soft-fg"
|
||||
: "rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
}
|
||||
>
|
||||
<Icon name={option.icon} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function Toggle({
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
|
||||
"inline-block size-5 rounded-full bg-surface shadow-sm transition-transform",
|
||||
checked ? "translate-x-5.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
@@ -114,7 +114,7 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
||||
className={cx(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl",
|
||||
entry.enabled && entry.implemented
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
? "bg-accent-soft text-accent-text"
|
||||
: "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Card({
|
||||
return (
|
||||
<section
|
||||
className={cx(
|
||||
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6",
|
||||
"rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -57,7 +57,7 @@ const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
primary:
|
||||
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
|
||||
secondary:
|
||||
"border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 disabled:hover:bg-white",
|
||||
"border border-slate-300 bg-surface text-slate-700 hover:bg-slate-50 disabled:hover:bg-surface",
|
||||
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
|
||||
};
|
||||
@@ -124,7 +124,7 @@ export function TextInput({
|
||||
return (
|
||||
<input
|
||||
className={cx(
|
||||
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
|
||||
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
|
||||
invalid
|
||||
? "border-red-400 focus-visible:outline-red-500"
|
||||
: "border-slate-300",
|
||||
@@ -137,7 +137,7 @@ export function TextInput({
|
||||
|
||||
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
|
||||
export const CONTROL_BASE =
|
||||
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
|
||||
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
|
||||
|
||||
export function TextArea({
|
||||
invalid,
|
||||
@@ -236,12 +236,14 @@ export function Badge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
tone?: "neutral" | "popular" | "soon" | "success";
|
||||
tone?: "neutral" | "accent" | "soon" | "success";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: "bg-slate-100 text-slate-600",
|
||||
popular: "bg-indigo-100 text-indigo-700",
|
||||
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
|
||||
// „Beliebt" auf der Startseite gedacht war.
|
||||
accent: "bg-accent-soft text-accent-soft-fg",
|
||||
soon: "bg-slate-100 text-slate-500",
|
||||
success: "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
@@ -167,7 +167,7 @@ export function UserAdmin({
|
||||
{user.name?.trim() || user.email}
|
||||
</span>
|
||||
{user.isInstanceAdmin ? (
|
||||
<Badge tone="popular">Administrator</Badge>
|
||||
<Badge tone="accent">Administrator</Badge>
|
||||
) : null}
|
||||
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
|
||||
{user.isLocked ? (
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import "server-only";
|
||||
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
|
||||
import { getBaseUrl } from "@/lib/env";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und
|
||||
* getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt
|
||||
* aus dem Discovery-Dokument des Anbieters.
|
||||
*
|
||||
* Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche
|
||||
* Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier
|
||||
* bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische
|
||||
* Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist
|
||||
* die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe
|
||||
* beim Anbieter hängen lässt.
|
||||
*/
|
||||
|
||||
export type OidcConfig = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
/** Anzeigename des Knopfs auf der Anmeldeseite. */
|
||||
buttonLabel: string;
|
||||
/** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */
|
||||
adminGroup: string | null;
|
||||
/** Name des Anspruchs, in dem die Gruppen stehen. */
|
||||
groupsClaim: string;
|
||||
/** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */
|
||||
defaultRole: "ADMIN" | "EDITOR" | "VIEWER";
|
||||
/** Ist die Anmeldung mit Passwort daneben noch erlaubt? */
|
||||
allowLocalLogin: boolean;
|
||||
};
|
||||
|
||||
function env(name: string): string {
|
||||
return process.env[name]?.trim() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum
|
||||
* Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs.
|
||||
*/
|
||||
export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow";
|
||||
export const OIDC_FLOW_MINUTES = 10;
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
return env("OIDC_ENABLED").toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt —
|
||||
* eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“
|
||||
* enden.
|
||||
*/
|
||||
export function getOidcConfig(): OidcConfig {
|
||||
const issuer = env("OIDC_ISSUER").replace(/\/+$/, "");
|
||||
const clientId = env("OIDC_CLIENT_ID");
|
||||
const clientSecret = env("OIDC_CLIENT_SECRET");
|
||||
|
||||
const fehlend = [
|
||||
!issuer && "OIDC_ISSUER",
|
||||
!clientId && "OIDC_CLIENT_ID",
|
||||
!clientSecret && "OIDC_CLIENT_SECRET",
|
||||
].filter(Boolean);
|
||||
|
||||
if (fehlend.length > 0) {
|
||||
throw new QrRequestError(
|
||||
`Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase();
|
||||
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes: env("OIDC_SCOPES") || "openid email profile",
|
||||
buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden",
|
||||
adminGroup: env("OIDC_ADMIN_GROUP") || null,
|
||||
groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups",
|
||||
defaultRole:
|
||||
rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER"
|
||||
? rolle
|
||||
: "VIEWER",
|
||||
allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true",
|
||||
};
|
||||
}
|
||||
|
||||
/** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */
|
||||
export function redirectUri(): string {
|
||||
return `${getBaseUrl()}/api/auth/oidc/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
|
||||
*
|
||||
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
|
||||
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
|
||||
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
|
||||
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
|
||||
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
|
||||
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
|
||||
* Ursache gut versteckt.
|
||||
*
|
||||
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
|
||||
*/
|
||||
export function assertHostMatchesBaseUrl(headers: Headers): void {
|
||||
const angekommen = (
|
||||
headers.get("x-forwarded-host") ??
|
||||
headers.get("host") ??
|
||||
""
|
||||
)
|
||||
.split(",")[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!angekommen) return;
|
||||
|
||||
let erwartet = "";
|
||||
try {
|
||||
erwartet = new URL(getBaseUrl()).host.toLowerCase();
|
||||
} catch {
|
||||
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
|
||||
}
|
||||
|
||||
if (angekommen === erwartet) return;
|
||||
|
||||
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
|
||||
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
|
||||
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
|
||||
// interne Adresse der Installation geht Fremde nichts an.
|
||||
console.error(
|
||||
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
|
||||
);
|
||||
|
||||
throw new QrRequestError(
|
||||
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
|
||||
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
|
||||
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
|
||||
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
|
||||
`Protokoll des Containers.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
type Discovery = {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
jwks_uri: string;
|
||||
userinfo_endpoint?: string;
|
||||
end_session_endpoint?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei
|
||||
* jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist
|
||||
* kurz genug, um einen Schlüsselwechsel mitzubekommen.
|
||||
*/
|
||||
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
||||
let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null;
|
||||
|
||||
export async function discover(issuer: string): Promise<Discovery> {
|
||||
const url = `${issuer}/.well-known/openid-configuration`;
|
||||
const jetzt = Date.now();
|
||||
|
||||
if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) {
|
||||
return discoveryCache.wert;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
} catch {
|
||||
throw new QrRequestError(
|
||||
`Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new QrRequestError(
|
||||
`Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const wert = (await response.json()) as Discovery;
|
||||
if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) {
|
||||
throw new QrRequestError(
|
||||
"Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert };
|
||||
return wert;
|
||||
}
|
||||
|
||||
/** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */
|
||||
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||
|
||||
function jwks(uri: string) {
|
||||
const vorhanden = jwksCache.get(uri);
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = createRemoteJWKSet(new URL(uri));
|
||||
jwksCache.set(uri, neu);
|
||||
return neu;
|
||||
}
|
||||
|
||||
export type OidcIdentity = {
|
||||
subject: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string | null;
|
||||
groups: string[];
|
||||
};
|
||||
|
||||
function readGroups(payload: JWTPayload, claim: string): string[] {
|
||||
const wert = payload[claim];
|
||||
if (Array.isArray(wert)) {
|
||||
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
|
||||
}
|
||||
// Manche Anbieter liefern eine durch Leerzeichen getrennte Liste.
|
||||
if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauscht den Code gegen Token und prüft das ID-Token.
|
||||
*
|
||||
* Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller,
|
||||
* Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo
|
||||
* erbeutetes Token hier eingereicht wird.
|
||||
*/
|
||||
export async function exchangeCode(options: {
|
||||
config: OidcConfig;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
nonce: string;
|
||||
}): Promise<OidcIdentity> {
|
||||
const { config, code, codeVerifier, nonce } = options;
|
||||
const meta = await discover(config.issuer);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri(),
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetch(meta.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber
|
||||
// nicht ungefiltert in den Browser.
|
||||
const text = await response.text().catch(() => "");
|
||||
console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text);
|
||||
throw new QrRequestError(
|
||||
"Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const token = (await response.json()) as { id_token?: string };
|
||||
if (!token.id_token) {
|
||||
throw new QrRequestError(
|
||||
"Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), {
|
||||
issuer: meta.issuer || config.issuer,
|
||||
audience: config.clientId,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("OIDC: ID-Token nicht gültig:", error);
|
||||
throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502);
|
||||
}
|
||||
|
||||
if (payload.nonce !== nonce) {
|
||||
throw new QrRequestError(
|
||||
"Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const subject = typeof payload.sub === "string" ? payload.sub : "";
|
||||
const email =
|
||||
typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||||
|
||||
if (!subject) {
|
||||
throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502);
|
||||
}
|
||||
if (!email) {
|
||||
throw new QrRequestError(
|
||||
"Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof payload.name === "string" && payload.name.trim().length > 0
|
||||
? payload.name.trim()
|
||||
: typeof payload.preferred_username === "string"
|
||||
? payload.preferred_username.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
subject,
|
||||
email,
|
||||
emailVerified: payload.email_verified !== false,
|
||||
name,
|
||||
groups: readGroups(payload, config.groupsClaim),
|
||||
};
|
||||
}
|
||||
@@ -34,9 +34,16 @@ function scryptAsync(
|
||||
*
|
||||
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
|
||||
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
|
||||
* entwerten.
|
||||
* entwerten — `needsRehash()` erkennt dann veraltete Hashes.
|
||||
*
|
||||
* N = 2^17, r = 8, p = 1 entspricht der Empfehlung des OWASP Password Storage
|
||||
* Cheat Sheet (Stand 2026). Das kostet rund 128 MiB Speicher je Berechnung;
|
||||
* `maxmem` muss darüber liegen, sonst weigert sich Node.
|
||||
*/
|
||||
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
||||
const PARAMS = { N: 131072, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
||||
|
||||
/** 128 · N · r plus Reserve. */
|
||||
const MAXMEM = 192 * 1024 * 1024;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(PARAMS.saltLength);
|
||||
@@ -48,8 +55,8 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
N: PARAMS.N,
|
||||
r: PARAMS.r,
|
||||
p: PARAMS.p,
|
||||
// Der Standardwert reicht für N = 32768 nicht aus.
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
// Der Standardwert reicht für dieses N nicht aus.
|
||||
maxmem: MAXMEM,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -92,7 +99,7 @@ export async function verifyPassword(
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
maxmem: MAXMEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
@@ -103,9 +110,33 @@ export async function verifyPassword(
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Hashes, die mit schwächeren Parametern erzeugt wurden.
|
||||
*
|
||||
* Beim nächsten erfolgreichen Anmelden wird das Passwort dann neu gehasht —
|
||||
* anders kommt man nicht an das Klartextpasswort, um den Hash zu erneuern.
|
||||
*/
|
||||
export function needsRehash(stored: string): boolean {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
|
||||
return (
|
||||
Number(parts[1]) < PARAMS.N ||
|
||||
Number(parts[2]) < PARAMS.r ||
|
||||
Number(parts[3]) < PARAMS.p
|
||||
);
|
||||
}
|
||||
|
||||
/** Mindestanforderungen an ein Passwort. */
|
||||
export const MIN_PASSWORD_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Obergrenze — nicht aus Sicherheitsgründen, sondern gegen Überlastung:
|
||||
* scrypt über ein mehrere Megabyte langes „Passwort" würde den Server
|
||||
* beschäftigen, und das an einer Stelle, die ohne Anmeldung erreichbar ist.
|
||||
* 128 Zeichen sind weit jenseits jeder sinnvollen Passphrase.
|
||||
*/
|
||||
export const MAX_PASSWORD_LENGTH = 128;
|
||||
|
||||
export function describePasswordRules(): string {
|
||||
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,30 @@ export type Action =
|
||||
| "codes:update"
|
||||
/** Codes löschen. */
|
||||
| "codes:delete"
|
||||
/**
|
||||
* Den Kurz-Link selbst vergeben oder ändern.
|
||||
*
|
||||
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
|
||||
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
|
||||
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
|
||||
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
|
||||
* Administratoren — freigeben lässt es sich in der Verwaltung.
|
||||
*/
|
||||
| "codes:slug"
|
||||
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
||||
| "group:manage"
|
||||
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
||||
| "instance:manage";
|
||||
|
||||
export type Scope = { groupId?: string };
|
||||
export type Scope = {
|
||||
groupId?: string;
|
||||
/**
|
||||
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
|
||||
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
|
||||
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
|
||||
*/
|
||||
editorsMaySetSlug?: boolean;
|
||||
};
|
||||
|
||||
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
||||
|
||||
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
||||
case "codes:update":
|
||||
case "codes:delete":
|
||||
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
||||
case "codes:slug":
|
||||
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
|
||||
if (role === "ADMIN") return true;
|
||||
return (
|
||||
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
|
||||
);
|
||||
case "group:manage":
|
||||
return role === "ADMIN";
|
||||
default:
|
||||
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
|
||||
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:delete":
|
||||
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:slug":
|
||||
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
|
||||
"group:manage":
|
||||
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
||||
"instance:manage":
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const SESSION_COOKIE = "qrgen_session";
|
||||
|
||||
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
|
||||
const SESSION_DAYS = 30;
|
||||
/**
|
||||
* Wie lange eine Sitzung ohne Aktivität gültig bleibt.
|
||||
*
|
||||
* Das OWASP Session Management Cheat Sheet nennt für Anwendungen mit
|
||||
* geringem Risiko 15–30 Minuten Leerlauf und 4–8 Stunden absolut. Das ist auf
|
||||
* öffentlich erreichbare Anwendungen mit vielen Nutzern gemünzt; für ein
|
||||
* internes Werkzeug, an dem man täglich arbeitet, wäre es eine Zumutung.
|
||||
* Deshalb großzügigere Vorgaben — aber beide über die Umgebung einstellbar,
|
||||
* damit strengere Werte eine Änderung in der `.env` sind und kein Umbau.
|
||||
*/
|
||||
function tage(name: string, voreinstellung: number): number {
|
||||
const roh = Number.parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(roh) && roh > 0 && roh <= 365 ? roh : voreinstellung;
|
||||
}
|
||||
|
||||
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Ohne Aktivität läuft die Sitzung nach so vielen Tagen ab. */
|
||||
function idleDays(): number {
|
||||
return tage("SESSION_IDLE_DAYS", 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Und spätestens nach so vielen Tagen ab dem Anmelden — unabhängig davon,
|
||||
* wie oft sie benutzt wurde. Ohne diese Grenze könnte eine einmal gestohlene
|
||||
* Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
||||
*/
|
||||
function absoluteDays(): number {
|
||||
return tage("SESSION_MAX_DAYS", 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
|
||||
@@ -19,7 +47,25 @@ function hashToken(token: string): string {
|
||||
}
|
||||
|
||||
function expiryFromNow(): Date {
|
||||
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
return new Date(Date.now() + idleDays() * MS_PRO_TAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die Verbindung über HTTPS?
|
||||
*
|
||||
* Neben der `BASE_URL` zählt auch `X-Forwarded-Proto`: Hinter einem Proxy,
|
||||
* der TLS beendet, spricht die Anwendung selbst nur HTTP — der Cookie muss
|
||||
* trotzdem als `Secure` gesetzt werden.
|
||||
*/
|
||||
async function isHttps(): Promise<boolean> {
|
||||
if ((process.env.BASE_URL ?? "").startsWith("https://")) return true;
|
||||
try {
|
||||
const kopf = await headers();
|
||||
return (kopf.get("x-forwarded-proto") ?? "").split(",")[0].trim() === "https";
|
||||
} catch {
|
||||
// Außerhalb einer Anfrage — dann bleibt nur die BASE_URL.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionUser = {
|
||||
@@ -32,10 +78,18 @@ export type SessionUser = {
|
||||
|
||||
/** Legt eine Sitzung an und setzt den Cookie. */
|
||||
export async function createSession(userId: string): Promise<void> {
|
||||
// 32 Byte aus dem CSPRNG — weit über den 128 Bit, die OWASP mindestens
|
||||
// verlangt.
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const jetzt = Date.now();
|
||||
|
||||
await prisma.session.create({
|
||||
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
|
||||
data: {
|
||||
tokenHash: hashToken(token),
|
||||
userId,
|
||||
expiresAt: expiryFromNow(),
|
||||
absoluteExpiresAt: new Date(jetzt + absoluteDays() * MS_PRO_TAG),
|
||||
},
|
||||
});
|
||||
|
||||
const store = await cookies();
|
||||
@@ -45,7 +99,7 @@ export async function createSession(userId: string): Promise<void> {
|
||||
path: "/",
|
||||
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
|
||||
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
|
||||
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
||||
secure: await isHttps(),
|
||||
expires: expiryFromNow(),
|
||||
});
|
||||
}
|
||||
@@ -89,7 +143,11 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt.getTime() < Date.now()) {
|
||||
// Abgelaufen — durch Leerlauf oder weil die absolute Grenze erreicht ist.
|
||||
if (
|
||||
session.expiresAt.getTime() < Date.now() ||
|
||||
session.absoluteExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
@@ -100,12 +158,26 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
|
||||
const anHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
if (session.lastSeenAt.getTime() < anHourAgo) {
|
||||
// Nie über die absolute Grenze hinaus verlängern.
|
||||
const verlaengert = expiryFromNow();
|
||||
await prisma.session
|
||||
.update({
|
||||
where: { id: session.id },
|
||||
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
|
||||
data: {
|
||||
lastSeenAt: new Date(),
|
||||
expiresAt:
|
||||
verlaengert < session.absoluteExpiresAt
|
||||
? verlaengert
|
||||
: session.absoluteExpiresAt,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Beiläufig aufräumen — abgelaufene Sitzungen sind harmlos, sollen sich
|
||||
// aber nicht endlos ansammeln.
|
||||
void prisma.session
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Grobe Bremse gegen automatisiertes Durchprobieren.
|
||||
*
|
||||
* Ergänzt die Kontosperre, ersetzt sie aber nicht: Die Sperre schützt ein
|
||||
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
|
||||
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
|
||||
*
|
||||
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
|
||||
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
|
||||
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
|
||||
* nirgends gespeichert.
|
||||
*/
|
||||
|
||||
type Eintrag = { anzahl: number; bis: number };
|
||||
|
||||
const zaehler = new Map<string, Eintrag>();
|
||||
|
||||
/** Ab wie vielen Versuchen je Zeitfenster abgeblockt wird. */
|
||||
export const LOGIN_ATTEMPTS_PER_WINDOW = 20;
|
||||
const FENSTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Zählt einen Versuch und meldet, ob das Fenster überschritten ist.
|
||||
* Gibt die verbleibenden Sekunden zurück, sonst `null`.
|
||||
*/
|
||||
export function tooManyAttempts(schluessel: string): number | null {
|
||||
const jetzt = Date.now();
|
||||
const eintrag = zaehler.get(schluessel);
|
||||
|
||||
if (!eintrag || eintrag.bis < jetzt) {
|
||||
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
|
||||
|
||||
// Gelegentlich ausmisten, damit die Tabelle nicht unbegrenzt wächst.
|
||||
if (zaehler.size > 5000) {
|
||||
for (const [key, wert] of zaehler) {
|
||||
if (wert.bis < jetzt) zaehler.delete(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
eintrag.anzahl += 1;
|
||||
if (eintrag.anzahl > LOGIN_ATTEMPTS_PER_WINDOW) {
|
||||
return Math.ceil((eintrag.bis - jetzt) / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Nach erfolgreicher Anmeldung ist der Zähler gegenstandslos. */
|
||||
export function clearAttempts(schluessel: string): void {
|
||||
zaehler.delete(schluessel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absender einer Anfrage, so gut es hinter einem Proxy geht.
|
||||
*
|
||||
* `X-Forwarded-For` ist fälschbar, wenn kein Proxy davorsteht — deshalb ist
|
||||
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
|
||||
* aber die Kontosperre greift weiterhin.
|
||||
*/
|
||||
export function requestOrigin(headers: Headers): string {
|
||||
return (
|
||||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
headers.get("x-real-ip") ||
|
||||
"unbekannt"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Erzeugt aus einer einzigen Akzentfarbe die volle Abstufung 50–950.
|
||||
*
|
||||
* Warum das reicht, um die ganze Oberfläche umzufärben: Tailwind 4 übersetzt
|
||||
* `bg-indigo-600` zu `background-color: var(--color-indigo-600)`. Wer diese
|
||||
* Variablen zur Laufzeit überschreibt, färbt damit jede der rund 60 Stellen
|
||||
* um — ohne eine einzige Klasse anzufassen.
|
||||
*
|
||||
* Vom gewählten Ton werden nur **Farbton und Sättigung** übernommen, nicht
|
||||
* die Helligkeit. Die Helligkeitskurve stammt aus der Tailwind-Vorlage. Das
|
||||
* ist Absicht: Auf `-600` steht weiße Schrift. Käme die Helligkeit aus der
|
||||
* Eingabe, würde ein helles Gelb die Kopfzeile unlesbar machen.
|
||||
*/
|
||||
|
||||
/** Die Abstufung von Tailwind 4 (indigo) als Vorlage für Helligkeit und Form. */
|
||||
const VORLAGE = [
|
||||
{ stop: 50, l: 96.2, c: 0.018, h: 272.314 },
|
||||
{ stop: 100, l: 93.0, c: 0.034, h: 272.788 },
|
||||
{ stop: 200, l: 87.0, c: 0.065, h: 274.039 },
|
||||
{ stop: 300, l: 78.5, c: 0.115, h: 274.713 },
|
||||
{ stop: 400, l: 67.3, c: 0.182, h: 276.935 },
|
||||
{ stop: 500, l: 58.5, c: 0.233, h: 277.117 },
|
||||
{ stop: 600, l: 51.1, c: 0.262, h: 276.966 },
|
||||
{ stop: 700, l: 45.7, c: 0.24, h: 277.023 },
|
||||
{ stop: 800, l: 39.8, c: 0.195, h: 277.366 },
|
||||
{ stop: 900, l: 35.9, c: 0.144, h: 278.697 },
|
||||
{ stop: 950, l: 25.7, c: 0.09, h: 281.288 },
|
||||
] as const;
|
||||
|
||||
const BEZUG = VORLAGE.find((eintrag) => eintrag.stop === 600)!;
|
||||
|
||||
export const DEFAULT_ACCENT = "#4F46E5";
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** sRGB-Kanal (0–1) in seinen linearen Anteil. */
|
||||
function toLinear(kanal: number): number {
|
||||
return kanal <= 0.04045
|
||||
? kanal / 12.92
|
||||
: Math.pow((kanal + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex nach OKLCH.
|
||||
*
|
||||
* OKLab ist so gebaut, dass gleiche Zahlenabstände auch gleich große
|
||||
* wahrgenommene Unterschiede sind — anders als HSL, wo dasselbe „50 %
|
||||
* Helligkeit" bei Gelb und Blau völlig verschieden aussieht.
|
||||
*/
|
||||
export function hexToOklch(hex: string): { l: number; c: number; h: number } {
|
||||
const wert = hex.trim().replace("#", "");
|
||||
const r = toLinear(Number.parseInt(wert.slice(0, 2), 16) / 255);
|
||||
const g = toLinear(Number.parseInt(wert.slice(2, 4), 16) / 255);
|
||||
const b = toLinear(Number.parseInt(wert.slice(4, 6), 16) / 255);
|
||||
|
||||
const lMs = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const mMs = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const sMs = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
|
||||
const l = 0.2104542553 * lMs + 0.793617785 * mMs - 0.0040720468 * sMs;
|
||||
const a = 1.9779984951 * lMs - 2.428592205 * mMs + 0.4505937099 * sMs;
|
||||
const bb = 0.0259040371 * lMs + 0.7827717662 * mMs - 0.808675766 * sMs;
|
||||
|
||||
const c = Math.sqrt(a * a + bb * bb);
|
||||
let h = (Math.atan2(bb, a) * 180) / Math.PI;
|
||||
if (h < 0) h += 360;
|
||||
|
||||
return { l: l * 100, c, h };
|
||||
}
|
||||
|
||||
export type ColorRamp = { stop: number; value: string }[];
|
||||
|
||||
/**
|
||||
* Die volle Abstufung zur gewählten Akzentfarbe.
|
||||
*
|
||||
* Die Sättigung wird so skaliert, dass der Ton auf `-600` der Eingabe
|
||||
* entspricht; nach oben begrenzt, damit ein sehr kräftiger Wert die hellen
|
||||
* Stufen nicht in ein grelles Pastell kippt. Browser rechnen Werte außerhalb
|
||||
* des darstellbaren Bereichs selbst zurück.
|
||||
*/
|
||||
export function buildRamp(hex: string): ColorRamp {
|
||||
const gewaehlt = hexToOklch(isHexColor(hex) ? hex : DEFAULT_ACCENT);
|
||||
|
||||
// Ein nahezu grauer Wert soll grau bleiben und nicht auf die Sättigung der
|
||||
// Vorlage hochgezogen werden.
|
||||
const faktor = Math.min(gewaehlt.c / BEZUG.c, 1.25);
|
||||
const drift = (h: number) => (h - BEZUG.h + 540) % 360 - 180;
|
||||
|
||||
return VORLAGE.map(({ stop, l, c, h }) => {
|
||||
const ton = (gewaehlt.h + drift(h) + 360) % 360;
|
||||
const saettigung = Math.max(c * faktor, 0);
|
||||
return {
|
||||
stop,
|
||||
value: `oklch(${l.toFixed(1)}% ${saettigung.toFixed(4)} ${ton.toFixed(3)})`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abstufung als CSS, fertig zum Einsetzen in einen `<style>`-Block.
|
||||
*
|
||||
* Überschrieben wird bewusst die Indigo-Reihe selbst — so wirkt die Farbe
|
||||
* überall dort, wo die Oberfläche sie schon benutzt.
|
||||
*/
|
||||
export function accentCss(hex: string): string {
|
||||
const zeilen = buildRamp(hex)
|
||||
.map(({ stop, value }) => `--color-indigo-${stop}:${value};`)
|
||||
.join("");
|
||||
return `:root{${zeilen}}`;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import "server-only";
|
||||
|
||||
import { DEFAULT_ACCENT, isHexColor } from "@/lib/branding-colors";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export type Branding = {
|
||||
appName: string;
|
||||
footerText: string;
|
||||
accentColor: string;
|
||||
logoUploadId: string | null;
|
||||
faviconUploadId: string | null;
|
||||
/** MIME-Typ des Favicons — der `<link>` braucht ihn. */
|
||||
faviconMimeType: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_BRANDING: Branding = {
|
||||
appName: "QR-Code-Generator",
|
||||
footerText: "statische und dynamische QR-Codes",
|
||||
accentColor: DEFAULT_ACCENT,
|
||||
logoUploadId: null,
|
||||
faviconUploadId: null,
|
||||
faviconMimeType: null,
|
||||
};
|
||||
|
||||
export const MAX_APP_NAME = 60;
|
||||
export const MAX_FOOTER_TEXT = 120;
|
||||
|
||||
/**
|
||||
* Bewusst **ohne** Zwischenspeicher.
|
||||
*
|
||||
* Ein erster Versuch hielt den Wert 15 Sekunden lang fest, um die Abfrage je
|
||||
* Seitenaufruf zu sparen. Das ging schief: Die API-Route und das Layout
|
||||
* laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
|
||||
* die andere Instanz also gar nicht. Nach dem Speichern blieb die Oberfläche
|
||||
* unverändert — der schlechteste denkbare Eindruck bei einer Einstellung, die
|
||||
* man ansieht.
|
||||
*
|
||||
* Es bleibt bei einer Abfrage auf den Primärschlüssel einer Tabelle mit einer
|
||||
* einzigen Zeile. Das ist zu verschmerzen; jede Seite fragt ohnehin mehr ab.
|
||||
*/
|
||||
export async function getBranding(): Promise<Branding> {
|
||||
let wert = DEFAULT_BRANDING;
|
||||
try {
|
||||
const zeile = await prisma.branding.findUnique({
|
||||
where: { id: "branding" },
|
||||
include: {
|
||||
faviconUpload: { select: { mimeType: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (zeile) {
|
||||
wert = {
|
||||
appName: zeile.appName,
|
||||
footerText: zeile.footerText,
|
||||
accentColor: isHexColor(zeile.accentColor)
|
||||
? zeile.accentColor
|
||||
: DEFAULT_ACCENT,
|
||||
logoUploadId: zeile.logoUploadId,
|
||||
faviconUploadId: zeile.faviconUploadId,
|
||||
faviconMimeType: zeile.faviconUpload?.mimeType ?? null,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Ein Problem mit der Datenbank darf nicht dazu führen, dass gar keine
|
||||
// Seite mehr erscheint — dann eben im Auslieferungszustand.
|
||||
console.error("Erscheinungsbild konnte nicht geladen werden:", error);
|
||||
}
|
||||
|
||||
return wert;
|
||||
}
|
||||
|
||||
export type BrandingUpdate = {
|
||||
appName?: string;
|
||||
footerText?: string;
|
||||
accentColor?: string;
|
||||
/** `null` entfernt das Bild, `undefined` lässt es unverändert. */
|
||||
logoUploadId?: string | null;
|
||||
faviconUploadId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schreibt das Erscheinungsbild und räumt ersetzte Bilder weg.
|
||||
*
|
||||
* Ohne das Aufräumen bliebe jedes einmal hochgeladene Logo für immer in der
|
||||
* Datenbank liegen: Es gehört zu keinem Code, und die Aufräumroutine für
|
||||
* herrenlose Dateien lässt „branding" bewusst in Ruhe.
|
||||
*/
|
||||
export async function saveBranding(update: BrandingUpdate): Promise<Branding> {
|
||||
if (update.accentColor !== undefined && !isHexColor(update.accentColor)) {
|
||||
throw new QrRequestError(
|
||||
"Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
|
||||
);
|
||||
}
|
||||
|
||||
const vorher = await prisma.branding.findUnique({
|
||||
where: { id: "branding" },
|
||||
select: { logoUploadId: true, faviconUploadId: true },
|
||||
});
|
||||
|
||||
const daten = {
|
||||
...(update.appName !== undefined ? { appName: update.appName.trim() } : {}),
|
||||
...(update.footerText !== undefined
|
||||
? { footerText: update.footerText.trim() }
|
||||
: {}),
|
||||
...(update.accentColor !== undefined
|
||||
? { accentColor: update.accentColor.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(update.logoUploadId !== undefined
|
||||
? { logoUploadId: update.logoUploadId }
|
||||
: {}),
|
||||
...(update.faviconUploadId !== undefined
|
||||
? { faviconUploadId: update.faviconUploadId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await prisma.branding.upsert({
|
||||
where: { id: "branding" },
|
||||
update: daten,
|
||||
create: { id: "branding", ...daten },
|
||||
});
|
||||
|
||||
// Die ersetzten Dateien erst danach löschen — solange die neue Zeile nicht
|
||||
// steht, wird noch die alte gebraucht.
|
||||
const abgeloest = [
|
||||
update.logoUploadId !== undefined &&
|
||||
vorher?.logoUploadId &&
|
||||
vorher.logoUploadId !== update.logoUploadId &&
|
||||
vorher.logoUploadId,
|
||||
update.faviconUploadId !== undefined &&
|
||||
vorher?.faviconUploadId &&
|
||||
vorher.faviconUploadId !== update.faviconUploadId &&
|
||||
vorher.faviconUploadId,
|
||||
].filter((id): id is string => typeof id === "string");
|
||||
|
||||
if (abgeloest.length > 0) {
|
||||
await prisma.upload
|
||||
.deleteMany({ where: { id: { in: abgeloest } } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return getBranding();
|
||||
}
|
||||
@@ -31,22 +31,31 @@ export type QrCodeWithRelations = QrCode & {
|
||||
group?: { name: string } | null;
|
||||
domain?: { hostname: string } | null;
|
||||
createdBy?: { name: string | null; email: string } | null;
|
||||
aliases?: { slug: string }[];
|
||||
};
|
||||
|
||||
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
||||
const data = asRecord(code.data);
|
||||
const qrType = getQrType(code.type);
|
||||
|
||||
const istStatisch = code.mode === "static";
|
||||
|
||||
let target = "";
|
||||
let summary = "";
|
||||
if (qrType) {
|
||||
try {
|
||||
// Ein statischer Code führt nirgendwohin — sein Inhalt steckt in ihm
|
||||
// selbst. Als „Ziel“ bleibt deshalb nichts übrig; die Zusammenfassung
|
||||
// sagt trotzdem, was drinsteht.
|
||||
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
|
||||
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
|
||||
// sich anzusehen.
|
||||
target = qrType.hasLandingPage
|
||||
? landingUrl(code.slug, code.domain?.hostname)
|
||||
: qrType.redirectTarget(data);
|
||||
target =
|
||||
istStatisch || !code.slug
|
||||
? ""
|
||||
: qrType.hasLandingPage
|
||||
? landingUrl(code.slug, code.domain?.hostname)
|
||||
: qrType.redirectTarget(data);
|
||||
summary = qrType.summary(data);
|
||||
} catch {
|
||||
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
|
||||
@@ -59,11 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
||||
|
||||
return {
|
||||
id: code.id,
|
||||
mode: istStatisch ? "static" : "dynamic",
|
||||
slug: code.slug,
|
||||
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
||||
type: code.type as QrTypeId,
|
||||
label: code.label,
|
||||
data,
|
||||
shortUrl: shortUrl(code.slug, code.domain?.hostname),
|
||||
shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null,
|
||||
target,
|
||||
summary,
|
||||
design: {
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import type { EcLevel } from "@/lib/qr/design";
|
||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||
|
||||
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
|
||||
export type QrCodeMode = "static" | "dynamic";
|
||||
|
||||
/**
|
||||
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von
|
||||
* Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von
|
||||
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
||||
*/
|
||||
export type QrCodeDto = {
|
||||
id: string;
|
||||
slug: string;
|
||||
/**
|
||||
* Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit
|
||||
* man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und
|
||||
* Scan-Zähler bleiben deshalb leer.
|
||||
*/
|
||||
mode: QrCodeMode;
|
||||
/** Kurz-Link. Leer bei statischen Codes. */
|
||||
slug: string | null;
|
||||
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
||||
aliases: string[];
|
||||
type: QrTypeId;
|
||||
label: string | null;
|
||||
data: Record<string, unknown>;
|
||||
/** Die Kurz-URL, die im QR-Code steckt. */
|
||||
shortUrl: string;
|
||||
/** Aktuelles Weiterleitungsziel. */
|
||||
/** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
|
||||
shortUrl: string | null;
|
||||
/** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
|
||||
target: string;
|
||||
/** Lesbare Kurzfassung des Ziels. */
|
||||
summary: string;
|
||||
|
||||
@@ -1,3 +1,44 @@
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Kommt diese Anfrage von der eigenen Oberfläche?
|
||||
*
|
||||
* Zweite Verteidigungslinie neben `SameSite=Lax` am Sitzungs-Cookie. Das
|
||||
* OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
|
||||
* genügt — es wirkt auf der Ebene der registrierbaren Domain, nicht des
|
||||
* Ursprungs.
|
||||
*
|
||||
* Verglichen wird gegen den Host der Anfrage, nicht gegen `BASE_URL`: Die
|
||||
* Anwendung ist absichtlich unter mehreren Domains erreichbar.
|
||||
*
|
||||
* Fehlt der `Origin`-Header, gilt die Anfrage als eigen: Browser setzen ihn
|
||||
* bei fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar
|
||||
* nicht. Ein Abweisen träfe nur die Falschen.
|
||||
*/
|
||||
export function isSameOrigin(headers: Headers): boolean {
|
||||
const origin = headers.get("origin");
|
||||
if (!origin) return true;
|
||||
|
||||
let herkunft: URL;
|
||||
try {
|
||||
herkunft = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
return Boolean(host) && herkunft.host === host;
|
||||
}
|
||||
|
||||
export function assertSameOrigin(headers: Headers): void {
|
||||
if (!isSameOrigin(headers)) {
|
||||
throw new QrRequestError(
|
||||
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(message: string, status = 400): Response {
|
||||
return Response.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
|
||||
slug: z.string().trim().optional(),
|
||||
/** Domain, unter der die Kurz-URL entsteht. */
|
||||
domainId: z.string().trim().optional(),
|
||||
/** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */
|
||||
label: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
||||
@@ -137,25 +139,41 @@ export async function resolveQrRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
||||
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||
function alsDateiname(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replaceAll("ä", "ae")
|
||||
.replaceAll("ö", "oe")
|
||||
.replaceAll("ü", "ue")
|
||||
.replaceAll("ß", "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiname für den Download.
|
||||
*
|
||||
* Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man
|
||||
* die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet.
|
||||
*/
|
||||
export function downloadFilename(
|
||||
qrType: QrTypeModule,
|
||||
data: unknown,
|
||||
extension: string,
|
||||
label?: string | null,
|
||||
): string {
|
||||
const ausName = label ? alsDateiname(label) : "";
|
||||
if (ausName) return `qr-${ausName}.${extension}`;
|
||||
|
||||
let base = "qr-code";
|
||||
try {
|
||||
const summary = qrType
|
||||
.summary(data)
|
||||
.toLowerCase()
|
||||
.replaceAll("ä", "ae")
|
||||
.replaceAll("ö", "oe")
|
||||
.replaceAll("ü", "ue")
|
||||
.replaceAll("ß", "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
if (summary) base = `qr-${summary}`;
|
||||
.toLowerCase();
|
||||
const gesaeubert = alsDateiname(summary);
|
||||
if (gesaeubert) base = `qr-${gesaeubert}`;
|
||||
} catch {
|
||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export type InstanceSettings = {
|
||||
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
|
||||
editorsMaySetSlug: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: InstanceSettings = {
|
||||
editorsMaySetSlug: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
|
||||
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
|
||||
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
|
||||
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
|
||||
*/
|
||||
export async function getSettings(): Promise<InstanceSettings> {
|
||||
try {
|
||||
const zeile = await prisma.instanceSetting.findUnique({
|
||||
where: { id: "instance" },
|
||||
});
|
||||
return zeile
|
||||
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
|
||||
: DEFAULT_SETTINGS;
|
||||
} catch (error) {
|
||||
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
|
||||
// Im Zweifel gilt die zurückhaltendere Einstellung.
|
||||
console.error("Einstellungen konnten nicht geladen werden:", error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
update: Partial<InstanceSettings>,
|
||||
): Promise<InstanceSettings> {
|
||||
await prisma.instanceSetting.upsert({
|
||||
where: { id: "instance" },
|
||||
update,
|
||||
create: { id: "instance", ...update },
|
||||
});
|
||||
return getSettings();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import "server-only";
|
||||
|
||||
import type { QrCode } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
import { isValidSlug, normalizeSlug } from "@/lib/slug";
|
||||
|
||||
/**
|
||||
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
|
||||
*
|
||||
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
|
||||
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
|
||||
* geht über den exakten Index. Erst wenn er nichts findet, wird
|
||||
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
|
||||
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
|
||||
*/
|
||||
export async function findCodeBySlug(
|
||||
domainId: string,
|
||||
slug: string,
|
||||
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
|
||||
if (!isValidSlug(slug)) return null;
|
||||
|
||||
const exakt = await prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId, slug } },
|
||||
});
|
||||
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
|
||||
|
||||
const normalisiert = normalizeSlug(slug);
|
||||
|
||||
const egalGeschrieben = await prisma.qrCode.findFirst({
|
||||
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||
});
|
||||
if (egalGeschrieben) {
|
||||
return { code: egalGeschrieben, ueberFrueheAdresse: false };
|
||||
}
|
||||
|
||||
const alias = await prisma.qrCodeAlias.findFirst({
|
||||
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||
include: { qrCode: true },
|
||||
});
|
||||
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist dieser Kurz-Link auf dieser Domain noch frei?
|
||||
*
|
||||
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
|
||||
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
|
||||
* wegnehmen.
|
||||
*/
|
||||
export async function assertSlugAvailable(
|
||||
domainId: string,
|
||||
slug: string,
|
||||
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
|
||||
eigenerCodeId?: string,
|
||||
): Promise<void> {
|
||||
const normalisiert = normalizeSlug(slug);
|
||||
|
||||
const belegtVomCode = await prisma.qrCode.findFirst({
|
||||
where: {
|
||||
domainId,
|
||||
slug: { equals: normalisiert, mode: "insensitive" },
|
||||
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (belegtVomCode) {
|
||||
throw new QrRequestError(
|
||||
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
|
||||
where: {
|
||||
domainId,
|
||||
slug: { equals: normalisiert, mode: "insensitive" },
|
||||
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (belegtAlsAlias) {
|
||||
throw new QrRequestError(
|
||||
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
|
||||
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||