Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2d222e86 | ||
|
|
fcc63ff69a | ||
|
|
1f4753b753 | ||
|
|
cdda7df17a | ||
|
|
c6891f66ff | ||
|
|
ff28352907 | ||
|
|
830d6d7379 | ||
|
|
a4bf3c77b6 | ||
|
|
43a0504f38 | ||
|
|
14b9ffc22f | ||
|
|
87ffdcae04 | ||
|
|
a7af9c9290 | ||
|
|
101f664cef | ||
|
|
3cb5421a20 | ||
|
|
c06f87e6d9 | ||
|
|
69c34c4f0c | ||
|
|
60f46bcdb5 | ||
|
|
142536140c | ||
|
|
1cf1df54df | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f | ||
|
|
7f67a35218 | ||
|
|
eeaf3820b7 | ||
|
|
46e7eb0b59 | ||
|
|
c38b27ca85 | ||
|
|
fcf41f56fe | ||
|
|
b828f38ca5 | ||
|
|
4046b72626 | ||
|
|
d0057f038c | ||
|
|
0ec532296d | ||
|
|
75252b32b4 | ||
|
|
1064915d37 | ||
|
|
7fcdcba694 | ||
|
|
055d8caaa0 | ||
|
|
e4ff55a6c9 | ||
|
|
c8d60d8006 | ||
|
|
e296b1e6c2 | ||
|
|
fdf6452b35 | ||
|
|
e627f7c372 | ||
|
|
b3d386b0d2 | ||
|
|
cdda55f9b0 | ||
|
|
6b2baa66c5 | ||
|
|
2a80f15e20 | ||
|
|
f1cdb3da13 | ||
|
|
94a373ff48 | ||
|
|
7f7cb6fa20 | ||
|
|
c6da20f419 |
@@ -13,6 +13,59 @@ BASE_URL=http://localhost:3000
|
|||||||
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
|
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
|
# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
|
||||||
|
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
|
||||||
|
# Administrator an.
|
||||||
|
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
|
||||||
|
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
|
||||||
|
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||||
|
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||||
|
# 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 ------------------------------------------------------------
|
# --- PostgreSQL ------------------------------------------------------------
|
||||||
POSTGRES_USER=qrcode
|
POSTGRES_USER=qrcode
|
||||||
POSTGRES_PASSWORD=bitte-aendern
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
|
||||||
|
Anwendung unter **Version und Änderungen**, erreichbar über die
|
||||||
|
Versionsnummer in der Fußzeile.
|
||||||
|
|
||||||
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
|
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||||
|
|
||||||
|
## 1.7.1 — 21.08.2026
|
||||||
|
|
||||||
|
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
|
||||||
|
|
||||||
|
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
|
||||||
|
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
|
||||||
|
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
|
||||||
|
|
||||||
|
## 1.7.0 — 21.08.2026
|
||||||
|
|
||||||
|
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
|
||||||
|
|
||||||
|
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
|
||||||
|
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
|
||||||
|
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
|
||||||
|
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
|
||||||
|
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
|
||||||
|
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
|
||||||
|
|
||||||
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|
||||||
|
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
|
||||||
|
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
|
||||||
|
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
|
||||||
|
|
||||||
|
## 1.5.0 — 20.08.2026
|
||||||
|
|
||||||
|
Statische Codes lassen sich benennen und ablegen.
|
||||||
|
|
||||||
|
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
|
||||||
|
- Der Name bestimmt den Dateinamen beim Herunterladen.
|
||||||
|
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
|
||||||
|
|
||||||
|
## 1.4.0 — 17.08.2026
|
||||||
|
|
||||||
|
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
|
||||||
|
|
||||||
|
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
|
||||||
|
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
|
||||||
|
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
|
||||||
|
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
|
||||||
|
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
|
||||||
|
|
||||||
|
## 1.3.0 — 16.08.2026
|
||||||
|
|
||||||
|
Eigenes Erscheinungsbild und ein Dark Mode.
|
||||||
|
|
||||||
|
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
|
||||||
|
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
|
||||||
|
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
|
||||||
|
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
|
||||||
|
|
||||||
|
## 1.2.0 — 15.08.2026
|
||||||
|
|
||||||
|
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
|
||||||
|
|
||||||
|
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
|
||||||
|
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
|
||||||
|
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
|
||||||
|
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
|
||||||
|
|
||||||
|
## 1.1.0 — 14.08.2026
|
||||||
|
|
||||||
|
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
|
||||||
|
|
||||||
|
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
|
||||||
|
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
|
||||||
|
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
|
||||||
|
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
|
||||||
|
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
|
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
|
||||||
|
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
|
||||||
|
|
||||||
|
## 1.0.0 — 13.08.2026
|
||||||
|
|
||||||
|
Erste lauffähige Fassung.
|
||||||
|
|
||||||
|
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
|
||||||
|
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
|
||||||
|
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
|
||||||
|
- Download als PNG und SVG, serverseitig gerendert.
|
||||||
|
- Update-Skript für Linux, macOS und Windows.
|
||||||
@@ -3,47 +3,427 @@
|
|||||||
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
|
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
|
||||||
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
|
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
|
||||||
|
|
||||||
**Version 1** unterstützt den QR-Typ **Website/URL**, statisch wie dynamisch.
|
Umgesetzt sind **alle 15 QR-Typen**. Welche davon in der Oberfläche erscheinen,
|
||||||
Die übrigen 14 Typen sind bereits in der Oberfläche sichtbar und für Version 2
|
entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||||
vorbereitet.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||||
|
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||||
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
|
||||||
|
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
|
||||||
|
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
|
||||||
|
- **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 — mit Kurzhilfe
|
||||||
|
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
|
||||||
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
|
||||||
|
für die ganze Installation oder je Gruppe.
|
||||||
|
- **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.
|
||||||
|
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
|
||||||
|
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
|
||||||
|
- **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
|
||||||
|
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
|
||||||
|
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
|
||||||
|
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||||
|
Härtungen und was bewusst offen bleibt
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Inhalt
|
## Inhalt
|
||||||
|
|
||||||
- [Schnellstart](#schnellstart)
|
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
||||||
|
- [Hinter einem Reverse Proxy (Nginx Proxy Manager)](#hinter-einem-reverse-proxy-nginx-proxy-manager)
|
||||||
|
- [Wenn etwas nicht läuft](#wenn-etwas-nicht-läuft)
|
||||||
- [Aktualisieren](#aktualisieren)
|
- [Aktualisieren](#aktualisieren)
|
||||||
- [Konfiguration](#konfiguration)
|
- [Konfiguration](#konfiguration)
|
||||||
- [Bedienung](#bedienung)
|
- [Bedienung](#bedienung)
|
||||||
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
|
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
|
||||||
- [Technischer Aufbau](#technischer-aufbau)
|
- [Technischer Aufbau](#technischer-aufbau)
|
||||||
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [Ausblick Version 2](#ausblick-version-2)
|
- [Einen weiteren Typ ergänzen](#einen-weiteren-typ-ergänzen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schnellstart
|
## Schritt für Schritt zum laufenden Stack
|
||||||
|
|
||||||
Voraussetzung: Docker mit Docker Compose.
|
Vom leeren Server bis zum ersten QR-Code. Alle Befehle laufen auf dem
|
||||||
|
Rechner, auf dem die Anwendung später läuft.
|
||||||
|
|
||||||
|
### Schritt 1 — Voraussetzungen prüfen
|
||||||
|
|
||||||
|
Gebraucht werden Docker mit Compose v2 und Git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose version # erwartet: Docker Compose version v2.x oder höher
|
||||||
|
git --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Kommt `docker: command not found`, fehlt Docker — die
|
||||||
|
[offizielle Anleitung](https://docs.docker.com/engine/install/) installiert
|
||||||
|
Engine und Compose in einem Rutsch.
|
||||||
|
|
||||||
|
### Schritt 2 — Projekt holen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.wilde-server.de/florian.wilde/qrcode-generator.git
|
||||||
|
cd qrcode-generator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3 — Konfiguration anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Werte **musst** du anfassen:
|
||||||
|
|
||||||
|
| Wert | Was eintragen |
|
||||||
|
| --- | --- |
|
||||||
|
| `POSTGRES_PASSWORD` | Ein eigenes Passwort. Es steht nur in dieser Datei und wird nirgends sonst gebraucht. |
|
||||||
|
| `DATABASE_URL` | Dasselbe Passwort noch einmal — es steckt mitten im Verbindungsstring. |
|
||||||
|
|
||||||
|
Und einen solltest du bewusst setzen:
|
||||||
|
|
||||||
|
| Wert | Was eintragen |
|
||||||
|
| --- | --- |
|
||||||
|
| `BASE_URL` | Die Adresse, unter der die Anwendung **von außen** erreichbar ist. |
|
||||||
|
|
||||||
|
Beispiel für ein eigenes Passwort `MeinGeheimes123`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTGRES_PASSWORD=MeinGeheimes123
|
||||||
|
DATABASE_URL=postgresql://qrcode:MeinGeheimes123@db:5432/qrcode?schema=public
|
||||||
|
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. 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.
|
||||||
|
|
||||||
|
### Schritt 4 — Starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # anschließend POSTGRES_PASSWORD und BASE_URL anpassen
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach ist die Anwendung unter <http://localhost:3000> erreichbar. Die
|
Der erste Durchlauf baut das Image und dauert je nach Maschine **5 bis 15
|
||||||
Datenbankmigrationen laufen beim Start des App-Containers automatisch — es
|
Minuten**. Spätere Starts brauchen nur wenige Sekunden.
|
||||||
sind keine weiteren Schritte nötig.
|
|
||||||
|
|
||||||
Nützliche Befehle:
|
### Schritt 5 — Prüfen, ob alles läuft
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f app # Protokoll der Anwendung
|
docker compose ps
|
||||||
docker compose ps # Status beider Container
|
|
||||||
docker compose down # Stack stoppen (Daten bleiben erhalten)
|
|
||||||
docker compose down -v # Stack stoppen und Datenbank löschen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Erwartet werden zwei Container, `db` mit dem Zusatz `(healthy)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME SERVICE STATUS
|
||||||
|
qrcode-generator-app-1 app Up 30 seconds
|
||||||
|
qrcode-generator-db-1 db Up 45 seconds (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
Und im Protokoll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs app
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Datenbankmigrationen werden angewendet …
|
||||||
|
All migrations have been successfully applied.
|
||||||
|
→ Anwendung wird gestartet …
|
||||||
|
▲ Next.js 16.3.0
|
||||||
|
✓ Ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Datenbank richtet sich beim ersten Start selbst ein — es gibt keinen
|
||||||
|
Extraschritt dafür.
|
||||||
|
|
||||||
|
### Schritt 6 — Aufrufen
|
||||||
|
|
||||||
|
Im Browser die Adresse öffnen, die du als `BASE_URL` eingetragen hast, zum
|
||||||
|
Beispiel <http://192.168.1.50:3000>. Es erscheint „Was möchtest du
|
||||||
|
erstellen?“ mit den Typ-Kacheln.
|
||||||
|
|
||||||
|
### Schritt 7 — Ersten Code erstellen
|
||||||
|
|
||||||
|
1. Kachel **Website** anklicken.
|
||||||
|
2. Adresse eingeben, etwa `https://example.com` — die Vorschau erscheint
|
||||||
|
sofort.
|
||||||
|
3. **PNG** oder **SVG** herunterladen. Fertig.
|
||||||
|
|
||||||
|
Für einen Code, dessen Ziel du später ändern kannst:
|
||||||
|
|
||||||
|
1. Unter „Art des Codes“ auf **Dynamisch** wechseln.
|
||||||
|
2. Optional einen Namen vergeben, damit du ihn wiederfindest.
|
||||||
|
3. **Dynamischen Code speichern** — erst dabei entsteht die Kurz-URL, deshalb
|
||||||
|
ist der Download bis dahin gesperrt.
|
||||||
|
4. Danach herunterladen. Unter **Meine Codes** lässt sich das Ziel jederzeit
|
||||||
|
ändern, ohne den gedruckten Code anzufassen.
|
||||||
|
|
||||||
|
### Schritt 8 — Aufräumen, was du nicht brauchst
|
||||||
|
|
||||||
|
Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — 15
|
||||||
|
Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Ausgeblendete
|
||||||
|
Typen verschwinden aus der Auswahl; bereits erstellte Codes dieses Typs
|
||||||
|
funktionieren unverändert weiter.
|
||||||
|
|
||||||
|
### Die wichtigsten Befehle im Betrieb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app # Protokoll mitlesen (Abbruch mit Strg+C)
|
||||||
|
docker compose ps # Status beider Container
|
||||||
|
docker compose restart app # Anwendung neu starten
|
||||||
|
docker compose down # Stack stoppen — die Daten bleiben erhalten
|
||||||
|
docker compose up -d # wieder starten
|
||||||
|
docker compose down -v # Stack stoppen UND die Datenbank löschen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinter einem Reverse Proxy (Nginx Proxy Manager)
|
||||||
|
|
||||||
|
Soll die Anwendung unter einer eigenen Domain mit HTTPS laufen, übernimmt der
|
||||||
|
Proxy die Zertifikate; die Anwendung selbst bleibt unverändert.
|
||||||
|
|
||||||
|
**1. Port nur lokal veröffentlichen.** Wenn Proxy und Anwendung auf demselben
|
||||||
|
Rechner laufen, muss Port 3000 nicht nach außen offen sein. In der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_PORT=3000
|
||||||
|
BASE_URL=https://qr.deine-domain.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Im Nginx Proxy Manager** einen *Proxy Host* anlegen:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| Domain Names | `qr.deine-domain.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||||
|
| Forward Port | `3000` |
|
||||||
|
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
|
||||||
|
|
||||||
|
Läuft der Proxy als Container im selben Docker-Netz wie dieser Stack, kann
|
||||||
|
statt der IP auch der Servicename `app` mit Port `3000` stehen — dann muss
|
||||||
|
das Port-Mapping gar nicht nach außen offen sein.
|
||||||
|
|
||||||
|
**3. `BASE_URL` auf die öffentliche Adresse setzen** — mit `https://` und ohne
|
||||||
|
Port. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> `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
|
||||||
|
|
||||||
|
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
|
||||||
|
lege sie unter **Verwaltung → Domains** an und richte im Proxy für jede einen
|
||||||
|
eigenen Proxy Host auf dieselbe Anwendung ein.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Nachprüfen, ob eine Domain wirklich hierher führt
|
||||||
|
|
||||||
|
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
|
||||||
|
Weg der Scanner nach und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
|
||||||
|
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
|
||||||
|
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
|
||||||
|
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
|
||||||
|
irgendwohin“ von „die Domain zeigt hierher“.
|
||||||
|
|
||||||
|
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
|
||||||
|
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
|
||||||
|
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
|
||||||
|
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
|
||||||
|
> Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
|
Zwei Dinge, die daraus folgen:
|
||||||
|
|
||||||
|
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
|
||||||
|
Slug darf auf zwei Domains liegen und zeigt dort auf verschiedene Ziele.
|
||||||
|
- Eine Domain kann **mehreren Gruppen** dienen. Das heißt aber nicht, dass
|
||||||
|
diese die Codes der jeweils anderen sehen — die Übersicht bleibt strikt
|
||||||
|
nach Gruppe gefiltert.
|
||||||
|
|
||||||
|
`BASE_URL` bleibt dabei wichtig: sie liefert die Standard-Domain für Aufrufe
|
||||||
|
über eine nicht registrierte Adresse (etwa direkt über die IP im LAN) und
|
||||||
|
bestimmt, ob Kurz-URLs mit `http` oder `https` gebildet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht läuft
|
||||||
|
|
||||||
|
**`POSTGRES_PASSWORD fehlt — bitte .env aus .env.example anlegen`**
|
||||||
|
Die `.env` fehlt oder ist leer. Schritt 3 nachholen.
|
||||||
|
|
||||||
|
**`ports are not available: ... bind: address already in use`**
|
||||||
|
Port 3000 ist belegt. Entweder den anderen Dienst beenden oder in der `.env`
|
||||||
|
einen anderen Port setzen, z. B. `APP_PORT=3100`, dann `docker compose up -d`.
|
||||||
|
|
||||||
|
**Der Container `db` bleibt `unhealthy` oder startet immer neu**
|
||||||
|
Meist ein Datenverzeichnis aus einer älteren PostgreSQL-Version. Protokoll
|
||||||
|
ansehen mit `docker compose logs db`. Wenn dort von einem inkompatiblen
|
||||||
|
Datenlayout die Rede ist und du die Daten nicht brauchst:
|
||||||
|
`docker compose down -v` — Achtung, das löscht die Datenbank.
|
||||||
|
|
||||||
|
**Der QR-Code führt auf dem Handy ins Leere**
|
||||||
|
`BASE_URL` zeigt auf `localhost`. Auf die LAN-Adresse oder Domain ändern,
|
||||||
|
`docker compose up -d`, und die betroffenen dynamischen Codes neu
|
||||||
|
herunterladen. Statische Codes sind davon nicht betroffen.
|
||||||
|
|
||||||
|
**Die Anwendung antwortet nicht, `docker compose ps` zeigt sie aber als `Up`**
|
||||||
|
`docker compose logs app` ansehen. Kommt dort ein Datenbankfehler, stimmt
|
||||||
|
meist das Passwort in `DATABASE_URL` nicht mit `POSTGRES_PASSWORD` überein —
|
||||||
|
beide Werte müssen identisch sein.
|
||||||
|
|
||||||
|
**Nach einem Update fehlt etwas oder die Seite ist leer**
|
||||||
|
`docker compose up -d --build` erzwingt einen Neubau. Hilft das nicht:
|
||||||
|
`docker compose down && docker compose up -d --build`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aktualisieren
|
## Aktualisieren
|
||||||
@@ -102,6 +482,7 @@ Alle Einstellungen stehen in der `.env` im Projektverzeichnis (Vorlage:
|
|||||||
| ------------------- | ------------------------------------------------------------- | ------------------------ |
|
| ------------------- | ------------------------------------------------------------- | ------------------------ |
|
||||||
| `BASE_URL` | Öffentliche Adresse der App — landet in dynamischen QR-Codes | `http://localhost:3000` |
|
| `BASE_URL` | Öffentliche Adresse der App — landet in dynamischen QR-Codes | `http://localhost:3000` |
|
||||||
| `APP_PORT` | Host-Port, auf dem die App veröffentlicht wird | `3000` |
|
| `APP_PORT` | Host-Port, auf dem die App veröffentlicht wird | `3000` |
|
||||||
|
| `AUTH_ENABLED` | Anmeldung mit Benutzern, Gruppen und Rollen | `false` |
|
||||||
| `DATABASE_URL` | Verbindungsstring der App zur Datenbank | — |
|
| `DATABASE_URL` | Verbindungsstring der App zur Datenbank | — |
|
||||||
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
|
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
|
||||||
| `POSTGRES_PASSWORD` | Datenbankpasswort (**muss gesetzt sein**) | — |
|
| `POSTGRES_PASSWORD` | Datenbankpasswort (**muss gesetzt sein**) | — |
|
||||||
@@ -137,9 +518,52 @@ werden — der Hostname darin bleibt `db` (der Servicename aus
|
|||||||
|
|
||||||
### 1. Typ wählen
|
### 1. Typ wählen
|
||||||
|
|
||||||
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid aller
|
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der
|
||||||
15 QR-Typen. In Version 1 ist **Website** aktiv (Badge „Beliebt“); die übrigen
|
freigegebenen QR-Typen. Alle 15 Typen sind umgesetzt.
|
||||||
Kacheln sind sichtbar, aber deaktiviert (Badge „Bald verfügbar“).
|
|
||||||
|
| Typ | Kodiert als | Statisch | Dynamisch |
|
||||||
|
| --- | --- | :-: | :-: |
|
||||||
|
| Website | die URL selbst | ✓ | ✓ |
|
||||||
|
| E-Mail | `mailto:` mit Betreff und Text | ✓ | ✓ |
|
||||||
|
| SMS | `SMSTO:` | ✓ | ✓ |
|
||||||
|
| WhatsApp | `https://wa.me/…` | ✓ | ✓ |
|
||||||
|
| Standort | `geo:` — dynamisch über OpenStreetMap | ✓ | ✓ |
|
||||||
|
| Video | die Videoadresse — dynamisch mit Player-Seite | ✓ | ✓ |
|
||||||
|
| vCard | vCard 3.0 | ✓ | — |
|
||||||
|
| WLAN | `WIFI:` | ✓ | — |
|
||||||
|
| App-Link | Weiterleitung je nach Gerät | — | ✓ |
|
||||||
|
| Zahlung | GiroCode (EPC069-12) oder Zahlungslink | ✓ | nur Link |
|
||||||
|
| Mehrere Links | eigene Seite mit Linkliste | — | ✓ |
|
||||||
|
| Social Media | eigene Seite mit den Profilen | — | ✓ |
|
||||||
|
| PDF | hinterlegte Datei auf eigener Seite | — | ✓ |
|
||||||
|
| Bild | hinterlegte Bilder auf eigener Seite | — | ✓ |
|
||||||
|
| Feedback | Bewertungsformular auf eigener Seite | — | ✓ |
|
||||||
|
|
||||||
|
Warum manche Typen nur eine Betriebsart können:
|
||||||
|
|
||||||
|
- **vCard und WLAN** werden vom Gerät direkt ausgewertet — die Kamera legt
|
||||||
|
einen Kontakt an oder richtet das Netz ein. Über eine Kurz-URL würde
|
||||||
|
stattdessen nur ein Browser aufgehen.
|
||||||
|
- **App-Link** kann nicht statisch sein: welches Ziel richtig ist, steht erst
|
||||||
|
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus und schickt
|
||||||
|
iOS in den App Store, Android zu Google Play und alles Übrige auf die
|
||||||
|
Ausweichadresse.
|
||||||
|
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
|
||||||
|
dynamischer Code funktioniert daher nur die Variante „Zahlungslink“ — die
|
||||||
|
Anwendung weist das beim Speichern ausdrücklich ab, statt einen Code
|
||||||
|
entstehen zu lassen, der beim Scannen ins Leere führt.
|
||||||
|
- **Mehrere Links, Social Media, PDF, Bild und Feedback** brauchen eine Seite,
|
||||||
|
die die Anwendung selbst ausliefert — eine Linkliste oder ein Formular passt
|
||||||
|
nicht in einen einzelnen Code. Diese Seiten liegen unter `/s/{slug}`; der
|
||||||
|
gedruckte Code zeigt weiterhin auf `/r/{slug}` und wird dorthin
|
||||||
|
weitergeleitet. Der Scan-Zähler zählt dabei den Aufruf des Codes, nicht das
|
||||||
|
Neuladen der Seite.
|
||||||
|
|
||||||
|
> **Zum WLAN-Code:** Das Passwort steht im Klartext im Code — wer ihn scannt
|
||||||
|
> oder abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht ein
|
||||||
|
> Netzwerkname oder Passwort ausschließlich aus Hex-Ziffern (etwa `abc123`),
|
||||||
|
> setzt die Anwendung ihn wie in der Spezifikation vorgesehen in
|
||||||
|
> Anführungszeichen — sonst würden manche Geräte den Wert als Hexzahl deuten.
|
||||||
|
|
||||||
### 2. Statisch oder dynamisch
|
### 2. Statisch oder dynamisch
|
||||||
|
|
||||||
@@ -176,7 +600,53 @@ exakt das, was auch heruntergeladen wird.
|
|||||||
**PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung
|
**PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung
|
||||||
(Plakate, Schilder, Druckvorstufe).
|
(Plakate, Schilder, Druckvorstufe).
|
||||||
|
|
||||||
### 5. Meine Codes
|
### 5. Verwaltung: welche Typen erscheinen
|
||||||
|
|
||||||
|
Unter **Verwaltung → QR-Typen** lässt sich jeder der 15 Typen einzeln
|
||||||
|
ein- und ausblenden. Ausgeblendete Typen verschwinden vollständig von der
|
||||||
|
Startseite und lassen sich nicht mehr neu anlegen — praktisch, um die noch
|
||||||
|
nicht umgesetzten Typen gar nicht erst zu zeigen.
|
||||||
|
|
||||||
|
Zwei Eigenschaften sind dabei zu unterscheiden:
|
||||||
|
|
||||||
|
| | Bedeutung | Woher |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
|
||||||
|
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
||||||
|
|
||||||
|
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
||||||
|
umgesetzt, erscheint als Kachel „Bald verfügbar“.
|
||||||
|
|
||||||
|
#### Freigabe je Gruppe
|
||||||
|
|
||||||
|
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
|
||||||
|
|
||||||
|
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
|
||||||
|
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
|
||||||
|
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
|
||||||
|
|
||||||
|
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
|
||||||
|
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
|
||||||
|
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
|
||||||
|
zu wissen, was er getan hat.
|
||||||
|
|
||||||
|
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||||
|
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
|
||||||
|
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
|
||||||
|
das noch einmal.
|
||||||
|
|
||||||
|
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
|
||||||
|
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
|
||||||
|
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
|
||||||
|
> nicht ungültig — gesperrt ist nur das Anlegen neuer Codes.
|
||||||
|
|
||||||
|
Ohne Eintrag in der Datenbank gilt ein Typ als freigegeben. Eine frische
|
||||||
|
Installation zeigt also alles, und Typen aus späteren Versionen erscheinen
|
||||||
|
automatisch.
|
||||||
|
|
||||||
|
### 6. Meine Codes
|
||||||
|
|
||||||
Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild,
|
Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild,
|
||||||
Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan.
|
Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan.
|
||||||
@@ -187,11 +657,221 @@ Dort lässt sich
|
|||||||
- der Code **löschen** (mit Bestätigungsdialog),
|
- der Code **löschen** (mit Bestätigungsdialog),
|
||||||
- das Bild erneut als PNG oder SVG **herunterladen**.
|
- das Bild erneut als PNG oder SVG **herunterladen**.
|
||||||
|
|
||||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
**Liste oder Karten.** Voreingestellt ist die **Liste** — eine Zeile je Code,
|
||||||
|
gedacht für viele Projekte nebeneinander. Über die beiden Schalter rechts neben
|
||||||
|
der Sortierung wechselst du zur **Kartenansicht** mit großer Vorschau. Die Wahl
|
||||||
|
merkt sich der Browser.
|
||||||
|
|
||||||
> Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine
|
Dazu kommen
|
||||||
> Benutzerverwaltung**. Wer die Anwendung erreicht, sieht und ändert alle
|
|
||||||
> Codes. Die App gehört daher nicht ungeschützt ins offene Internet.
|
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
||||||
|
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
||||||
|
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
|
||||||
|
- ein **Filter nach Art**, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen.
|
||||||
|
|
||||||
|
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
||||||
|
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
||||||
|
Kartenansicht.
|
||||||
|
|
||||||
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
|
||||||
|
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
|
||||||
|
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
|
||||||
|
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
|
||||||
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
|
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||||
|
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Eigene Seiten: Links, Profile, Dateien, Feedback
|
||||||
|
|
||||||
|
Fünf Typen haben kein fremdes Ziel, auf das sich weiterleiten ließe — sie
|
||||||
|
brauchen eine Seite. Die liefert die Anwendung selbst aus, unter
|
||||||
|
`{BASE_URL}/s/{slug}`:
|
||||||
|
|
||||||
|
| Typ | Was die Seite zeigt |
|
||||||
|
| --- | --- |
|
||||||
|
| Mehrere Links | Überschrift, kurzer Text, bis zu 20 beschriftete Links |
|
||||||
|
| Social Media | dieselbe Form, aber mit Netzwerk-Auswahl aus einem Katalog |
|
||||||
|
| PDF | das hinterlegte Dokument mit „Öffnen“ und „Herunterladen“ |
|
||||||
|
| Bild | ein Bild oder eine Galerie aus bis zu 10 Bildern mit Bildunterschriften |
|
||||||
|
| Feedback | ein Formular mit Sternen oder Gesichtern und optionalem Kommentar |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Der gedruckte Code zeigt weiterhin auf `/r/{slug}`; von dort geht es auf die
|
||||||
|
Seite. Der Scan-Zähler zählt dabei den Aufruf des Codes — ein Neuladen der
|
||||||
|
Seite zählt nicht mit.
|
||||||
|
|
||||||
|
**Bei Social Media** genügt als Eingabe 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.
|
||||||
|
|
||||||
|
**Beim Video-Typ** wird der Player von YouTube oder Vimeo erst nach einem Klick
|
||||||
|
geladen, und davor steht, dass dabei Daten an den Anbieter gehen. Ungefragt
|
||||||
|
einen Drittanbieter einzubinden wäre auf einer Seite, die Fremde nach einem
|
||||||
|
Scan zu sehen bekommen, nicht in Ordnung.
|
||||||
|
|
||||||
|
#### Hochgeladene 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.
|
||||||
|
|
||||||
|
- Die Grenze steht in der `.env` unter `MAX_UPLOAD_MB` (Voreinstellung 10).
|
||||||
|
- Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF. **SVG bewusst nicht** —
|
||||||
|
eine SVG-Datei darf Skripte enthalten und würde von derselben Adresse wie die
|
||||||
|
Anwendung ausgeliefert.
|
||||||
|
- Geprüft wird der **Inhalt**, nicht die Dateiendung. Eine Textdatei mit dem
|
||||||
|
Namen `harmlos.pdf` wird abgewiesen.
|
||||||
|
- Wird ein Code gelöscht, verschwindet seine Datei mit ihm. Eine ausgetauschte
|
||||||
|
Datei und eine hochgeladene, aber nie gespeicherte werden nach 24 Stunden
|
||||||
|
aufgeräumt.
|
||||||
|
|
||||||
|
#### Rückmeldungen
|
||||||
|
|
||||||
|
Ein Feedback-Eintrag besteht aus einer Note von 1 bis 5 und einem optionalen
|
||||||
|
Text. Gespeichert wird **kein Name, keine IP-Adresse, keine Gerätekennung** —
|
||||||
|
wer einen Aushang scannt und eine Note vergibt, bleibt anonym. 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.
|
||||||
|
|
||||||
|
> Ohne eingeschaltete Anmeldung sieht und ändert jeder, der die Anwendung
|
||||||
|
> erreicht, alle Codes. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt;
|
||||||
|
> ins offene Internet gehört die Anwendung dann nicht. Mit `AUTH_ENABLED=true`
|
||||||
|
> gibt es Benutzer, Gruppen und Rollen — siehe unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anmeldung, Gruppen und Rollen
|
||||||
|
|
||||||
|
Standardmäßig ist die Anmeldung **aus**: wer die Anwendung erreicht, darf
|
||||||
|
alles. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt. Sobald mehrere
|
||||||
|
Leute damit arbeiten, lässt sie sich einschalten.
|
||||||
|
|
||||||
|
### Einschalten
|
||||||
|
|
||||||
|
In der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim nächsten Aufruf erscheint die **Ersteinrichtung** und du legst den
|
||||||
|
ersten Administrator an. Diese Seite schließt sich, sobald es einen Benutzer
|
||||||
|
gibt — danach führt sie zur normalen Anmeldung.
|
||||||
|
|
||||||
|
> **Fehlt die Zeile in deiner `.env`?** Die `.env` wird von `git pull` nicht
|
||||||
|
> angefasst — sie gehört bewusst nicht ins Repository. Wurde sie vor
|
||||||
|
> Einführung der Anmeldung angelegt, fehlt `AUTH_ENABLED` darin. Einfach
|
||||||
|
> ergänzen; ohne die Zeile gilt `false`.
|
||||||
|
|
||||||
|
Ob es angekommen ist, zeigt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app env | grep AUTH_ENABLED
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Aussperren ist nicht möglich.** Der Schalter steht bewusst in der `.env`
|
||||||
|
> und nicht in der Oberfläche. Wer den Zugang verliert, setzt
|
||||||
|
> `AUTH_ENABLED=false`, startet neu und kommt wieder an alles heran.
|
||||||
|
|
||||||
|
Bestehende Codes gehen dabei nicht verloren: die Migration legt eine Gruppe
|
||||||
|
**Standard** an und ordnet ihnen diese zu.
|
||||||
|
|
||||||
|
### Die vier Ebenen
|
||||||
|
|
||||||
|
| Ebene | Darf |
|
||||||
|
| --- | --- |
|
||||||
|
| **Instanz-Administrator** | alles: Benutzer, Gruppen, QR-Typen und sämtliche Codes |
|
||||||
|
| **Administrator** einer Gruppe | alles in seiner Gruppe, einschließlich Mitglieder und Rollen |
|
||||||
|
| **Bearbeiter** | Codes der Gruppe anlegen, ändern, löschen |
|
||||||
|
| **Betrachter** | Codes der Gruppe ansehen und herunterladen — sonst nichts |
|
||||||
|
|
||||||
|
Ein Benutzer kann in mehreren Gruppen sein, mit unterschiedlicher Rolle je
|
||||||
|
Gruppe. Er sieht ausschließlich die Codes der Gruppen, in denen er Mitglied
|
||||||
|
ist.
|
||||||
|
|
||||||
|
| Aktion | Betrachter | Bearbeiter | Admin der Gruppe | Instanz-Admin |
|
||||||
|
| --- | :-: | :-: | :-: | :-: |
|
||||||
|
| Codes sehen, Bilder herunterladen | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Statische Codes erzeugen | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Dynamische Codes anlegen und ändern | – | ✓ | ✓ | ✓ |
|
||||||
|
| Codes löschen | – | ✓ | ✓ | ✓ |
|
||||||
|
| Mitglieder und Rollen der Gruppe | – | – | ✓ | ✓ |
|
||||||
|
| Gruppen und Benutzer anlegen | – | – | – | ✓ |
|
||||||
|
| QR-Typen freigeben | – | – | – | ✓ |
|
||||||
|
|
||||||
|
### Was unabhängig von der Anmeldung bleibt
|
||||||
|
|
||||||
|
Die Weiterleitung unter `/r/{slug}` ist **immer öffentlich** — sonst könnte
|
||||||
|
niemand einen gedruckten Code scannen. Sie verrät nur das Ziel, nicht die
|
||||||
|
Verwaltung.
|
||||||
|
|
||||||
|
### Benutzer verwalten
|
||||||
|
|
||||||
|
Unter **Verwaltung → Benutzer**: Konten anlegen, Passwörter setzen, Konten
|
||||||
|
deaktivieren oder löschen.
|
||||||
|
|
||||||
|
- Passwörter werden mit **scrypt** gespeichert (Node-Standardbibliothek, kein
|
||||||
|
natives Modul — dadurch keine Abhängigkeit, die zur Container-Plattform
|
||||||
|
passen muss).
|
||||||
|
- Ein neues Passwort oder das Deaktivieren beendet **alle offenen Sitzungen**
|
||||||
|
des Betroffenen sofort.
|
||||||
|
- Nach acht Fehlversuchen ist ein Konto 15 Minuten gesperrt; ein Administrator
|
||||||
|
kann die Sperre vorzeitig aufheben.
|
||||||
|
- Der **letzte aktive Administrator** kann sich weder selbst deaktivieren noch
|
||||||
|
sich die Rechte entziehen noch gelöscht werden.
|
||||||
|
- Passwörter kann nur ein Administrator setzen. Einen Versand per E-Mail gibt
|
||||||
|
es bewusst nicht — das spart einen SMTP-Zugang.
|
||||||
|
|
||||||
|
### Gruppen verwalten
|
||||||
|
|
||||||
|
Unter **Verwaltung → Gruppen**: Gruppen anlegen und Mitglieder mit Rollen
|
||||||
|
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)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,6 +1020,10 @@ deutschen Meldung.
|
|||||||
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
||||||
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
||||||
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
||||||
|
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
||||||
|
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
||||||
|
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
|
||||||
|
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
@@ -357,25 +1041,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
|
Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er
|
||||||
Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN und
|
aus:
|
||||||
Feedback.
|
|
||||||
|
|
||||||
Die Architektur ist darauf ausgelegt — ein neuer Typ besteht aus:
|
|
||||||
|
|
||||||
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
|
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
|
||||||
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
|
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
|
||||||
3. **je einem Eintrag** in `src/lib/qr/registry.ts` (ersetzt dort den
|
3. **je einem Eintrag** in `src/lib/qr/registry.ts` und in
|
||||||
`defineComingSoonType`-Platzhalter) und in
|
`src/components/qr-forms/index.ts`,
|
||||||
`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,
|
Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung,
|
||||||
Download, Gestaltung, Speicherung dynamischer Codes und die Weiterleitung
|
Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen
|
||||||
arbeiten für alle Typen gleich.
|
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
|
Für einen angekündigten, aber noch nicht gebauten Typ gibt es
|
||||||
„Mehrere Links“ oder „Feedback“), setzen dafür `redirectTarget` auf eine
|
`defineComingSoonType`: Er erscheint als Kachel mit dem Badge „Bald
|
||||||
interne Adresse — auch dafür ist keine Änderung am Datenmodell nötig, weil
|
verfügbar“ und lässt sich nicht öffnen.
|
||||||
`data` als JSONB gespeichert wird.
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -28,9 +28,29 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
# Achtung beim Ergänzen: Compose liest die .env nur, um Platzhalter wie
|
||||||
|
# ${BASE_URL} zu ersetzen. In den Container gelangt ausschließlich, was
|
||||||
|
# hier aufgeführt ist — eine neue Variable in der .env allein bleibt
|
||||||
|
# wirkungslos.
|
||||||
|
#
|
||||||
# Öffentliche Adresse der App — landet in jedem dynamischen QR-Code.
|
# Öffentliche Adresse der App — landet in jedem dynamischen QR-Code.
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
# 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:
|
ports:
|
||||||
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### Ein Logo in die Mitte setzen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
|
||||||
|
dann fragt die Anwendung, woher das Bild kommen soll:
|
||||||
|
|
||||||
|
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
|
||||||
|
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
|
||||||
|
überhaupt eines liegt.
|
||||||
|
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
|
||||||
|
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
|
||||||
|
|
||||||
|
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
|
||||||
|
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
|
||||||
|
der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
|
||||||
|
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
|
||||||
|
Auswahl; heimlich geschieht es nicht.
|
||||||
|
|
||||||
|
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
|
||||||
|
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
|
||||||
|
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
|
||||||
|
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
|
||||||
|
> kosten eine halbe Minute und ersparen eine Neuauflage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meine Codes
|
||||||
|
|
||||||
|
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
|
||||||
|
hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
|
||||||
|
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
|
||||||
|

|
||||||
|
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
|
||||||
|
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Statische Codes ablegen
|
||||||
|
|
||||||
|
Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
|
||||||
|
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
|
||||||
|
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
|
||||||
|
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
|
||||||
|
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
|
||||||
|
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
|
||||||
|
Hausfarben gilt hier als Dublette.
|
||||||
|
|
||||||
|
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
|
||||||
|
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Auswahl | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
|
||||||
|
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
|
||||||
|
|
||||||
|
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
|
||||||
|
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
|
||||||
|
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr.
|
||||||
|
|
||||||
|
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
|
||||||
|
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
|
||||||
|
Gruppe.
|
||||||
|
|
||||||
|
### 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. Was dafür einzurichten ist, steht nicht nur im README: Der
|
||||||
|
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
|
||||||
|
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
|
||||||
|
Registrieren, Gruppenzuteilung, Nachprüfen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
|
||||||
|
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Schritt | Was er sagt |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | ob der Name auflöst, und auf welche Adressen |
|
||||||
|
| Zertifikat | gültig bis wann, von wem ausgestellt |
|
||||||
|
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
|
||||||
|
| Ziel | ob dahinter **diese** Installation steht |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
|
||||||
|
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
|
||||||
|
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
|
||||||
|
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: 117 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 106 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: 47 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,656 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
|
||||||
|
identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
|
||||||
|
wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
|
||||||
|
keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
|
||||||
|
|
||||||
|
Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
|
||||||
|
Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
|
||||||
|
genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
|
||||||
|
bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
|
||||||
|
sobald zwei Kampagnen getrennt gezählt werden sollen.
|
||||||
|
|
||||||
|
### Ein Logo in der Mitte
|
||||||
|
|
||||||
|
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
|
||||||
|
Zur Wahl stehen zwei Quellen:
|
||||||
|
|
||||||
|
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
|
||||||
|
genügt, kein Upload je Code;
|
||||||
|
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
|
||||||
|
|
||||||
|
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
|
||||||
|
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
|
||||||
|
Fläche in der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
|
||||||
|
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
|
||||||
|
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
|
||||||
|
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
|
||||||
|
nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
|
||||||
|
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
|
||||||
|
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
|
||||||
|
|
||||||
|
### 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 die dynamischen Codes und die statischen, die abgelegt wurden. Ein
|
||||||
|
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
|
||||||
|
„statisch“ und zählt keine Scans.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
|
||||||
|
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
|
||||||
|
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
|
||||||
|
Scans, die Spalte wäre eine Reihe aus Strichen.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Kurzhilfe zur Einrichtung
|
||||||
|
|
||||||
|
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
|
||||||
|
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
|
||||||
|
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
|
||||||
|
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
|
||||||
|
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
|
||||||
|
|
||||||
|
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
|
||||||
|
Einrichten und danach nie wieder.
|
||||||
|
|
||||||
|
### Prüfen, ob eine Domain hierher führt
|
||||||
|
|
||||||
|
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
|
||||||
|
und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
|
||||||
|
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
|
||||||
|
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
|
||||||
|
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
|
||||||
|
Datenbank und verlässt den Server nie.
|
||||||
|
|
||||||
|
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
|
||||||
|
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
|
||||||
|
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
|
||||||
|
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
|
||||||
|
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
|
||||||
|
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
|
||||||
|
dass die Domain von außen erreichbar ist.
|
||||||
|
|
||||||
|
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
|
||||||
|
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
|
||||||
|
beliebige Adressen abklopfen ließen. Näheres in
|
||||||
|
[sicherheit.md](sicherheit.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 — für alle oder je Gruppe |
|
||||||
|
| **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.
|
||||||
|
|
||||||
|
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
|
||||||
|
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
|
||||||
|
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
|
||||||
|
folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
|
||||||
|
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
|
||||||
|
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
|
||||||
|
abgeschaltet“ keine verlässliche Aussage mehr.
|
||||||
|
|
||||||
|
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||||
|
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
|
||||||
|
beim Anlegen anhand der gewählten Gruppe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version und Änderungen
|
||||||
|
|
||||||
|
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
|
||||||
|
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
|
||||||
|
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
|
||||||
|
Steht dort noch die alte Nummer, ist er es nicht.
|
||||||
|
|
||||||
|
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
|
||||||
|
Daraus entstehen sowohl die Seite in der Anwendung als auch die
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
|
||||||
|
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
|
||||||
|
einen Git-Tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,150 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
Der vollständige Verlauf mit Versionsnummern steht im
|
||||||
|
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
|
||||||
|
**Version und Änderungen**, erreichbar über die Versionsnummer in der
|
||||||
|
Fußzeile.
|
||||||
|
|
||||||
|
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||||
|
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||||
|
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||||
|
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
|
||||||
|
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
|
||||||
|
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
|
||||||
|
die Kurzhilfe zu den Domains.
|
||||||
|
|
||||||
|
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
|
||||||
|
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Als Nächstes
|
||||||
|
|
||||||
|
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||||
|
Person, die den Code kennt.
|
||||||
|
|
||||||
|
### 1. 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“.
|
||||||
|
|
||||||
|
### 2. 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.
|
||||||
|
|
||||||
|
### 3. 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.
|
||||||
|
|
||||||
|
### 4. Gestaltung eines gespeicherten Codes ändern · klein
|
||||||
|
|
||||||
|
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
|
||||||
|
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
|
||||||
|
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
|
||||||
|
|
||||||
|
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
|
||||||
|
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
|
||||||
|
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
|
||||||
|
Bearbeiten-Dialog.
|
||||||
|
|
||||||
|
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
|
||||||
|
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,426 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
|
||||||
|
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
|
||||||
|
* selbst unter der eingetragenen Adresse nicht erreicht.
|
||||||
|
* - 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";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt eine Seite auf.
|
||||||
|
*
|
||||||
|
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
|
||||||
|
* bei denen die ganze Seite mehr verdeckt als zeigt.
|
||||||
|
*/
|
||||||
|
async function schuss(
|
||||||
|
context,
|
||||||
|
pfad,
|
||||||
|
datei,
|
||||||
|
{ ganzeSeite = true, vorher, bereich } = {},
|
||||||
|
) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||||
|
if (vorher) await vorher(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const ziel = join(ZIEL, datei);
|
||||||
|
if (bereich) {
|
||||||
|
await page.locator(bereich).first().screenshot({ path: ziel });
|
||||||
|
} else {
|
||||||
|
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
console.log(" ✓", datei);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
|
||||||
|
*
|
||||||
|
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
|
||||||
|
* und die Farbe passt zu den übrigen Aufnahmen.
|
||||||
|
*/
|
||||||
|
async function beispiellogo() {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#312E81"/>
|
||||||
|
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
|
||||||
|
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
|
||||||
|
</svg>`;
|
||||||
|
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "Code 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");
|
||||||
|
|
||||||
|
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
|
||||||
|
|
||||||
|
const logo = await beispiellogo();
|
||||||
|
|
||||||
|
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
|
||||||
|
// sind — das eine erklärt das andere.
|
||||||
|
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||||
|
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
|
||||||
|
await page.setInputFiles('input[type="file"]', {
|
||||||
|
name: "logo.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: logo,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
|
||||||
|
const statisch = await api("/api/codes", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
type: "website",
|
||||||
|
data: { url: "https://example.com/aushang" },
|
||||||
|
design: DESIGN,
|
||||||
|
mode: "static",
|
||||||
|
label: "Aushang Eingang",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (statisch.status === 201) angelegt.push(statisch.body.code);
|
||||||
|
|
||||||
|
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
|
||||||
|
bereich: "section:has-text('Art des Codes')",
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/aushang");
|
||||||
|
await page.locator('button[data-mode="static"]').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.getByRole("button", { name: /ablegen/i }).click();
|
||||||
|
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
|
||||||
|
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByLabel("Nach Art filtern").selectOption("static");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
|
||||||
|
const gruppe = await api("/api/admin/groups", {
|
||||||
|
method: "POST",
|
||||||
|
json: { name: "Marketing" },
|
||||||
|
});
|
||||||
|
const gruppeId = gruppe.body?.group?.id ?? null;
|
||||||
|
|
||||||
|
if (gruppeId) {
|
||||||
|
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
|
||||||
|
// um den es geht.
|
||||||
|
await api("/api/admin/types", {
|
||||||
|
method: "PATCH",
|
||||||
|
json: { type: "payment", enabled: false, groupId: gruppeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
if (gruppeId) {
|
||||||
|
await page.getByLabel("Freigabe für").selectOption(gruppeId);
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
|
||||||
|
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
|
||||||
|
bereich: "dialog[open]",
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByRole("button", { name: "Kurzhilfe" }).click();
|
||||||
|
await page.locator("dialog[open]").waitFor();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
|
||||||
|
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
|
||||||
|
// Fall, den die Anleitung zeigen soll.
|
||||||
|
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.getByRole("button", { name: "Prüfen" }).first().click();
|
||||||
|
await page
|
||||||
|
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
|
||||||
|
.first()
|
||||||
|
.waitFor({ timeout: 30000 });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
|
||||||
|
|
||||||
|
const bestand = await api("/api/codes");
|
||||||
|
for (const eintrag of bestand.body?.codes ?? []) {
|
||||||
|
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
|
||||||
|
angelegt.push(eintrag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
|
||||||
|
// Instanz so hinterlassen, wie es sie vorgefunden hat.
|
||||||
|
if (gruppeId) {
|
||||||
|
await api("/api/admin/types", {
|
||||||
|
method: "PATCH",
|
||||||
|
json: { type: "payment", enabled: true, groupId: gruppeId },
|
||||||
|
});
|
||||||
|
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 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,308 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Domain-Check und seine Grenzen
|
||||||
|
|
||||||
|
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
|
||||||
|
Server aus auf. Drei Dinge grenzen das ein:
|
||||||
|
|
||||||
|
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
|
||||||
|
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
|
||||||
|
aus beliebige Adressen abklopfen ließen.
|
||||||
|
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
|
||||||
|
die auch Domains anlegt.
|
||||||
|
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
|
||||||
|
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
|
||||||
|
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
|
||||||
|
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
|
||||||
|
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
|
||||||
|
|
||||||
|
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
|
||||||
|
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
|
||||||
|
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
||||||
|
Übertragen wird über diese Verbindung nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
|
||||||
// sollen nicht vom Bundler verarbeitet werden.
|
// sollen nicht vom Bundler verarbeitet werden.
|
||||||
serverExternalPackages: ["qrcode", "pg"],
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -564,7 +567,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2063,6 +2065,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
@@ -4037,7 +4105,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5024,6 +5091,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -5950,6 +6032,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -7085,6 +7176,38 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
@@ -7737,7 +7860,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -7787,7 +7909,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"changelog": "node scripts/changelog.mjs",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
@@ -16,12 +17,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "QrTypeSetting" (
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "QrTypeSetting_pkey" PRIMARY KEY ("type")
|
||||||
|
);
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
-- Anmeldung, Gruppen und Rollen.
|
||||||
|
--
|
||||||
|
-- Die Reihenfolge ist wichtig: `QrCode.groupId` ist verpflichtend, es gibt
|
||||||
|
-- aber möglicherweise schon Codes. Deshalb wird die Spalte erst ohne
|
||||||
|
-- Einschränkung angelegt, dann mit der Standardgruppe gefüllt und erst
|
||||||
|
-- danach auf NOT NULL gesetzt. Ohne diesen Zwischenschritt scheitert die
|
||||||
|
-- Migration auf jeder Installation, die bereits benutzt wurde.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"failedLogins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lockedUntil" TIMESTAMP(3),
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Group" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Membership" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Membership_groupId_idx" ON "Membership"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Membership_userId_groupId_key" ON "Membership"("userId", "groupId");
|
||||||
|
|
||||||
|
-- Standardgruppe anlegen. Sie ist auch bei ausgeschalteter Anmeldung die
|
||||||
|
-- Heimat aller Codes, damit es keinen Sonderfall "Code ohne Gruppe" gibt.
|
||||||
|
INSERT INTO "Group" ("id", "name", "description", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'grp_standard',
|
||||||
|
'Standard',
|
||||||
|
'Beim ersten Start angelegt. Enthält alle Codes, die vor Einführung der Benutzerverwaltung entstanden sind.',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable: erst nullable anlegen …
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "createdById" TEXT,
|
||||||
|
ADD COLUMN "groupId" TEXT;
|
||||||
|
|
||||||
|
-- … bestehende Codes der Standardgruppe zuordnen …
|
||||||
|
UPDATE "QrCode" SET "groupId" = 'grp_standard' WHERE "groupId" IS NULL;
|
||||||
|
|
||||||
|
-- … und erst jetzt verpflichtend machen.
|
||||||
|
ALTER TABLE "QrCode" ALTER COLUMN "groupId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "QrCode_groupId_idx" ON "QrCode"("groupId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- Mehrere Domains für Kurz-URLs.
|
||||||
|
--
|
||||||
|
-- Zwei Dinge sind hier heikel und deshalb ausdrücklich schrittweise gelöst:
|
||||||
|
--
|
||||||
|
-- 1. `QrCode.domainId` ist verpflichtend, es gibt aber schon Codes. Die
|
||||||
|
-- Spalte entsteht zuerst ohne Einschränkung, wird gefüllt und erst danach
|
||||||
|
-- auf NOT NULL gesetzt.
|
||||||
|
-- 2. `slug` war global eindeutig und ist es künftig nur noch je Domain. Der
|
||||||
|
-- alte Index muss weichen, bevor der neue greifen kann.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Domain" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"hostname" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Domain_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainGrant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"domainId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainGrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Domain_hostname_key" ON "Domain"("hostname");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainGrant_groupId_idx" ON "DomainGrant"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainGrant_domainId_groupId_key" ON "DomainGrant"("domainId", "groupId");
|
||||||
|
|
||||||
|
-- Standard-Domain. Der Hostname ist zunächst ein Platzhalter: die
|
||||||
|
-- Migration kennt BASE_URL nicht. Beim ersten Start ersetzt
|
||||||
|
-- ensureDefaultDomain() ihn durch den Hostnamen aus BASE_URL.
|
||||||
|
INSERT INTO "Domain" ("id", "hostname", "label", "isActive", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'dom_standard',
|
||||||
|
'standard.invalid',
|
||||||
|
'Standard-Domain',
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alle bestehenden Gruppen bekommen die Standard-Domain als Voreinstellung.
|
||||||
|
INSERT INTO "DomainGrant" ("id", "domainId", "groupId", "isDefault", "createdAt")
|
||||||
|
SELECT 'dg_std_' || "Group"."id", 'dom_standard', "Group"."id", true, CURRENT_TIMESTAMP
|
||||||
|
FROM "Group";
|
||||||
|
|
||||||
|
-- AlterTable: erst nullable …
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "domainId" TEXT;
|
||||||
|
|
||||||
|
-- … bestehende Codes der Standard-Domain zuordnen …
|
||||||
|
UPDATE "QrCode" SET "domainId" = 'dom_standard' WHERE "domainId" IS NULL;
|
||||||
|
|
||||||
|
-- … und erst jetzt verpflichtend machen.
|
||||||
|
ALTER TABLE "QrCode" ALTER COLUMN "domainId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Eindeutigkeit des Slugs von global auf „je Domain“ umstellen.
|
||||||
|
DROP INDEX "QrCode_slug_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "QrCode_domainId_slug_key" ON "QrCode"("domainId", "slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "QrCode_domainId_idx" ON "QrCode"("domainId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Hochgeladene Dateien für die Typen „PDF" und „Bild".
|
||||||
|
--
|
||||||
|
-- Der Inhalt liegt als bytea in der Datenbank. Damit bleibt es bei einem
|
||||||
|
-- Volumen, das gesichert werden muss.
|
||||||
|
CREATE TABLE "Upload" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"filename" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"data" BYTEA NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"qrCodeId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Upload_qrCodeId_idx" ON "Upload"("qrCodeId");
|
||||||
|
CREATE INDEX "Upload_createdAt_idx" ON "Upload"("createdAt");
|
||||||
|
|
||||||
|
-- Wird ein Code gelöscht, verschwindet seine Datei mit ihm.
|
||||||
|
ALTER TABLE "Upload"
|
||||||
|
ADD CONSTRAINT "Upload_qrCodeId_fkey"
|
||||||
|
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Rückmeldungen zu einem Feedback-Code.
|
||||||
|
--
|
||||||
|
-- Ohne IP-Adresse und ohne Gerätekennung: Wer einen Aushang scannt und eine
|
||||||
|
-- Note vergibt, soll nicht wiedererkennbar sein.
|
||||||
|
CREATE TABLE "Feedback" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"qrCodeId" TEXT NOT NULL,
|
||||||
|
"rating" INTEGER NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Feedback_qrCodeId_createdAt_idx" ON "Feedback"("qrCodeId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "Feedback"
|
||||||
|
ADD CONSTRAINT "Feedback_qrCodeId_fkey"
|
||||||
|
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
|
||||||
|
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
|
||||||
|
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
|
||||||
|
--
|
||||||
|
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
|
||||||
|
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
|
||||||
|
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
|
||||||
|
--
|
||||||
|
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
|
||||||
|
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
|
||||||
|
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
|
||||||
|
|
||||||
|
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
|
||||||
|
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- QR-Typen lassen sich je Gruppe abschalten.
|
||||||
|
--
|
||||||
|
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
|
||||||
|
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
|
||||||
|
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
|
||||||
|
-- aufheben — global aus gewinnt immer.
|
||||||
|
--
|
||||||
|
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||||
|
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||||
|
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||||
|
-- nebeneinander zu.
|
||||||
|
CREATE TABLE "QrTypeGroupSetting" (
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
|
||||||
|
|
||||||
|
-- Mit der Gruppe verschwindet auch ihre Abweichung.
|
||||||
|
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
|
||||||
|
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -10,15 +10,196 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
||||||
|
///
|
||||||
|
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
||||||
|
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
||||||
|
/// erste Gruppe erstellt.
|
||||||
|
enum Role {
|
||||||
|
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
||||||
|
ADMIN
|
||||||
|
/// Darf Codes anlegen, ändern und löschen.
|
||||||
|
EDITOR
|
||||||
|
/// Darf nur lesen und herunterladen.
|
||||||
|
VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||||
|
/// 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
|
||||||
|
/// werden beim nächsten Zugriff verworfen.
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
failedLogins Int @default(0)
|
||||||
|
lockedUntil DateTime?
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
memberships Membership[]
|
||||||
|
createdCodes QrCode[] @relation("QrCodeCreator")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
||||||
|
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
||||||
|
/// deaktiviert wird.
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
||||||
|
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
||||||
|
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.
|
||||||
|
model Group {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
memberships Membership[]
|
||||||
|
codes QrCode[]
|
||||||
|
domainGrants DomainGrant[]
|
||||||
|
typeSettings QrTypeGroupSetting[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||||
|
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
||||||
|
model Membership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
groupId String
|
||||||
|
role Role
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, groupId])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
|
||||||
|
///
|
||||||
|
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
||||||
|
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
|
||||||
|
/// damit lässt sich über einen gefälschten Header keine fremde Domain
|
||||||
|
/// ansprechen.
|
||||||
|
model Domain {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
|
||||||
|
hostname String @unique
|
||||||
|
label String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
grants DomainGrant[]
|
||||||
|
codes QrCode[]
|
||||||
|
aliases QrCodeAlias[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
||||||
|
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
|
||||||
|
/// `isDefault`.
|
||||||
|
model DomainGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
domainId String
|
||||||
|
groupId String
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||||
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([domainId, groupId])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
||||||
|
///
|
||||||
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
||||||
|
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
|
||||||
|
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
|
||||||
|
/// automatisch sichtbar.
|
||||||
|
model QrTypeSetting {
|
||||||
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
||||||
|
type String @id
|
||||||
|
enabled Boolean
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
|
||||||
|
///
|
||||||
|
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||||
|
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||||
|
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||||
|
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
|
||||||
|
/// nicht gesichert.
|
||||||
|
///
|
||||||
|
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
|
||||||
|
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
|
||||||
|
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
|
||||||
|
/// aufheben — global aus gewinnt immer.
|
||||||
|
model QrTypeGroupSetting {
|
||||||
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
||||||
|
type String
|
||||||
|
groupId String
|
||||||
|
enabled Boolean
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([type, groupId])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
||||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||||
///
|
///
|
||||||
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
|
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
||||||
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||||
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
|
||||||
model QrCode {
|
model QrCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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
|
type String
|
||||||
label String?
|
label String?
|
||||||
data Json
|
data Json
|
||||||
@@ -27,10 +208,162 @@ model QrCode {
|
|||||||
size Int @default(512)
|
size Int @default(512)
|
||||||
ecLevel String @default("M")
|
ecLevel String @default("M")
|
||||||
margin Int @default(4)
|
margin Int @default(4)
|
||||||
|
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
|
||||||
|
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
|
||||||
|
logoSource String?
|
||||||
|
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
|
||||||
|
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
|
||||||
|
logoUploadId String?
|
||||||
|
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
|
||||||
|
logoScale Int @default(20)
|
||||||
scans Int @default(0)
|
scans Int @default(0)
|
||||||
lastScanAt DateTime?
|
lastScanAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
|
||||||
|
domainId String
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id])
|
||||||
|
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
||||||
|
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
||||||
|
groupId String
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
||||||
|
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
||||||
|
createdById String?
|
||||||
|
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit.
|
||||||
|
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([createdAt])
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([domainId])
|
||||||
|
@@index([mode])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||||
|
/// einem Bild-Code.
|
||||||
|
///
|
||||||
|
/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es
|
||||||
|
/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
||||||
|
/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer
|
||||||
|
/// Installation dieser Größenordnung ist das der bessere Tausch als ein
|
||||||
|
/// zweites Volumen, das beim Sichern vergessen werden kann.
|
||||||
|
model Upload {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Ursprünglicher Dateiname, für den Download.
|
||||||
|
filename String
|
||||||
|
mimeType String
|
||||||
|
/// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden.
|
||||||
|
size Int
|
||||||
|
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)
|
||||||
|
/// Geheimnis fuer den Domain-Check.
|
||||||
|
///
|
||||||
|
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
|
||||||
|
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
|
||||||
|
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
|
||||||
|
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
|
||||||
|
/// Server nie und entsteht beim ersten Check.
|
||||||
|
checkSecret String?
|
||||||
|
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
|
||||||
|
/// abgegeben hat.
|
||||||
|
///
|
||||||
|
/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen
|
||||||
|
/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt,
|
||||||
|
/// soll nicht wiedererkennbar sein.
|
||||||
|
model Feedback {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
qrCodeId String
|
||||||
|
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||||
|
/// 1 bis 5.
|
||||||
|
rating Int
|
||||||
|
comment String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([qrCodeId, createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
|
||||||
|
*
|
||||||
|
* node scripts/changelog.mjs erzeugen
|
||||||
|
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
|
||||||
|
*
|
||||||
|
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
|
||||||
|
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
|
||||||
|
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const ziel = join(wurzel, "CHANGELOG.md");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
|
||||||
|
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
|
||||||
|
* besteht ausschließlich aus Zeichenketten und Listen.
|
||||||
|
*/
|
||||||
|
function ladeVerlauf() {
|
||||||
|
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
|
||||||
|
const anfang = quelle.indexOf("export const CHANGELOG");
|
||||||
|
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
|
||||||
|
|
||||||
|
const start = quelle.indexOf("[", anfang);
|
||||||
|
const ende = quelle.indexOf("\n];", start);
|
||||||
|
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
|
||||||
|
|
||||||
|
const literal = quelle.slice(start, ende + 2);
|
||||||
|
return Function(`"use strict"; return (${literal});`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatiereDatum(iso) {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baue(verlauf) {
|
||||||
|
const zeilen = [
|
||||||
|
"# Änderungen",
|
||||||
|
"",
|
||||||
|
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
|
||||||
|
"Anwendung unter **Version und Änderungen**, erreichbar über die",
|
||||||
|
"Versionsnummer in der Fußzeile.",
|
||||||
|
"",
|
||||||
|
"> Diese Datei wird erzeugt: `npm run changelog`.",
|
||||||
|
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const eintrag of verlauf) {
|
||||||
|
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
|
||||||
|
zeilen.push(eintrag.summary, "");
|
||||||
|
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
|
||||||
|
zeilen.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return zeilen.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = baue(ladeVerlauf());
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
let vorhanden = "";
|
||||||
|
try {
|
||||||
|
vorhanden = readFileSync(ziel, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (vorhanden !== inhalt) {
|
||||||
|
console.error(
|
||||||
|
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("CHANGELOG.md ist aktuell.");
|
||||||
|
} else {
|
||||||
|
writeFileSync(ziel, inhalt, "utf8");
|
||||||
|
console.log("CHANGELOG.md geschrieben.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
type PageProps = { searchParams: Promise<{ fehler?: string }> };
|
||||||
|
|
||||||
|
export default async function SignInPage({ searchParams }: PageProps) {
|
||||||
|
// Ohne Anmeldung gibt es hier nichts zu tun.
|
||||||
|
if (!isAuthEnabled()) redirect("/");
|
||||||
|
|
||||||
|
const actor = await getActor();
|
||||||
|
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 && zeigeFormular ? "lock" : "qr"} className="size-6" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
||||||
|
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
|
||||||
|
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
|
||||||
|
|
||||||
|
{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 →
|
||||||
|
Benutzer zurücksetzen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { QrGenerator } from "@/components/qr-generator";
|
||||||
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ type: string }> };
|
||||||
|
|
||||||
|
// Die Freigabe der Typen kommt aus der Datenbank und ist jederzeit änderbar.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { type } = await params;
|
||||||
|
const qrType = getQrType(type);
|
||||||
|
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erklärseite statt 404 — der Typ existiert, ist nur (noch) nicht nutzbar. */
|
||||||
|
function Hinweis({
|
||||||
|
icon,
|
||||||
|
titel,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
icon: Parameters<typeof Icon>[0]["name"];
|
||||||
|
titel: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<Icon name={icon} className="size-7" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold text-slate-900">{titel}</h1>
|
||||||
|
<p className="mt-2 text-slate-600">{text}</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Zur Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreatePage({ params }: Props) {
|
||||||
|
const actor = await requirePageActor();
|
||||||
|
const { type } = await params;
|
||||||
|
|
||||||
|
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
|
||||||
|
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
const state = await getQrTypeState(type, zielgruppe);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.enabled) {
|
||||||
|
return (
|
||||||
|
<Hinweis
|
||||||
|
icon="lock"
|
||||||
|
titel={`„${state.qrType.title}“ ist abgeschaltet`}
|
||||||
|
text={
|
||||||
|
state.enabledGlobally
|
||||||
|
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
|
||||||
|
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.implemented) {
|
||||||
|
return (
|
||||||
|
<Hinweis
|
||||||
|
icon={state.qrType.icon}
|
||||||
|
titel={`„${state.qrType.title}“ ist bald verfügbar`}
|
||||||
|
text="Dieser QR-Typ folgt in einer späteren Version. Bereits verfügbare Typen findest du in der Typ-Auswahl."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 maySetSlug = can(actor, "codes:slug", {
|
||||||
|
groupId: zielgruppe,
|
||||||
|
editorsMaySetSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||||
|
// der eigene Upload.
|
||||||
|
const { logoUploadId } = await getBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QrGenerator
|
||||||
|
typeId={state.qrType.id}
|
||||||
|
maySetSlug={maySetSlug}
|
||||||
|
hasBrandingLogo={logoUploadId !== null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Kein Zugriff" };
|
||||||
|
|
||||||
|
export default function NoAccessPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 py-20 text-center sm:px-6">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<Icon name="lock" className="size-7" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold text-slate-900">Kein Zugriff</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
|
||||||
|
dass das ein Versehen ist, wende dich an einen Administrator.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ChangePasswordForm, SignOutCard } from "@/components/account";
|
||||||
|
import { Badge, Card, CardTitle, Notice } from "@/components/ui";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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") {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
|
Konto
|
||||||
|
</h1>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Notice tone="info">
|
||||||
|
<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-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-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
|
||||||
|
an.
|
||||||
|
</Notice>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Zur Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = actor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Konto
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardTitle>Angemeldet als</CardTitle>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div className="flex flex-wrap gap-x-2">
|
||||||
|
<dt className="text-slate-500">Name:</dt>
|
||||||
|
<dd className="text-slate-900">{user.name?.trim() || "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2">
|
||||||
|
<dt className="text-slate-500">E-Mail:</dt>
|
||||||
|
<dd className="text-slate-900">{user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2">
|
||||||
|
<dt className="text-slate-500">Rolle:</dt>
|
||||||
|
<dd>
|
||||||
|
{user.isInstanceAdmin ? (
|
||||||
|
<Badge tone="accent">Administrator der Installation</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-900">Benutzer</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle
|
||||||
|
hint={
|
||||||
|
user.isInstanceAdmin
|
||||||
|
? "Als Administrator der Installation siehst du ohnehin alle Gruppen."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Meine Gruppen
|
||||||
|
</CardTitle>
|
||||||
|
{user.memberships.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Du bist in keiner Gruppe. Bitte einen Administrator, dich einer
|
||||||
|
zuzuordnen — sonst kannst du keine Codes anlegen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{user.memberships.map((membership) => (
|
||||||
|
<li
|
||||||
|
key={membership.groupId}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-3"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
{membership.groupName}
|
||||||
|
</span>
|
||||||
|
<Badge tone="neutral">{ROLE_LABELS[membership.role]}</Badge>
|
||||||
|
<span className="w-full text-sm text-slate-500">
|
||||||
|
{ROLE_DESCRIPTIONS[membership.role]}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isAuthEnabled() ? (
|
||||||
|
<>
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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";
|
||||||
|
import { APP_VERSION } from "@/lib/changelog";
|
||||||
|
|
||||||
|
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||||
|
// werden.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const NAV_LINK =
|
||||||
|
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen der eigentlichen Anwendung: Kopfzeile mit Navigation, Inhalt,
|
||||||
|
* Fußzeile. Die öffentlichen Landingpages liegen bewusst außerhalb dieser
|
||||||
|
* Gruppe und bekommen davon nichts ab.
|
||||||
|
*/
|
||||||
|
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-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"
|
||||||
|
>
|
||||||
|
{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. 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}>
|
||||||
|
Erstellen
|
||||||
|
</Link>
|
||||||
|
<Link href="/meine-codes" className={NAV_LINK}>
|
||||||
|
Meine Codes
|
||||||
|
</Link>
|
||||||
|
{showAdmin ? (
|
||||||
|
<Link href="/verwaltung" className={NAV_LINK}>
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isAuthEnabled() && actor.kind === "user" ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/konto"
|
||||||
|
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-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
||||||
|
{actorLabel(actor).slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden truncate sm:inline">
|
||||||
|
{actorLabel(actor)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isAuthEnabled() && actor.kind === "anonymous" ? (
|
||||||
|
<Link href="/anmelden" className={NAV_LINK}>
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
|
||||||
|
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
|
||||||
|
{!isAuthEnabled() ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/konto"
|
||||||
|
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
|
||||||
|
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Ohne Anmeldung
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-slate-200 bg-surface">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||||
|
<span>
|
||||||
|
{branding.appName}
|
||||||
|
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||||
|
</span>
|
||||||
|
{/* Welche Fassung hier läuft — nach einem Update die schnellste
|
||||||
|
Antwort auf die Frage, ob das Update auch angekommen ist. */}
|
||||||
|
<Link
|
||||||
|
href="/version"
|
||||||
|
title="Version und Änderungen"
|
||||||
|
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
v{APP_VERSION}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Card } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import { can } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Rückmeldungen" };
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
function formatDate(value: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FeedbackResultsPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const actor = await requirePageActor();
|
||||||
|
|
||||||
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
feedback: { orderBy: { createdAt: "desc" }, take: 200 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!code || code.type !== "feedback") notFound();
|
||||||
|
if (!can(actor, "codes:read", { groupId: code.groupId })) notFound();
|
||||||
|
|
||||||
|
const daten = feedbackDataSchema.safeParse(code.data);
|
||||||
|
const skala = daten.success ? daten.data.scale : "stars";
|
||||||
|
const titel = daten.success ? daten.data.title : (code.label ?? "Feedback");
|
||||||
|
|
||||||
|
const eintraege = code.feedback;
|
||||||
|
const anzahl = eintraege.length;
|
||||||
|
const schnitt =
|
||||||
|
anzahl > 0
|
||||||
|
? eintraege.reduce((summe, eintrag) => summe + eintrag.rating, 0) / anzahl
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Verteilung von 5 nach 1 — so liest man eine Bewertungsübersicht.
|
||||||
|
const verteilung = [5, 4, 3, 2, 1].map((note) => ({
|
||||||
|
note,
|
||||||
|
anzahl: eintraege.filter((eintrag) => eintrag.rating === note).length,
|
||||||
|
}));
|
||||||
|
const groesste = Math.max(1, ...verteilung.map((zeile) => zeile.anzahl));
|
||||||
|
|
||||||
|
const kommentare = eintraege.filter((eintrag) => eintrag.comment);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/meine-codes"
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
Zurück zu Meine Codes
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
{titel}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
{anzahl === 0
|
||||||
|
? "Es ist noch keine Rückmeldung eingegangen."
|
||||||
|
: `${anzahl} ${anzahl === 1 ? "Rückmeldung" : "Rückmeldungen"} · ${code.scans} ${code.scans === 1 ? "Scan" : "Scans"}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{anzahl > 0 ? (
|
||||||
|
<>
|
||||||
|
<Card className="mt-7">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-4xl font-bold text-slate-900 tabular-nums">
|
||||||
|
{schnitt.toFixed(1).replace(".", ",")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">von 5 im Schnitt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
{verteilung.map((zeile) => (
|
||||||
|
<div key={zeile.note} className="flex items-center gap-2.5">
|
||||||
|
<span className="w-10 shrink-0 text-right text-sm text-slate-500 tabular-nums">
|
||||||
|
{zeile.note}{" "}
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{FEEDBACK_SCALES[skala].symbols[zeile.note - 1]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-indigo-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round((zeile.anzahl / groesste) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-8 shrink-0 text-sm text-slate-600 tabular-nums">
|
||||||
|
{zeile.anzahl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{kommentare.length > 0 ? (
|
||||||
|
<div className="mt-7">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">
|
||||||
|
Kommentare ({kommentare.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{kommentare.map((eintrag) => (
|
||||||
|
<li
|
||||||
|
key={eintrag.id}
|
||||||
|
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">
|
||||||
|
{eintrag.rating}/5
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<time dateTime={eintrag.createdAt.toISOString()}>
|
||||||
|
{formatDate(eintrag.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-slate-800">
|
||||||
|
{eintrag.comment}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{anzahl >= 200 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-500">
|
||||||
|
Angezeigt werden die 200 jüngsten Rückmeldungen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-7 text-slate-600">
|
||||||
|
Bisher hat niemand etwas dazugeschrieben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card className="mt-7">
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Sobald jemand den Code scannt und eine Bewertung abgibt, steht sie
|
||||||
|
hier. Die Rückmeldungen enthalten keinen Namen und keine
|
||||||
|
IP-Adresse.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function MyCodesPage() {
|
||||||
|
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">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Meine Codes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
|
||||||
|
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
|
||||||
|
lässt sich die Liste auf eine der beiden Arten einschränken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodesList maySetSlug={maySetSlug} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge, Notice } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
|
||||||
|
|
||||||
|
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
||||||
|
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const actor = await requirePageActor();
|
||||||
|
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
|
||||||
|
const types = await getVisibleQrTypesForActor(actor);
|
||||||
|
const usableCount = types.filter((entry) => entry.usable).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
|
||||||
|
Was möchtest du erstellen?
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-base text-slate-600">
|
||||||
|
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
|
||||||
|
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
|
||||||
|
später änderst, ohne den Code neu drucken zu müssen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usableCount === 0 ? (
|
||||||
|
<div className="mt-9 max-w-2xl">
|
||||||
|
<Notice tone="warning">
|
||||||
|
{types.length === 0
|
||||||
|
? "Zurzeit ist kein QR-Typ freigegeben."
|
||||||
|
: "Zurzeit ist kein QR-Typ zum Erstellen freigegeben — die unten gezeigten Typen folgen erst in einer späteren Version."}{" "}
|
||||||
|
Unter{" "}
|
||||||
|
<Link href="/verwaltung/typen" className="font-medium underline">
|
||||||
|
Verwaltung → QR-Typen
|
||||||
|
</Link>{" "}
|
||||||
|
lassen sich Typen wieder freigeben.
|
||||||
|
</Notice>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{types.length === 0 ? null : (
|
||||||
|
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{types.map(({ qrType, usable }) => {
|
||||||
|
const tile = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
usable
|
||||||
|
? "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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={qrType.icon} className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
usable
|
||||||
|
? "font-semibold text-slate-900"
|
||||||
|
: "font-semibold text-slate-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{qrType.title}
|
||||||
|
</span>
|
||||||
|
{/* 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={
|
||||||
|
usable
|
||||||
|
? "mt-1 block text-sm text-slate-600"
|
||||||
|
: "mt-1 block text-sm text-slate-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{qrType.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={qrType.id}>
|
||||||
|
{usable ? (
|
||||||
|
<Link
|
||||||
|
href={`/erstellen/${qrType.id}`}
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
aria-disabled="true"
|
||||||
|
title={`„${qrType.title}“ folgt in einer späteren Version`}
|
||||||
|
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
|
||||||
|
>
|
||||||
|
{tile}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usableCount > 0 && usableCount < types.length ? (
|
||||||
|
<p className="mt-8 text-sm text-slate-500">
|
||||||
|
Die übrigen Typen folgen in einer späteren Version. Welche Kacheln
|
||||||
|
hier erscheinen, legst du unter{" "}
|
||||||
|
<Link
|
||||||
|
href="/verwaltung/typen"
|
||||||
|
className="font-medium text-slate-600 underline"
|
||||||
|
>
|
||||||
|
Verwaltung → QR-Typen
|
||||||
|
</Link>{" "}
|
||||||
|
fest.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge, Card } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
CHANGELOG,
|
||||||
|
formatChangelogDate,
|
||||||
|
} from "@/lib/changelog";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Version und Änderungen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function VersionPage() {
|
||||||
|
await requirePageActor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
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 Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Version und Änderungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Hier läuft Version{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
|
||||||
|
Darunter steht, was in welcher Fassung dazugekommen ist.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
|
||||||
|
Nummer, läuft der Container noch mit dem alten Abbild.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{CHANGELOG.map((eintrag) => {
|
||||||
|
const laeuft = eintrag.version === APP_VERSION;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.version}>
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
{eintrag.version}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-slate-500 tabular-nums">
|
||||||
|
{formatChangelogDate(eintrag.date)}
|
||||||
|
</span>
|
||||||
|
{laeuft ? (
|
||||||
|
<Badge tone="accent">läuft hier</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-slate-700">
|
||||||
|
{eintrag.changes.map((zeile) => (
|
||||||
|
<li key={zeile} className="flex gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
|
||||||
|
/>
|
||||||
|
<span>{zeile}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { UserAdmin } from "@/components/user-admin";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Benutzer" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const actor = await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
|
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" />
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Benutzer
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Konten anlegen, Passwörter setzen und Zugänge deaktivieren. Wer in
|
||||||
|
welcher Gruppe mitarbeitet, legst du unter{" "}
|
||||||
|
<Link href="/verwaltung/gruppen" className="font-medium underline">
|
||||||
|
Gruppen
|
||||||
|
</Link>{" "}
|
||||||
|
fest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserAdmin
|
||||||
|
minPasswordLength={MIN_PASSWORD_LENGTH}
|
||||||
|
currentUserId={actor.kind === "user" ? actor.user.id : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { DomainAdmin } from "@/components/domain-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { baseUrlHostname } from "@/lib/domains";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Domains" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DomainsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
|
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" />
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Domains
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain
|
||||||
|
kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain
|
||||||
|
eindeutig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DomainAdmin baseUrlHostname={baseUrlHostname()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { GroupAdmin } from "@/components/group-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import { can } from "@/lib/auth/policy";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Gruppen" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function GroupsPage() {
|
||||||
|
// Bewusst nur „angemeldet“: ein Gruppen-Administrator darf seine eigene
|
||||||
|
// Gruppe pflegen, auch ohne Rechte an der Installation.
|
||||||
|
const actor = await requirePageActor();
|
||||||
|
const canCreate = can(actor, "instance:manage");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href={canCreate ? "/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" />
|
||||||
|
{canCreate ? "Verwaltung" : "Zur Typ-Auswahl"}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Gruppen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. Jedes
|
||||||
|
Mitglied hat darin eine Rolle: Administrator, Bearbeiter oder
|
||||||
|
Betrachter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupAdmin canCreate={canCreate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
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" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
icon: IconName;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
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-accent-soft text-accent-text">
|
||||||
|
<Icon name={icon} className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-semibold text-slate-900">{title}</span>
|
||||||
|
<span className="mt-1 block text-sm text-slate-600">{description}</span>
|
||||||
|
<span className="mt-2 block text-xs text-slate-500 tabular-nums">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Verwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Benutzer, Gruppen und die verfügbaren QR-Typen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAuthEnabled() ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<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-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-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
|
||||||
|
an. Benutzer und Gruppen lassen sich schon jetzt vorbereiten.
|
||||||
|
</Notice>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/benutzer"
|
||||||
|
icon="contact"
|
||||||
|
title="Benutzer"
|
||||||
|
description="Konten anlegen, Passwörter zurücksetzen, deaktivieren."
|
||||||
|
hint={`${userCount} ${userCount === 1 ? "Benutzer" : "Benutzer"} angelegt`}
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/gruppen"
|
||||||
|
icon="list"
|
||||||
|
title="Gruppen"
|
||||||
|
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
|
||||||
|
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/domains"
|
||||||
|
icon="globe"
|
||||||
|
title="Domains"
|
||||||
|
description="Adressen, unter denen Kurz-URLs ausgeliefert werden."
|
||||||
|
hint={`${domainCount} ${domainCount === 1 ? "Domain" : "Domains"} registriert`}
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/typen"
|
||||||
|
icon="qr"
|
||||||
|
title="QR-Typen"
|
||||||
|
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,70 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "QR-Typen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function TypeSettingsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
const availability = await getQrTypeAvailability();
|
||||||
|
|
||||||
|
const initial: TypeSetting[] = availability.map(
|
||||||
|
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
|
||||||
|
id: qrType.id,
|
||||||
|
title: qrType.title,
|
||||||
|
description: qrType.description,
|
||||||
|
icon: qrType.icon,
|
||||||
|
implemented,
|
||||||
|
enabled,
|
||||||
|
enabledGlobally,
|
||||||
|
disabledInGroup,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
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 Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
QR-Typen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Lege fest, welche Typen auf der Startseite erscheinen. Abgeschaltete
|
||||||
|
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
|
||||||
|
anlegen.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
|
||||||
|
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
|
||||||
|
abgeschaltet.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
|
||||||
|
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
|
||||||
|
wird durch das Abschalten seines Typs also nicht ungültig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TypeSettings initial={initial} groups={groups} />
|
||||||
|
</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,43 @@
|
|||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { pruefeDomain } from "@/lib/domain-check";
|
||||||
|
import { errorResponse } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
|
||||||
|
* dahinter wirklich diese Installation steht.
|
||||||
|
*
|
||||||
|
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
|
||||||
|
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
|
||||||
|
*
|
||||||
|
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
|
||||||
|
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
|
||||||
|
* Server aus beliebige Adressen abklopfen lassen.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { hostname: true },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ report: await pruefeDomain(domain.hostname) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { DEFAULT_DOMAIN_ID } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
label: z.string().trim().max(80).nullable().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Vollständige Liste der Gruppen, die diese Domain nutzen dürfen. Wer
|
||||||
|
* fehlt, verliert die Zuteilung.
|
||||||
|
*/
|
||||||
|
grants: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().trim().min(1),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({ where: { id } });
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label !== undefined || parsed.data.isActive !== undefined) {
|
||||||
|
await prisma.domain.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.label !== undefined
|
||||||
|
? { label: parsed.data.label || null }
|
||||||
|
: {}),
|
||||||
|
...(parsed.data.isActive !== undefined
|
||||||
|
? { isActive: parsed.data.isActive }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.grants !== undefined) {
|
||||||
|
const grants = parsed.data.grants;
|
||||||
|
|
||||||
|
const known = await prisma.group.findMany({
|
||||||
|
where: { id: { in: grants.map((g) => g.groupId) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== grants.length) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Mindestens eine der ausgewählten Gruppen existiert nicht mehr.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Gruppe, die diese Domain noch für Codes verwendet, darf die
|
||||||
|
// Zuteilung nicht verlieren — sonst zeigten deren Kurz-URLs auf eine
|
||||||
|
// Domain, die der Gruppe gar nicht mehr gehört.
|
||||||
|
const stillInUse = await prisma.qrCode.findMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
select: { group: { select: { name: true } } },
|
||||||
|
distinct: ["groupId"],
|
||||||
|
});
|
||||||
|
if (stillInUse.length > 0) {
|
||||||
|
const names = stillInUse.map((entry) => entry.group.name).join(", ");
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Diese Gruppen haben noch Codes unter dieser Domain: ${names}. Solange die Codes bestehen, muss die Zuteilung bleiben.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.domainGrant.deleteMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
}),
|
||||||
|
...grants.map((grant) =>
|
||||||
|
prisma.domainGrant.upsert({
|
||||||
|
where: {
|
||||||
|
domainId_groupId: { domainId: id, groupId: grant.groupId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
domainId: id,
|
||||||
|
groupId: grant.groupId,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
},
|
||||||
|
update: { isDefault: grant.isDefault },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Je Gruppe darf nur eine Domain die Voreinstellung sein.
|
||||||
|
...grants
|
||||||
|
.filter((grant) => grant.isDefault)
|
||||||
|
.map((grant) =>
|
||||||
|
prisma.domainGrant.updateMany({
|
||||||
|
where: { groupId: grant.groupId, domainId: { not: id } },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (id === DEFAULT_DOMAIN_ID) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Standard-Domain lässt sich nicht löschen — sie fängt Aufrufe über nicht registrierte Adressen ab.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { codes: true } } },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain._count.codes > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Unter dieser Domain liegen noch ${domain._count.codes} ${domain._count.codes === 1 ? "Code" : "Codes"}. Sie zu löschen würde gedruckte Codes ins Leere laufen lassen — schalte sie stattdessen ab, wenn sie nicht mehr genutzt werden soll.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.domain.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ensureDefaultDomain, normalizeHostname } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Hostname, optional mit Port. Bewusst ohne Schema und ohne Pfad — das
|
||||||
|
* ist der Wert, den ein Browser im `Host`-Header sendet.
|
||||||
|
*/
|
||||||
|
const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Hostnamen ein." })
|
||||||
|
.max(253, { error: "Der Hostname ist zu lang." })
|
||||||
|
.transform(normalizeHostname)
|
||||||
|
.refine((value) => /^[a-z0-9.-]+(:\d{1,5})?$/.test(value), {
|
||||||
|
error:
|
||||||
|
"Das ist kein gültiger Hostname. Erwartet wird z. B. qr.example.com — ohne https:// und ohne Pfad.",
|
||||||
|
})
|
||||||
|
.refine((value) => value.includes(".") || value.startsWith("localhost"), {
|
||||||
|
error: "Bitte gib den vollständigen Hostnamen an, z. B. qr.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
hostname: hostnameSchema,
|
||||||
|
label: z.string().trim().max(80).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
// Sorgt dafür, dass die Standard-Domain den Hostnamen aus BASE_URL trägt.
|
||||||
|
await ensureDefaultDomain();
|
||||||
|
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
orderBy: { hostname: "asc" },
|
||||||
|
include: {
|
||||||
|
grants: { include: { group: { select: { id: true, name: true } } } },
|
||||||
|
_count: { select: { codes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
domains: domains.map((domain) => ({
|
||||||
|
id: domain.id,
|
||||||
|
hostname: domain.hostname,
|
||||||
|
label: domain.label,
|
||||||
|
isActive: domain.isActive,
|
||||||
|
codeCount: domain._count.codes,
|
||||||
|
grants: domain.grants.map((grant) => ({
|
||||||
|
groupId: grant.group.id,
|
||||||
|
groupName: grant.group.name,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.domain.findUnique({
|
||||||
|
where: { hostname: parsed.data.hostname },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError("Diese Domain ist bereits registriert.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
hostname: parsed.data.hostname,
|
||||||
|
label: parsed.data.label || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ domain: { id: domain.id, hostname: domain.hostname } },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80).optional(),
|
||||||
|
description: z.string().trim().max(300).nullable().optional(),
|
||||||
|
/**
|
||||||
|
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
|
||||||
|
* so bleibt der Zustand nach dem Speichern eindeutig.
|
||||||
|
*/
|
||||||
|
members: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
|
||||||
|
error: "Ungültige Rolle.",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({ where: { id } });
|
||||||
|
if (!group) {
|
||||||
|
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
|
||||||
|
// darf auch ein Administrator der Gruppe selbst pflegen.
|
||||||
|
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||||
|
requirePermission(actor, "instance:manage");
|
||||||
|
}
|
||||||
|
if (parsed.data.members !== undefined) {
|
||||||
|
requirePermission(actor, "group:manage", { groupId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||||
|
await prisma.group.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||||
|
...(parsed.data.description !== undefined
|
||||||
|
? { description: parsed.data.description || null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.members !== undefined) {
|
||||||
|
const members = parsed.data.members;
|
||||||
|
|
||||||
|
const known = await prisma.user.findMany({
|
||||||
|
where: { id: { in: members.map((m) => m.userId) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== members.length) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
|
||||||
|
// weil das Setzen der neuen Liste fehlgeschlagen ist.
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.membership.deleteMany({
|
||||||
|
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
|
||||||
|
}),
|
||||||
|
...members.map((member) =>
|
||||||
|
prisma.membership.upsert({
|
||||||
|
where: {
|
||||||
|
userId_groupId: { userId: member.userId, groupId: id },
|
||||||
|
},
|
||||||
|
create: { userId: member.userId, groupId: id, role: member.role },
|
||||||
|
update: { role: member.role },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (id === DEFAULT_GROUP_ID) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { codes: true } } },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group._count.codes > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.group.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
|
||||||
|
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
|
||||||
|
description: z.string().trim().max(300).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
if (actor.kind === "anonymous") {
|
||||||
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = visibleGroupIds(actor);
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
where: visible === null ? undefined : { id: { in: visible } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
memberships: {
|
||||||
|
include: { user: { select: { id: true, email: true, name: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
_count: { select: { codes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
groups: groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
codeCount: group._count.codes,
|
||||||
|
canManage: can(actor, "group:manage", { groupId: group.id }),
|
||||||
|
members: group.memberships.map((m) => ({
|
||||||
|
userId: m.user.id,
|
||||||
|
email: m.user.email,
|
||||||
|
name: m.user.name,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.group.findUnique({
|
||||||
|
where: { name: parsed.data.name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await prisma.group.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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 { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
getQrTypeAvailability,
|
||||||
|
setQrTypeEnabled,
|
||||||
|
setQrTypeEnabledForGroup,
|
||||||
|
} from "@/lib/qr/availability";
|
||||||
|
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
||||||
|
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
|
||||||
|
/**
|
||||||
|
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
|
||||||
|
* für die ganze Installation.
|
||||||
|
*/
|
||||||
|
groupId: z.string().trim().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand aller QR-Typen für die Verwaltung.
|
||||||
|
*
|
||||||
|
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
|
||||||
|
* Grundeinstellung.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
|
||||||
|
if (groupId !== undefined) {
|
||||||
|
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = await getQrTypeAvailability(groupId);
|
||||||
|
return Response.json({
|
||||||
|
groupId: groupId ?? null,
|
||||||
|
types: types.map(
|
||||||
|
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
|
||||||
|
id: qrType.id,
|
||||||
|
title: qrType.title,
|
||||||
|
description: qrType.description,
|
||||||
|
icon: qrType.icon,
|
||||||
|
implemented,
|
||||||
|
enabled,
|
||||||
|
enabledGlobally,
|
||||||
|
disabledInGroup,
|
||||||
|
usable,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Typ freigeben oder abschalten. */
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, enabled, groupId } = parsed.data;
|
||||||
|
|
||||||
|
if (groupId === undefined) {
|
||||||
|
await setQrTypeEnabled(type, enabled);
|
||||||
|
} else {
|
||||||
|
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||||
|
await setQrTypeEnabledForGroup(type, groupId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ type, enabled, groupId: groupId ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { destroyAllSessions } from "@/lib/auth/session";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().trim().max(120).nullable().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
isInstanceAdmin: z.boolean().optional(),
|
||||||
|
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.`,
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
/** Sperre nach Fehlversuchen aufheben. */
|
||||||
|
unlock: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
|
||||||
|
* mindestens ein aktiver Administrator übrig bleiben.
|
||||||
|
*/
|
||||||
|
async function assertNotLastAdmin(userId: string, action: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isInstanceAdmin: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (!user?.isInstanceAdmin || !user.isActive) return;
|
||||||
|
|
||||||
|
const others = await prisma.user.count({
|
||||||
|
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
|
||||||
|
});
|
||||||
|
if (others === 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
requirePermission(actor, "instance:manage");
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) {
|
||||||
|
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
name?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
isInstanceAdmin?: boolean;
|
||||||
|
passwordHash?: string;
|
||||||
|
failedLogins?: number;
|
||||||
|
lockedUntil?: Date | null;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (parsed.data.name !== undefined) {
|
||||||
|
data.name = parsed.data.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.isActive === false) {
|
||||||
|
await assertNotLastAdmin(id, "das Deaktivieren");
|
||||||
|
data.isActive = false;
|
||||||
|
} else if (parsed.data.isActive === true) {
|
||||||
|
data.isActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.isInstanceAdmin === false) {
|
||||||
|
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
|
||||||
|
data.isInstanceAdmin = false;
|
||||||
|
} else if (parsed.data.isInstanceAdmin === true) {
|
||||||
|
data.isInstanceAdmin = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.password) {
|
||||||
|
data.passwordHash = await hashPassword(parsed.data.password);
|
||||||
|
data.failedLogins = 0;
|
||||||
|
data.lockedUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.unlock) {
|
||||||
|
data.failedLogins = 0;
|
||||||
|
data.lockedUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({ where: { id }, data });
|
||||||
|
|
||||||
|
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
|
||||||
|
// sonst bliebe der Betroffene angemeldet.
|
||||||
|
if (data.isActive === false || data.passwordHash) {
|
||||||
|
await destroyAllSessions(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
user: {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
isActive: updated.isActive,
|
||||||
|
isInstanceAdmin: updated.isInstanceAdmin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
requirePermission(actor, "instance:manage");
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (actor.kind === "user" && actor.user.id === id) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Du kannst dein eigenes Konto nicht löschen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertNotLastAdmin(id, "das Löschen");
|
||||||
|
|
||||||
|
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
|
||||||
|
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
|
||||||
|
// sie einmal angelegt hat.
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
|
||||||
|
.max(254, { error: "Die Adresse ist zu lang." })
|
||||||
|
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
|
||||||
|
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()}`, })
|
||||||
|
.max(MAX_PASSWORD_LENGTH, {
|
||||||
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||||
|
}),
|
||||||
|
isInstanceAdmin: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
|
||||||
|
include: {
|
||||||
|
memberships: { include: { group: { select: { id: true, name: true } } } },
|
||||||
|
_count: { select: { sessions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
users: users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
isInstanceAdmin: user.isInstanceAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
isLocked: Boolean(
|
||||||
|
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
|
||||||
|
),
|
||||||
|
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
activeSessions: user._count.sessions,
|
||||||
|
memberships: user.memberships.map((m) => ({
|
||||||
|
groupId: m.group.id,
|
||||||
|
groupName: m.group.name,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: parsed.data.email },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Diese E-Mail-Adresse ist bereits vergeben.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: parsed.data.email,
|
||||||
|
name: parsed.data.name || null,
|
||||||
|
passwordHash: await hashPassword(parsed.data.password),
|
||||||
|
isInstanceAdmin: parsed.data.isInstanceAdmin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ user: { id: user.id, email: user.email } },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||||
|
import {
|
||||||
|
describePasswordRules,
|
||||||
|
hashPassword,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
needsRehash,
|
||||||
|
verifyPassword,
|
||||||
|
} from "@/lib/auth/password";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
||||||
|
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()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
|
||||||
|
.max(254, { error: "Die Adresse ist zu lang." })
|
||||||
|
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
|
||||||
|
error: "Das ist keine gültige E-Mail-Adresse.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
action: z.literal("login"),
|
||||||
|
email: emailSchema,
|
||||||
|
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()}`,
|
||||||
|
})
|
||||||
|
.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." })
|
||||||
|
.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", [
|
||||||
|
loginSchema,
|
||||||
|
setupSchema,
|
||||||
|
logoutSchema,
|
||||||
|
changePasswordSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
return Response.json({
|
||||||
|
authEnabled: isAuthEnabled(),
|
||||||
|
setupOpen: await isSetupOpen(),
|
||||||
|
user:
|
||||||
|
actor.kind === "user"
|
||||||
|
? {
|
||||||
|
id: actor.user.id,
|
||||||
|
email: actor.user.email,
|
||||||
|
name: actor.user.name,
|
||||||
|
isInstanceAdmin: actor.user.isInstanceAdmin,
|
||||||
|
memberships: actor.user.memberships,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = bodySchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.action === "logout") {
|
||||||
|
await destroySession();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthEnabled()) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
|
||||||
|
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
|
||||||
|
// catch an und würde als nackter 500er beim Aufrufer landen.
|
||||||
|
if (parsed.data.action === "change-password") {
|
||||||
|
return await handleChangePassword(parsed.data);
|
||||||
|
}
|
||||||
|
if (parsed.data.action === "setup") {
|
||||||
|
return await handleSetup(parsed.data);
|
||||||
|
}
|
||||||
|
return await handleLogin(parsed.data, requestOrigin(request.headers));
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand
|
||||||
|
* an einem unbeaufsichtigten Rechner das Konto übernehmen.
|
||||||
|
*/
|
||||||
|
async function handleChangePassword(
|
||||||
|
input: z.infer<typeof changePasswordSchema>,
|
||||||
|
): Promise<Response> {
|
||||||
|
const actor = await getActor();
|
||||||
|
if (actor.kind !== "user") {
|
||||||
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: actor.user.id } });
|
||||||
|
if (!user) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.currentPassword === input.newPassword) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Das neue Passwort ist mit dem bisherigen identisch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await hashPassword(input.newPassword),
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alle Sitzungen beenden — auch die eigene — und sofort eine neue
|
||||||
|
// aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu
|
||||||
|
// anmelden muss.
|
||||||
|
await destroyAllSessions(user.id);
|
||||||
|
await createSession(user.id);
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
|
||||||
|
async function handleSetup(
|
||||||
|
input: z.infer<typeof setupSchema>,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!(await isSetupOpen())) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// damit herausfinden, welche Konten es gibt.
|
||||||
|
const invalid = new QrRequestError(
|
||||||
|
"E-Mail-Adresse oder Passwort stimmt nicht.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
||||||
|
await verifyPassword(input.password, DUMMY_HASH);
|
||||||
|
throw invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||||
|
const minutes = Math.ceil(
|
||||||
|
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||||
|
);
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
failedLogins,
|
||||||
|
lockedUntil:
|
||||||
|
failedLogins >= MAX_ATTEMPTS
|
||||||
|
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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(),
|
||||||
|
...(passwordHash ? { passwordHash } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearAttempts(`login:${absender}`);
|
||||||
|
await createSession(user.id);
|
||||||
|
return Response.json({ ok: true, email: user.email });
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { shortUrl } from "@/lib/env";
|
import { shortUrl } from "@/lib/env";
|
||||||
|
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||||
import { errorResponse } from "@/lib/http";
|
import { errorResponse } from "@/lib/http";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -27,24 +31,40 @@ export async function GET(
|
|||||||
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
|
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
|
||||||
const isDownload = request.nextUrl.searchParams.get("download") === "1";
|
const isDownload = request.nextUrl.searchParams.get("download") === "1";
|
||||||
|
|
||||||
const code = await prisma.qrCode.findUnique({ where: { id } });
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { domain: { select: { hostname: true } } },
|
||||||
|
});
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Das Bild verrät die Kurz-URL und damit das Ziel — es gehört hinter
|
||||||
|
// dieselbe Prüfung wie die Liste.
|
||||||
|
requirePermission(await getActor(), "codes:read", { groupId: code.groupId });
|
||||||
|
|
||||||
const design = designSchema.parse({
|
const design = designSchema.parse({
|
||||||
fgColor: code.fgColor,
|
fgColor: code.fgColor,
|
||||||
bgColor: code.bgColor,
|
bgColor: code.bgColor,
|
||||||
size: code.size,
|
size: code.size,
|
||||||
ecLevel: code.ecLevel,
|
ecLevel: code.ecLevel,
|
||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
|
logoSource: code.logoSource,
|
||||||
|
logoUploadId: code.logoUploadId,
|
||||||
|
logoScale: code.logoScale,
|
||||||
});
|
});
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
|
|
||||||
const content = shortUrl(code.slug);
|
// 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 =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
|
|||||||
@@ -2,17 +2,31 @@ import type { NextRequest } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { getActiveQrType } from "@/lib/qr/registry";
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
import { QrRequestError } from "@/lib/qr/request";
|
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||||
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type Context = { params: Promise<{ id: string }> };
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
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({
|
const updateSchema = z.object({
|
||||||
data: z.record(z.string(), z.unknown()).optional(),
|
data: z.record(z.string(), z.unknown()).optional(),
|
||||||
design: designSchema.optional(),
|
design: designSchema.optional(),
|
||||||
@@ -22,10 +36,24 @@ const updateSchema = z.object({
|
|||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.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) {
|
async function loadCode(id: string) {
|
||||||
const code = await prisma.qrCode.findUnique({ where: { id } });
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: WITH_RELATIONS,
|
||||||
|
});
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
}
|
}
|
||||||
@@ -38,7 +66,10 @@ export async function GET(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
|
const actor = await getActor();
|
||||||
|
const code = await loadCode(id);
|
||||||
|
requirePermission(actor, "codes:read", { groupId: code.groupId });
|
||||||
|
return Response.json({ code: toQrCodeDto(code) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -51,26 +82,44 @@ export async function PATCH(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
|
const actor = await getActor();
|
||||||
|
|
||||||
const parsed = updateSchema.safeParse(await request.json());
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await loadCode(id);
|
const existing = await loadCode(id);
|
||||||
const update: Prisma.QrCodeUpdateInput = {};
|
requirePermission(actor, "codes:update", { groupId: existing.groupId });
|
||||||
|
|
||||||
if (parsed.data.data !== undefined) {
|
const update: Prisma.QrCodeUpdateInput = {};
|
||||||
const qrType = getActiveQrType(existing.type);
|
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||||
if (!qrType) {
|
let neueDateien: string[] | null = null;
|
||||||
throw new QrRequestError(
|
|
||||||
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
|
// 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
|
||||||
|
// unbearbeitbar machen — sie sind längst gedruckt.
|
||||||
|
const qrType = requireImplementedQrType(existing.type);
|
||||||
const validated = qrType.validate(parsed.data.data);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return jsonError(validated.message);
|
return jsonError(validated.message);
|
||||||
}
|
}
|
||||||
|
// Auch beim Bearbeiten: die neuen Daten müssen ein Weiterleitungsziel
|
||||||
|
// hergeben. Sonst könnte man einen laufenden Code durch eine Änderung
|
||||||
|
// stillschweigend unbrauchbar machen.
|
||||||
|
assertDynamicPossible(qrType, validated.data);
|
||||||
update.data = toJsonInput(validated.data);
|
update.data = toJsonInput(validated.data);
|
||||||
|
neueDateien = qrType.uploadIds(validated.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.label !== undefined) {
|
if (parsed.data.label !== undefined) {
|
||||||
@@ -84,11 +133,124 @@ export async function PATCH(
|
|||||||
Object.assign(update, parsed.data.design);
|
Object.assign(update, parsed.data.design);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length === 0) {
|
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
|
||||||
|
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
|
||||||
|
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
|
||||||
|
const logoVorher =
|
||||||
|
existing.logoSource === "upload" ? existing.logoUploadId : null;
|
||||||
|
const logoNachher =
|
||||||
|
parsed.data.design === undefined
|
||||||
|
? logoVorher
|
||||||
|
: parsed.data.design.logoSource === "upload"
|
||||||
|
? parsed.data.design.logoUploadId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- 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.");
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.qrCode.update({ where: { id }, data: update });
|
// 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
|
||||||
|
// später als verwaist weggeräumt.
|
||||||
|
if (neueDateien !== null) {
|
||||||
|
await attachUploads(
|
||||||
|
updated.id,
|
||||||
|
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
|
||||||
|
);
|
||||||
|
} else if (logoNachher && logoNachher !== logoVorher) {
|
||||||
|
await attachUpload(updated.id, logoNachher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
|
||||||
|
// später weggeräumt.
|
||||||
|
if (logoVorher && logoVorher !== logoNachher) {
|
||||||
|
await detachUpload(updated.id, logoVorher);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(updated) });
|
return Response.json({ code: toQrCodeDto(updated) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -101,7 +263,10 @@ export async function DELETE(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
await loadCode(id);
|
const actor = await getActor();
|
||||||
|
const existing = await loadCode(id);
|
||||||
|
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
|
||||||
|
|
||||||
await prisma.qrCode.delete({ where: { id } });
|
await prisma.qrCode.delete({ where: { id } });
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||||
|
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { resolveTargetDomain } from "@/lib/domains";
|
||||||
|
import { findStaticDuplicate } from "@/lib/duplicates";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { getActiveQrType } from "@/lib/qr/registry";
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
import { QrRequestError } from "@/lib/qr/request";
|
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||||
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
|
||||||
import { generateSlug } from "@/lib/slug";
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { attachUploads } from "@/lib/uploads";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,8 +29,47 @@ const createSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.optional(),
|
.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. */
|
||||||
|
domainId: z.string().trim().min(1).optional(),
|
||||||
|
/**
|
||||||
|
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
|
||||||
|
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
|
||||||
|
* statischer Code in der Gruppe liegt.
|
||||||
|
*/
|
||||||
|
allowDuplicate: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Dateien, die zu einem Code gehören.
|
||||||
|
*
|
||||||
|
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
||||||
|
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
||||||
|
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
||||||
|
*/
|
||||||
|
function dateienDesCodes(
|
||||||
|
qrType: { uploadIds: (data: never) => string[] },
|
||||||
|
data: unknown,
|
||||||
|
design: { logoSource: string | null; logoUploadId: string | null },
|
||||||
|
): string[] {
|
||||||
|
const ids = qrType.uploadIds(data as never);
|
||||||
|
return design.logoSource === "upload" && design.logoUploadId
|
||||||
|
? [...ids, design.logoUploadId]
|
||||||
|
: ids;
|
||||||
|
}
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
@@ -32,10 +79,26 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alle dynamischen Codes, neueste zuerst. */
|
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
|
const actor = await getActor();
|
||||||
|
if (actor.kind === "anonymous") {
|
||||||
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = visibleGroupIds(actor);
|
||||||
|
const codes = await prisma.qrCode.findMany({
|
||||||
|
where: visible === null ? undefined : { groupId: { in: visible } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return Response.json({ codes: codes.map(toQrCodeDto) });
|
return Response.json({ codes: codes.map(toQrCodeDto) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -45,44 +108,141 @@ export async function GET(): Promise<Response> {
|
|||||||
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
|
||||||
const parsed = createSchema.safeParse(await request.json());
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, design, label } = parsed.data;
|
const { type, design, label } = parsed.data;
|
||||||
const qrType = getActiveQrType(type);
|
|
||||||
if (!qrType) {
|
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
|
||||||
throw new QrRequestError(
|
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
||||||
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
|
requirePermission(actor, "codes:create", { groupId });
|
||||||
404,
|
|
||||||
);
|
// Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar
|
||||||
}
|
// ist — und der Slug ist nur je Domain eindeutig.
|
||||||
if (!qrType.supportsDynamic) {
|
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
||||||
throw new QrRequestError(
|
|
||||||
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
|
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
|
||||||
);
|
const qrType = await requireAvailableQrType(type, groupId);
|
||||||
}
|
|
||||||
|
|
||||||
const validated = qrType.validate(parsed.data.data);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return jsonError(validated.message);
|
return jsonError(validated.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
|
||||||
|
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
|
||||||
|
// eines stillen zweiten Eintrags.
|
||||||
|
if (istStatisch && !parsed.data.allowDuplicate) {
|
||||||
|
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
|
||||||
|
if (dublette) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: dublette.label
|
||||||
|
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
|
||||||
|
: "Diesen Inhalt hast du schon abgelegt.",
|
||||||
|
duplicate: dublette,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, dateienDesCodes(qrType, validated.data, design));
|
||||||
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const created = await prisma.qrCode.create({
|
const created = await prisma.qrCode.create({
|
||||||
data: {
|
data: {
|
||||||
slug: generateSlug(),
|
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||||
type: qrType.id,
|
type: qrType.id,
|
||||||
label: label && label.length > 0 ? label : null,
|
label: label && label.length > 0 ? label : null,
|
||||||
data: toJsonInput(validated.data),
|
data: toJsonInput(validated.data),
|
||||||
|
groupId,
|
||||||
|
domainId,
|
||||||
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||||
...design,
|
...design,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
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 —
|
||||||
|
// vorher lagen sie ohne Bezug herum.
|
||||||
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
} catch (error) {
|
} 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;
|
if (!isUniqueViolation(error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beweis-Route für den Domain-Check.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
|
||||||
|
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
|
||||||
|
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
|
||||||
|
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
|
||||||
|
* nichts über das Geheimnis.
|
||||||
|
*
|
||||||
|
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
|
||||||
|
|
||||||
|
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
|
||||||
|
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
|
||||||
|
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Es fehlt eine gültige Zufallszahl." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proof = bildeBeweis(await getCheckSecret(), nonce);
|
||||||
|
return Response.json(
|
||||||
|
{ app: "qrcode-generator", proof },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Domain-Check konnte keinen Beweis bilden:", error);
|
||||||
|
return Response.json({ error: "Nicht möglich." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const feedbackSchema = z.object({
|
||||||
|
codeId: z.string().trim().min(1),
|
||||||
|
rating: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, { error: "Bitte wähle eine Bewertung." })
|
||||||
|
.max(5, { error: "Bitte wähle eine Bewertung." }),
|
||||||
|
comment: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1000, { error: "Der Text ist zu lang (höchstens 1000 Zeichen)." })
|
||||||
|
.default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grobe Bremse gegen Fluten.
|
||||||
|
*
|
||||||
|
* Im Arbeitsspeicher und damit pro Container — bei einem Dienst dieser Größe
|
||||||
|
* genügt das. Die Adresse wird nur als Schlüssel benutzt und nirgends
|
||||||
|
* gespeichert; nach dem Zeitfenster ist sie wieder weg.
|
||||||
|
*/
|
||||||
|
const FENSTER_MS = 60_000;
|
||||||
|
const MAX_PRO_FENSTER = 10;
|
||||||
|
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
||||||
|
|
||||||
|
function zuSchnell(schluessel: string): boolean {
|
||||||
|
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 wächst.
|
||||||
|
if (zaehler.size > 5000) {
|
||||||
|
for (const [key, wert] of zaehler) {
|
||||||
|
if (wert.bis < jetzt) zaehler.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
eintrag.anzahl += 1;
|
||||||
|
return eintrag.anzahl > MAX_PRO_FENSTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt eine Rückmeldung von einer öffentlichen Landingpage entgegen. */
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = feedbackSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const herkunft =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unbekannt";
|
||||||
|
|
||||||
|
if (zuSchnell(herkunft)) {
|
||||||
|
return jsonError(
|
||||||
|
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Codes, die tatsächlich Feedback einsammeln — sonst ließen sich
|
||||||
|
// beliebige Codes mit Einträgen zumüllen.
|
||||||
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id: parsed.data.codeId },
|
||||||
|
select: { id: true, type: true },
|
||||||
|
});
|
||||||
|
if (!code || code.type !== "feedback") {
|
||||||
|
return jsonError("Zu diesem Code lässt sich keine Rückmeldung abgeben.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.feedback.create({
|
||||||
|
data: {
|
||||||
|
qrCodeId: code.id,
|
||||||
|
rating: parsed.data.rating,
|
||||||
|
comment: parsed.data.comment.length > 0 ? parsed.data.comment : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import {
|
import {
|
||||||
downloadFilename,
|
downloadFilename,
|
||||||
@@ -17,6 +19,10 @@ const downloadSchema = qrRequestSchema.extend({
|
|||||||
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
|
||||||
|
// unter /r/{slug} bleibt davon unberührt und öffentlich.
|
||||||
|
requireSignedIn(await getActor());
|
||||||
|
|
||||||
const parsed = downloadSchema.safeParse(await request.json());
|
const parsed = downloadSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
@@ -31,17 +37,18 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { qrType, data, content } = resolveQrRequest(parsed.data);
|
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
||||||
|
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"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",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { renderSvg } from "@/lib/qr/render";
|
import { renderSvg } from "@/lib/qr/render";
|
||||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -10,13 +12,18 @@ import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
|
||||||
|
// unter /r/{slug} bleibt davon unberührt und öffentlich.
|
||||||
|
requireSignedIn(await getActor());
|
||||||
|
|
||||||
const parsed = qrRequestSchema.safeParse(await request.json());
|
const parsed = qrRequestSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content } = resolveQrRequest(parsed.data);
|
const { content } = await resolveQrRequest(parsed.data);
|
||||||
const svg = await renderSvg(content, parsed.data.design);
|
const logo = await ladeLogo(parsed.data.design);
|
||||||
|
const svg = await renderSvg(content, parsed.data.design, logo);
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine hochgeladene Datei aus.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Anmeldung: Die Landingpages sind öffentlich, und wer einen
|
||||||
|
* Code scannt, soll das hinterlegte PDF sehen können. Der Schutz liegt in der
|
||||||
|
* nicht erratbaren Id — dieselbe Überlegung wie beim Slug einer Kurz-URL.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const upload = await prisma.upload.findUnique({ where: { id } });
|
||||||
|
if (!upload) {
|
||||||
|
return new Response("Datei nicht gefunden", {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `?download` erzwingt das Speichern statt der Anzeige.
|
||||||
|
const alsDownload = request.nextUrl.searchParams.has("download");
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(upload.data), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": upload.mimeType,
|
||||||
|
"Content-Length": String(upload.size),
|
||||||
|
// Der Typ steht fest — der Browser soll nicht raten und daraus
|
||||||
|
// womöglich etwas Ausführbares machen.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
// 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'; 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
|
||||||
|
import {
|
||||||
|
cleanupOrphanedUploads,
|
||||||
|
isUploadKind,
|
||||||
|
maxUploadBytes,
|
||||||
|
storeUpload,
|
||||||
|
uploadPurposeOf,
|
||||||
|
} from "@/lib/uploads";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt eine Datei entgegen und gibt ihre Id zurück.
|
||||||
|
*
|
||||||
|
* Der Upload passiert vor dem Speichern des Codes — anders geht es nicht, das
|
||||||
|
* Formular braucht die Id, um sie im Code zu hinterlegen. Dateien ohne Code
|
||||||
|
* bleiben deshalb erst einmal liegen und werden später weggeräumt.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
|
||||||
|
// die Herkunft deshalb selbst.
|
||||||
|
assertSameOrigin(request.headers);
|
||||||
|
|
||||||
|
const actor = await getActor();
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Die Datei konnte nicht gelesen werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = form.get("kind");
|
||||||
|
if (!isUploadKind(kind)) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = await storeUpload(file, kind);
|
||||||
|
|
||||||
|
// Beiläufig aufräumen. Scheitert das, ist der Upload trotzdem gültig.
|
||||||
|
try {
|
||||||
|
await cleanupOrphanedUploads();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Verwaiste Uploads konnten nicht entfernt werden:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ upload }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damit die Oberfläche die Grenze nennen kann, bevor jemand wählt. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
return Response.json({ maxBytes: maxUploadBytes() });
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
|
||||||
import { QrGenerator } from "@/components/qr-generator";
|
|
||||||
import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry";
|
|
||||||
|
|
||||||
type Props = { params: Promise<{ type: string }> };
|
|
||||||
|
|
||||||
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return QR_TYPE_IDS.map((type) => ({ type }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const { type } = await params;
|
|
||||||
const qrType = getQrType(type);
|
|
||||||
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function CreatePage({ params }: Props) {
|
|
||||||
const { type } = await params;
|
|
||||||
const qrType = getQrType(type);
|
|
||||||
|
|
||||||
if (!qrType) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registrierte, aber noch nicht implementierte Typen bekommen eine
|
|
||||||
// erklärende Seite statt einer 404.
|
|
||||||
if (!getActiveQrType(type)) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
|
|
||||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
|
||||||
<Icon name={qrType.icon} className="size-7" />
|
|
||||||
</span>
|
|
||||||
<h1 className="mt-5 text-2xl font-bold text-slate-900">
|
|
||||||
„{qrType.title}“ ist bald verfügbar
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-slate-600">
|
|
||||||
Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“
|
|
||||||
zur Verfügung.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/erstellen/website"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
Website-QR-Code erstellen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <QrGenerator typeId={qrType.id} />;
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,81 @@
|
|||||||
monospace;
|
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 {
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-slate-50);
|
background-color: var(--color-slate-50);
|
||||||
color: var(--color-slate-900);
|
color: var(--color-slate-900);
|
||||||
@@ -22,7 +93,7 @@ body {
|
|||||||
|
|
||||||
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-indigo-600);
|
outline: 2px solid var(--color-accent-text);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,72 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||||
|
import { accentCss } from "@/lib/branding-colors";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
// 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: {
|
title: {
|
||||||
default: "QR-Code-Generator",
|
default: branding.appName,
|
||||||
template: "%s · QR-Code-Generator",
|
template: `%s · ${branding.appName}`,
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
"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`, `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 async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" className="h-full">
|
<html lang="de" className="h-full">
|
||||||
<body className="flex min-h-full flex-col">
|
<body className="flex min-h-full flex-col">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
{/*
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
|
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
|
||||||
<Link
|
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||||
href="/"
|
`light-dark()` im CSS von allein.
|
||||||
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
*/}
|
||||||
>
|
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
{/*
|
||||||
<Icon name="qr" className="size-5" />
|
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||||
</span>
|
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||||
QR-Code-Generator
|
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
|
||||||
</Link>
|
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
|
||||||
<nav className="flex items-center gap-1 text-sm font-medium">
|
die Voreinstellung zurück.
|
||||||
<Link
|
*/}
|
||||||
href="/"
|
<style
|
||||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||||
>
|
/>
|
||||||
Erstellen
|
{children}
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/meine-codes"
|
|
||||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
>
|
|
||||||
Meine Codes
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-white">
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
|
||||||
QR-Code-Generator · Version 1 — QR-Typ „Website/URL“
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { CodesList } from "@/components/codes-list";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Meine Codes" };
|
|
||||||
|
|
||||||
export default function MyCodesPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
|
||||||
<div className="mb-7">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
|
||||||
Meine Codes
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-slate-600">
|
|
||||||
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
|
||||||
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
|
||||||
gespeichert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CodesList />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
|
||||||
import { Badge } from "@/components/ui";
|
|
||||||
import { qrTypeList } from "@/lib/qr/registry";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
|
|
||||||
Was möchtest du erstellen?
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-base text-slate-600">
|
|
||||||
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
|
|
||||||
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
|
|
||||||
später änderst, ohne den Code neu drucken zu müssen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{qrTypeList.map((type) => {
|
|
||||||
const tile = (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "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-slate-100 text-slate-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name={type.icon} className="size-6" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "font-semibold text-slate-900"
|
|
||||||
: "font-semibold text-slate-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.title}
|
|
||||||
</span>
|
|
||||||
{type.active ? (
|
|
||||||
<Badge tone="popular">Beliebt</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge tone="soon">Bald verfügbar</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "mt-1 block text-sm text-slate-600"
|
|
||||||
: "mt-1 block text-sm text-slate-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.description}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={type.id}>
|
|
||||||
{type.active ? (
|
|
||||||
<Link
|
|
||||||
href={`/erstellen/${type.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"
|
|
||||||
>
|
|
||||||
{tile}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
aria-disabled="true"
|
|
||||||
title={`„${type.title}“ folgt in Version 2`}
|
|
||||||
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
|
|
||||||
>
|
|
||||||
{tile}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p className="mt-8 text-sm text-slate-500">
|
|
||||||
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in
|
|
||||||
Version 2.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import { isValidSlug } from "@/lib/slug";
|
import { isValidSlug } from "@/lib/slug";
|
||||||
|
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 {
|
function notFound(message: string): Response {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -34,18 +38,40 @@ function notFound(message: string): Response {
|
|||||||
*
|
*
|
||||||
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
||||||
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
||||||
|
*
|
||||||
|
* Der Slug ist nur je Domain eindeutig, deshalb wird zuerst die Domain aus
|
||||||
|
* dem `Host`-Header bestimmt. Ein unbekannter Hostname endet hier — sonst
|
||||||
|
* ließe sich über einen gefälschten Header ein fremder Code ansprechen.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: Context,
|
context: Context,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { slug } = await context.params;
|
const abschnitte = (await context.params).slug;
|
||||||
|
const slug = (abschnitte ?? []).join("/");
|
||||||
|
|
||||||
if (!isValidSlug(slug)) {
|
if (!isValidSlug(slug)) {
|
||||||
return notFound("Diese Kurz-Adresse ist ungültig.");
|
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await prisma.qrCode.findUnique({ where: { slug } });
|
const matched = await resolveRequestDomain(request.headers);
|
||||||
|
|
||||||
|
// Aufruf über eine nicht registrierte Adresse — etwa direkt über die IP im
|
||||||
|
// LAN. Dann gilt die Standard-Domain aus BASE_URL.
|
||||||
|
const domain = matched ?? {
|
||||||
|
...(await ensureDefaultDomain()),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!domain.isActive) {
|
||||||
|
return notFound(
|
||||||
|
"Diese Adresse ist zurzeit abgeschaltet. Bitte wende dich an denjenigen, der den Code erstellt hat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gefunden = await findCodeBySlug(domain.id, slug);
|
||||||
|
const code = gefunden?.code;
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return notFound(
|
return notFound(
|
||||||
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
||||||
@@ -58,11 +84,22 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let target: string;
|
let target: string;
|
||||||
|
if (qrType.hasLandingPage) {
|
||||||
|
// 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/${code.slug}`;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
target = qrType.redirectTarget(code.data);
|
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
||||||
|
// wohin die Weiterleitung führt.
|
||||||
|
target = qrType.redirectTarget(code.data, {
|
||||||
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
|
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
|
||||||
try {
|
try {
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { qrLandings } from "@/components/landing";
|
||||||
|
import { LandingNotice } from "@/components/landing/shell";
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
|
||||||
|
type PageProps = { params: Promise<{ slug: string[] }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Landingpage eines dynamischen Codes.
|
||||||
|
*
|
||||||
|
* Hierher schickt `/r/[slug]` alle Typen, die keine fremde Adresse haben,
|
||||||
|
* sondern eine Seite: Linksammlungen, hinterlegte Dateien, Feedback.
|
||||||
|
*
|
||||||
|
* 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(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 (await findCodeBySlug(domain.id, slug))?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const code = await loadCode(slug);
|
||||||
|
const qrType = code ? getQrType(code.type) : undefined;
|
||||||
|
|
||||||
|
if (!code || !qrType?.hasLandingPage) {
|
||||||
|
return { title: "Nicht gefunden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = qrType.title;
|
||||||
|
try {
|
||||||
|
title = qrType.summary(code.data);
|
||||||
|
} catch {
|
||||||
|
// Daten passen nicht mehr zum Schema — der Typname tut es auch.
|
||||||
|
}
|
||||||
|
// Absolut, damit der Titel nicht mit dem Anwendungsnamen ergänzt wird:
|
||||||
|
// Wer hier landet, hat mit dem Generator nichts zu tun.
|
||||||
|
return { title: { absolute: title } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LandingPage({ params }: PageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const code = await loadCode(slug);
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return (
|
||||||
|
<LandingNotice title="Diese Seite gibt es nicht">
|
||||||
|
Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an
|
||||||
|
denjenigen, der ihn erstellt hat.
|
||||||
|
</LandingNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrType = getQrType(code.type);
|
||||||
|
const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined;
|
||||||
|
|
||||||
|
if (!qrType || !Landing) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte Datensätze können nach einer Schema-Änderung unbrauchbar sein. Das
|
||||||
|
// ist kein Grund, den Besuchern einen Absturz zu zeigen.
|
||||||
|
if (!qrType.validate(code.data).ok) {
|
||||||
|
return (
|
||||||
|
<LandingNotice title="Diese Seite ist gerade nicht verfügbar">
|
||||||
|
Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an
|
||||||
|
denjenigen, der den Code erstellt hat.
|
||||||
|
</LandingNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
label={code.label}
|
||||||
|
slug={code.slug}
|
||||||
|
codeId={code.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button, Card, CardTitle, Field, Notice } from "@/components/ui";
|
||||||
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ minLength }: { minLength: number }) {
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const mismatch = repeat.length > 0 && next !== repeat;
|
||||||
|
const tooShort = next.length > 0 && next.length < minLength;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
action: "change-password",
|
||||||
|
currentPassword: current,
|
||||||
|
newPassword: next,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setRepeat("");
|
||||||
|
setDone(true);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Das Passwort konnte nicht geändert werden.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Nach der Änderung wirst du auf allen anderen Geräten abgemeldet.">
|
||||||
|
Passwort ändern
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mismatch || tooShort) return;
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
{done ? (
|
||||||
|
<Notice tone="success">
|
||||||
|
Das Passwort wurde geändert. Andere Geräte wurden abgemeldet.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Bisheriges Passwort" htmlFor="current-password">
|
||||||
|
<PasswordInput
|
||||||
|
id="current-password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={current}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Neues Passwort"
|
||||||
|
htmlFor="next-password"
|
||||||
|
error={tooShort ? `Mindestens ${minLength} Zeichen.` : null}
|
||||||
|
hint={`Mindestens ${minLength} Zeichen.`}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="next-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={tooShort}
|
||||||
|
value={next}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setNext(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Neues Passwort wiederholen"
|
||||||
|
htmlFor="repeat-password"
|
||||||
|
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="repeat-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={mismatch}
|
||||||
|
value={repeat}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={pending || mismatch || tooShort}>
|
||||||
|
{pending ? "Wird geändert …" : "Passwort ändern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutCard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Beendet die Sitzung auf diesem Gerät.">Abmelden</CardTitle>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "logout" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// refresh() verwirft den serverseitig gerenderten Kopfbereich —
|
||||||
|
// sonst stünde dort noch der abgemeldete Name.
|
||||||
|
router.replace("/anmelden");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="lock" className="size-4" />
|
||||||
|
{pending ? "Wird abgemeldet …" : "Jetzt abmelden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
||||||
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
|
||||||
|
function useSubmit() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (json: unknown) => {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", { method: "POST", json });
|
||||||
|
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
|
||||||
|
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
|
||||||
|
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
|
||||||
|
router.replace("/");
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Anmeldung ist fehlgeschlagen.",
|
||||||
|
);
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { submit, pending, error, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const { submit, pending, error } = useSubmit();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit({ action: "login", email, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
<Field label="E-Mail-Adresse" htmlFor="login-email">
|
||||||
|
<TextInput
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Passwort" htmlFor="login-password">
|
||||||
|
<PasswordInput
|
||||||
|
id="login-password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird angemeldet …" : "Anmelden"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupForm({ minLength }: { minLength: number }) {
|
||||||
|
const { submit, pending, error } = useSubmit();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
|
||||||
|
const mismatch = repeat.length > 0 && password !== repeat;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mismatch) return;
|
||||||
|
void submit({ action: "setup", email, name, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
<Field label="Name (optional)" htmlFor="setup-name">
|
||||||
|
<TextInput
|
||||||
|
id="setup-name"
|
||||||
|
autoComplete="name"
|
||||||
|
value={name}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="E-Mail-Adresse" htmlFor="setup-email">
|
||||||
|
<TextInput
|
||||||
|
id="setup-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Passwort"
|
||||||
|
htmlFor="setup-password"
|
||||||
|
hint={`Mindestens ${minLength} Zeichen.`}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="setup-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={minLength}
|
||||||
|
value={password}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Passwort wiederholen"
|
||||||
|
htmlFor="setup-repeat"
|
||||||
|
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="setup-repeat"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={mismatch}
|
||||||
|
value={repeat}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending || mismatch}>
|
||||||
|
{pending ? "Wird angelegt …" : "Administrator anlegen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "logout" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
router.replace("/anmelden");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "…" : "Abmelden"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,20 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
import { qrForms } from "@/components/qr-forms";
|
import { qrForms } from "@/components/qr-forms";
|
||||||
import { Badge, Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Field,
|
||||||
|
Notice,
|
||||||
|
Select,
|
||||||
|
TextInput,
|
||||||
|
cx,
|
||||||
|
} from "@/components/ui";
|
||||||
import { ApiError, apiJson } from "@/lib/api-client";
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
import type { QrCodeDto } from "@/lib/dto";
|
import type { QrCodeDto } from "@/lib/dto";
|
||||||
import { qrTypes } from "@/lib/qr/registry";
|
import { qrTypes } from "@/lib/qr/registry";
|
||||||
|
import { describeSlugProblem } from "@/lib/slug";
|
||||||
import type { QrFormData } from "@/lib/qr/type-module";
|
import type { QrFormData } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
@@ -17,16 +27,66 @@ const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
|||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shortDateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
return dateFormat.format(new Date(value));
|
return dateFormat.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodesList() {
|
function formatShortDate(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return shortDateFormat.format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = "list" | "cards";
|
||||||
|
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
||||||
|
/** Betriebsart, auf die die Liste eingeschränkt ist. */
|
||||||
|
type ModeFilter = "alle" | "dynamic" | "static";
|
||||||
|
|
||||||
|
const SORT_LABELS: Record<SortKey, string> = {
|
||||||
|
neueste: "Neueste zuerst",
|
||||||
|
aelteste: "Älteste zuerst",
|
||||||
|
name: "Name (A–Z)",
|
||||||
|
scans: "Meiste Scans",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||||
|
alle: "Alle Arten",
|
||||||
|
dynamic: "Nur dynamische",
|
||||||
|
static: "Nur statische",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||||
|
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||||
|
|
||||||
|
function readStoredView(): View {
|
||||||
|
if (typeof window === "undefined") return "list";
|
||||||
|
return window.localStorage.getItem(VIEW_STORAGE_KEY) === "cards"
|
||||||
|
? "cards"
|
||||||
|
: "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name, unter dem ein Code in der Liste geführt wird. */
|
||||||
|
function displayName(code: QrCodeDto): string {
|
||||||
|
return code.label?.trim() || code.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [editing, setEditing] = useState<QrCodeDto | 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 [editData, setEditData] = useState<QrFormData>({});
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
@@ -38,6 +98,32 @@ export function CodesList() {
|
|||||||
|
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [view, setView] = useState<View>("list");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [sort, setSort] = useState<SortKey>("neueste");
|
||||||
|
const [groupFilter, setGroupFilter] = useState("alle");
|
||||||
|
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
|
||||||
|
|
||||||
|
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
||||||
|
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setView(readStoredView());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeView = (next: View) => {
|
||||||
|
setView(next);
|
||||||
|
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeModeFilter = (next: ModeFilter) => {
|
||||||
|
setModeFilter(next);
|
||||||
|
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
|
||||||
|
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
|
||||||
|
// eine Reihenfolge an, die es dort gar nicht gibt.
|
||||||
|
if (next === "static" && sort === "scans") setSort("neueste");
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||||
@@ -64,9 +150,21 @@ export function CodesList() {
|
|||||||
setEditing(code);
|
setEditing(code);
|
||||||
setEditData(code.data);
|
setEditData(code.data);
|
||||||
setEditLabel(code.label ?? "");
|
setEditLabel(code.label ?? "");
|
||||||
|
setEditSlug(code.slug ?? "");
|
||||||
|
setEditAliases(code.aliases);
|
||||||
setEditError(null);
|
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 EditForm = editing ? qrForms[editing.type] : undefined;
|
||||||
const editValidation = useMemo(
|
const editValidation = useMemo(
|
||||||
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
||||||
@@ -79,12 +177,29 @@ export function CodesList() {
|
|||||||
setEditError(editValidation.message);
|
setEditError(editValidation.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (slugProblem) {
|
||||||
|
setEditError(slugProblem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||||
method: "PATCH",
|
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);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -119,6 +234,7 @@ export function CodesList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copy = async (code: QrCodeDto) => {
|
const copy = async (code: QrCodeDto) => {
|
||||||
|
if (!code.shortUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.shortUrl);
|
await navigator.clipboard.writeText(code.shortUrl);
|
||||||
setCopiedId(code.id);
|
setCopiedId(code.id);
|
||||||
@@ -128,12 +244,191 @@ export function CodesList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* -------------------------------------------------- Filtern und Sortieren */
|
||||||
|
|
||||||
|
// Gruppen kommen aus den geladenen Codes — ein eigener Abruf wäre dafür
|
||||||
|
// unnötig, und Gruppen ohne Codes gehören hier ohnehin nicht in die Auswahl.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const code of codes ?? []) {
|
||||||
|
if (code.groupName) seen.set(code.groupId, code.groupName);
|
||||||
|
}
|
||||||
|
return [...seen].map(([id, name]) => ({ id, name })).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "de"),
|
||||||
|
);
|
||||||
|
}, [codes]);
|
||||||
|
|
||||||
|
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
|
||||||
|
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
|
||||||
|
// Arten in der Liste stehen.
|
||||||
|
const kinds = useMemo(() => {
|
||||||
|
let dynamisch = false;
|
||||||
|
let statisch = false;
|
||||||
|
for (const code of codes ?? []) {
|
||||||
|
if (code.mode === "static") statisch = true;
|
||||||
|
else dynamisch = true;
|
||||||
|
}
|
||||||
|
return { dynamisch, statisch, beide: dynamisch && statisch };
|
||||||
|
}, [codes]);
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
const filtered = (codes ?? []).filter((code) => {
|
||||||
|
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
||||||
|
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
|
||||||
|
if (!needle) return true;
|
||||||
|
return [
|
||||||
|
displayName(code),
|
||||||
|
code.target,
|
||||||
|
code.shortUrl,
|
||||||
|
code.slug,
|
||||||
|
qrTypes[code.type]?.title ?? code.type,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(needle);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = [...filtered];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
switch (sort) {
|
||||||
|
case "aelteste":
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
case "name":
|
||||||
|
return displayName(a).localeCompare(displayName(b), "de");
|
||||||
|
case "scans":
|
||||||
|
return b.scans - a.scans;
|
||||||
|
default:
|
||||||
|
return b.createdAt.localeCompare(a.createdAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [codes, query, sort, groupFilter, modeFilter]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- View */
|
/* ---------------------------------------------------------------- View */
|
||||||
|
|
||||||
if (codes === null) {
|
if (codes === null) {
|
||||||
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
|
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Herunterladen und Löschen — in beiden Ansichten dieselben Ziele. */
|
||||||
|
const pngHref = (code: QrCodeDto) =>
|
||||||
|
`/api/codes/${code.id}/qr?format=png&download=1`;
|
||||||
|
const svgHref = (code: QrCodeDto) =>
|
||||||
|
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
||||||
|
|
||||||
|
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
|
||||||
|
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
|
||||||
|
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
|
||||||
|
|
||||||
|
const toolbar = codes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-0 flex-1 basis-56">
|
||||||
|
<Icon
|
||||||
|
name="search"
|
||||||
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
type="search"
|
||||||
|
aria-label="Codes durchsuchen"
|
||||||
|
placeholder="Suchen nach Name, Ziel oder Kurz-URL …"
|
||||||
|
className="pl-9"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length > 1 ? (
|
||||||
|
<Select
|
||||||
|
aria-label="Nach Gruppe filtern"
|
||||||
|
className="!w-auto"
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="alle">Alle Gruppen</option>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{kinds.beide ? (
|
||||||
|
<Select
|
||||||
|
aria-label="Nach Art filtern"
|
||||||
|
className="!w-auto"
|
||||||
|
value={modeFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeModeFilter(event.target.value as ModeFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{MODE_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
aria-label="Sortierung"
|
||||||
|
className="!w-auto"
|
||||||
|
value={sort}
|
||||||
|
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||||
|
>
|
||||||
|
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||||
|
.filter((key) => key !== "scans" || !nurStatische)
|
||||||
|
.map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{SORT_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Ansicht"
|
||||||
|
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["list", "viewList", "Als Liste anzeigen"],
|
||||||
|
["cards", "viewGrid", "Als Karten anzeigen"],
|
||||||
|
] as const
|
||||||
|
).map(([value, icon, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={view === value}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
data-view={value}
|
||||||
|
onClick={() => changeView(value)}
|
||||||
|
className={cx(
|
||||||
|
"rounded-md p-2 transition-colors",
|
||||||
|
view === value
|
||||||
|
? "bg-accent-soft text-accent-soft-fg"
|
||||||
|
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name={icon} className="size-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultCount =
|
||||||
|
codes.length > 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 tabular-nums">
|
||||||
|
{visible.length === codes.length
|
||||||
|
? `${codes.length} ${codes.length === 1 ? "Code" : "Codes"}`
|
||||||
|
: `${visible.length} von ${codes.length} Codes`}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
|
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
|
||||||
@@ -159,11 +454,269 @@ export function CodesList() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{codes.map((code) => (
|
{toolbar}
|
||||||
|
{resultCount}
|
||||||
|
|
||||||
|
{codes.length > 0 && visible.length === 0 ? (
|
||||||
|
<Card className="text-center">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Kein Code passt zu Suche und Filtern.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setGroupFilter("alle");
|
||||||
|
setModeFilter("alle");
|
||||||
|
}}
|
||||||
|
className="font-medium text-accent-text underline"
|
||||||
|
>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------ Listenansicht */}
|
||||||
|
{view === "list" && visible.length > 0 ? (
|
||||||
|
<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. */}
|
||||||
|
{/* Feste Spaltenbreiten: so bekommt der Name den ganzen Rest und
|
||||||
|
kürzt selbst, statt die Tabelle breiter zu machen als die Seite. */}
|
||||||
|
<table className="w-full table-fixed text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||||
|
<th scope="col" className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="hidden w-44 px-3 py-2.5 md:table-cell lg:w-56">
|
||||||
|
Kurz-URL
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="hidden w-40 px-3 py-2.5 sm:table-cell lg:w-56">
|
||||||
|
Ziel
|
||||||
|
</th>
|
||||||
|
{groups.length > 1 ? (
|
||||||
|
<th scope="col" className="hidden w-32 px-3 py-2.5 lg:table-cell">
|
||||||
|
Gruppe
|
||||||
|
</th>
|
||||||
|
) : null}
|
||||||
|
{nurStatische ? null : (
|
||||||
|
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||||
|
Scans
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||||
|
>
|
||||||
|
Erstellt
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="w-20 py-2.5 pr-4 pl-3 text-right sm:w-36">
|
||||||
|
<span className="sr-only">Aktionen</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{visible.map((code) => (
|
||||||
|
<tr key={code.id} className="hover:bg-slate-50/70">
|
||||||
|
<td className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
{/* Reines Bild zum Wiedererkennen — zum Herunterladen
|
||||||
|
stehen PNG und SVG am Zeilenende. */}
|
||||||
|
<span className="qr-canvas size-8 shrink-0 rounded-md border border-slate-200 p-0.5 sm:size-10">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
|
||||||
|
<img
|
||||||
|
src={`/api/codes/${code.id}/qr`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
data-code-name
|
||||||
|
className="truncate font-medium text-slate-900"
|
||||||
|
>
|
||||||
|
{displayName(code)}
|
||||||
|
</div>
|
||||||
|
{/* Solange die Kurz-URL keine eigene Spalte hat, steht
|
||||||
|
sie hier — sie ist wichtiger als der Typ. */}
|
||||||
|
<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. */}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||||
|
{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">
|
||||||
|
{code.target ? (
|
||||||
|
<a
|
||||||
|
href={code.target}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={code.target}
|
||||||
|
className="block truncate text-slate-700 hover:underline"
|
||||||
|
>
|
||||||
|
{code.target.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"block truncate",
|
||||||
|
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||||
|
)}
|
||||||
|
title={code.summary}
|
||||||
|
>
|
||||||
|
{code.summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{groups.length > 1 ? (
|
||||||
|
<td className="hidden px-3 py-2.5 text-slate-600 lg:table-cell">
|
||||||
|
<span className="block truncate" title={code.groupName ?? undefined}>
|
||||||
|
{code.groupName ?? "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{nurStatische ? null : (
|
||||||
|
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||||
|
{code.mode === "static" ? (
|
||||||
|
<span
|
||||||
|
className="font-normal text-slate-400"
|
||||||
|
title="Statische Codes werden nicht gezählt"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
code.scans
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<td
|
||||||
|
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||||
|
title={`Erstellt: ${formatDate(code.createdAt)}\nLetzter Scan: ${formatDate(code.lastScanAt)}`}
|
||||||
|
>
|
||||||
|
{formatShortDate(code.createdAt)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2.5 pr-4 pl-3">
|
||||||
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
|
{/* Ein Feedback-Code ohne Weg zu den Antworten waere
|
||||||
|
nur die halbe Sache. */}
|
||||||
|
{code.type === "feedback" ? (
|
||||||
|
<Link
|
||||||
|
href={`/meine-codes/${code.id}/feedback`}
|
||||||
|
title="Rückmeldungen ansehen"
|
||||||
|
aria-label={`Rückmeldungen zu ${displayName(code)} ansehen`}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="star" className="size-4" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(code)}
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
{/* Auf dem Handy fehlt der Platz; dort führt der Weg zum
|
||||||
|
Download über die Kartenansicht. */}
|
||||||
|
<a
|
||||||
|
href={pngHref(code)}
|
||||||
|
title="Als PNG herunterladen"
|
||||||
|
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
|
||||||
|
>
|
||||||
|
PNG
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={svgHref(code)}
|
||||||
|
title="Als SVG herunterladen"
|
||||||
|
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
|
||||||
|
>
|
||||||
|
SVG
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleting(code);
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
title="Löschen"
|
||||||
|
aria-label={`${displayName(code)} löschen`}
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Icon name="trash" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------ Kartenansicht */}
|
||||||
|
{view === "cards" &&
|
||||||
|
visible.map((code) => (
|
||||||
<Card key={code.id}>
|
<Card key={code.id}>
|
||||||
<div className="flex flex-col gap-5 sm:flex-row">
|
<div className="flex flex-col gap-5 sm:flex-row">
|
||||||
<a
|
<a
|
||||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
href={pngHref(code)}
|
||||||
title="Als PNG herunterladen"
|
title="Als PNG herunterladen"
|
||||||
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
|
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
|
||||||
>
|
>
|
||||||
@@ -171,6 +724,7 @@ export function CodesList() {
|
|||||||
<img
|
<img
|
||||||
src={`/api/codes/${code.id}/qr`}
|
src={`/api/codes/${code.id}/qr`}
|
||||||
alt={`QR-Code für ${code.summary}`}
|
alt={`QR-Code für ${code.summary}`}
|
||||||
|
loading="lazy"
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@@ -184,6 +738,7 @@ export function CodesList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 text-sm">
|
<dl className="mt-3 space-y-2 text-sm">
|
||||||
|
{code.shortUrl ? (
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||||
<dd className="flex items-center gap-2">
|
<dd className="flex items-center gap-2">
|
||||||
@@ -191,7 +746,7 @@ export function CodesList() {
|
|||||||
href={code.shortUrl}
|
href={code.shortUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-mono text-xs break-all text-indigo-600 hover:underline"
|
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||||
>
|
>
|
||||||
{code.shortUrl}
|
{code.shortUrl}
|
||||||
</a>
|
</a>
|
||||||
@@ -208,6 +763,16 @@ export function CodesList() {
|
|||||||
</button>
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dt className="text-slate-500">Ziel:</dt>
|
<dt className="text-slate-500">Ziel:</dt>
|
||||||
@@ -241,6 +806,15 @@ export function CodesList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{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-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
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -248,18 +822,18 @@ export function CodesList() {
|
|||||||
onClick={() => openEdit(code)}
|
onClick={() => openEdit(code)}
|
||||||
>
|
>
|
||||||
<Icon name="pencil" className="size-3.5" />
|
<Icon name="pencil" className="size-3.5" />
|
||||||
Ziel bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
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" />
|
<Icon name="download" className="size-3.5" />
|
||||||
PNG
|
PNG
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
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" />
|
<Icon name="download" className="size-3.5" />
|
||||||
SVG
|
SVG
|
||||||
@@ -286,7 +860,7 @@ export function CodesList() {
|
|||||||
{/* ------------------------------------------------------ Bearbeiten */}
|
{/* ------------------------------------------------------ Bearbeiten */}
|
||||||
<Modal
|
<Modal
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title="Ziel bearbeiten"
|
title="Code bearbeiten"
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -307,15 +881,12 @@ export function CodesList() {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
|
{editing.mode === "static"
|
||||||
auf{" "}
|
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||||
<code className="font-mono text-xs break-all">
|
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||||
{editing.shortUrl}
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{EditForm ? (
|
{editing.mode === "static" ? null : EditForm ? (
|
||||||
<EditForm
|
<EditForm
|
||||||
value={editData}
|
value={editData}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -331,6 +902,95 @@ export function CodesList() {
|
|||||||
</Notice>
|
</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">
|
<Field label="Name (optional)" htmlFor="edit-label">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="edit-label"
|
id="edit-label"
|
||||||
|
|||||||