11 Commits
Author SHA1 Message Date
Florian Wilde e296b1e6c2 Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut.

PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank,
nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden
muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der
Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht
durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von
derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit
nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet.

Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte
oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde
Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie
einem anderen Code entziehen.

Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die
Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste
erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
2026-08-14 23:34:28 +02:00
Florian Wilde fdf6452b35 Typen Social Media und Video
Social Media buendelt Profile auf einer Seite. Wer „@firma" eintippt, meint
das Profil beim gewaehlten Netzwerk — daraus wird die volle Adresse. Statt
nachgezeichneter Markenlogos steht ein Kreis in der Hausfarbe mit Monogramm;
ein schlecht nachgebautes Logo sieht schlechter aus als ein sauberes Zeichen.

Video erkennt YouTube, Vimeo und direkte Dateien. Der Rahmen zum Anbieter
laedt erst auf Klick, und davor steht, dass dabei Daten uebertragen werden —
diese Seite bekommen Fremde nach einem Scan zu sehen, ungefragt einen
Drittanbieter einzubinden waere nicht in Ordnung. Videodateien laden mit
preload="none": wer unterwegs scannt, zahlt das Volumen.
2026-08-14 22:44:23 +02:00
Florian Wilde e627f7c372 Landingpages als eigene Gattung, erster Typ: Mehrere Links
Die sechs noch offenen Typen haben kein fremdes Ziel, auf das man
weiterleiten koennte — sie brauchen eine Seite. Dafuer bekommt der
Typ-Vertrag `hasLandingPage`: /r/[slug] schickt solche Codes auf /s/[slug],
statt `redirectTarget` zu befragen.

Die oeffentlichen Seiten duerfen die Navigation der Anwendung nicht zeigen —
wer einen Code scannt, hat mit „Verwaltung" nichts zu tun. Deshalb liegt das
App-Chrome jetzt in der Routengruppe (app); das Wurzel-Layout traegt nur noch
html und body. Die URLs aendern sich dadurch nicht.
2026-08-14 22:27:49 +02:00
Florian Wilde b3d386b0d2 Meine Codes: Listenansicht mit Suche, Sortierung und Gruppenfilter
Bei vielen Projekten ist die Kartenansicht unuebersichtlich. Die Liste ist
jetzt die Voreinstellung, die Karten bleiben ueber einen Schalter erreichbar;
die Wahl merkt sich der Browser.

Dazu Suche ueber Name, Ziel, Kurz-URL und Typ, Sortierung nach Datum, Name
oder Scans sowie ein Gruppenfilter, sobald Codes in mehr als einer Gruppe
liegen. Die Seite ist breiter als die uebrigen, damit die Namen Platz haben.

Die Tabelle hat feste Spaltenbreiten: mit automatischem Layout hat ihre
Eigenbreite die ganze Seite seitlich scrollbar gemacht, auch wenn der
Container sie eigentlich beschnitten hat. Auf schmalen Schirmen weichen die
hinteren Spalten, die Kurz-URL rueckt unter den Namen.
2026-08-14 15:35:05 +02:00
Florian Wilde cdda55f9b0 AUTH_ENABLED an den Container durchreichen
Der Schalter blieb wirkungslos: AUTH_ENABLED stand zwar in der .env und im
README, fehlte aber im environment-Block des app-Dienstes. Docker Compose
liest die .env nur, um Platzhalter wie \ zu ersetzen - in den
Container gelangt ausschliesslich, was unter environment: aufgefuehrt ist.
Wer die Anmeldung einschalten wollte, blieb deshalb ohne jede Rueckmeldung
im Zustand 'ohne Anmeldung' haengen.

Gefunden hat das Florian beim Benutzen. Meine Pruefungen liefen alle gegen
'next start' mit direkt gesetzter Umgebungsvariable und konnten die Luecke
zwischen .env und Container deshalb gar nicht sehen.

- AUTH_ENABLED steht jetzt im environment des app-Dienstes, mit Hinweis im
  docker-compose.yml, warum das Ergaenzen dort nicht vergessen werden darf.
- Neue Pruefung compose-env.mjs: vergleicht die dokumentierten Variablen mit
  dem aufgeloesten Compose-Setup und mit der Umgebung des laufenden
  Containers. Genau diese Klasse von Fehler faellt damit kuenftig auf.
- README: Hinweis, dass die .env von git pull nicht angefasst wird und die
  Zeile in aelteren Installationen fehlt, plus ein Befehl zum Nachpruefen.

Geprueft gegen den Container in beiden Stellungen: 63 Pruefungen fuer
Anmeldung und Rollen sowie 34 fuer Multi-Domain mit AUTH_ENABLED=true,
dazu 293 mit false.
2026-08-14 14:29:39 +02:00
Florian Wilde 6b2baa66c5 Passwoerter verdeckt, Kontobereich mit eigenem Passwortwechsel
Zwei berechtigte Rueckmeldungen von Florian:

1. In der Benutzerverwaltung standen die Passwortfelder auf type='text',
   das Passwort war also im Klartext lesbar - vor fremden Augen und in
   jeder Bildschirmuebertragung. Alle Passwortfelder nutzen jetzt ein
   gemeinsames PasswordInput: verdeckt als Voreinstellung, mit Auge zum
   Aufdecken. Weil ein Administrator das Passwort weitergeben muss, gibt es
   daneben einen Generator fuer ein zufaelliges, vorlesbares Passwort in
   Vierergruppen.

2. Es gab keinen Kontobereich. Neu ist /konto mit Profil, den eigenen
   Gruppen samt Rollenerklaerung, Passwortwechsel und Abmelden. Im
   Kopfbereich fuehrt ein Namenskuerzel dorthin. Ist die Anmeldung
   ausgeschaltet, steht dort 'Ohne Anmeldung' und die Seite erklaert, wie
   man sie einschaltet - vorher suchte man den Abmelden-Knopf vergeblich.

Dabei eine echte Luecke geschlossen: ein Benutzer konnte sein Passwort gar
nicht selbst aendern, nur ein Administrator konnte es setzen. Der Wechsel
verlangt das bisherige Passwort, lehnt ein identisches ab und beendet alle
anderen Sitzungen - die eigene bleibt bestehen.

Nebenbei: die Betriebsart-Knoepfe im Generator haben jetzt data-mode. Ueber
aria-pressed waren sie nicht mehr eindeutig, seit das Passwortfeld einen
eigenen Umschalter hat.

Geprueft: 63 Pruefungen fuer Anmeldung inkl. Passwortwechsel und Konto,
dazu 280 bestehende bei ausgeschalteter Anmeldung.
2026-08-14 13:46:21 +02:00
Florian Wilde 2a80f15e20 Phase B2: mehrere Domains fuer Kurz-URLs
Eine Domain kann mehreren Gruppen dienen (n:m ueber DomainGrant), und der
Slug eines Codes ist nur noch je Domain eindeutig - derselbe sprechende
Slug darf also auf zwei Domains liegen.

Sicherheitsrelevanter Kern: die Weiterleitung liest den Hostnamen aus
X-Forwarded-Host bzw. Host und prueft ihn gegen die Domain-Tabelle. Ein
unbekannter Hostname endet mit 404, faellt also auf die Standard-Domain
zurueck und findet dort nichts. Ueber einen gefaelschten Header laesst sich
damit keine fremde Domain ansprechen. Auch der Hostname fuer die Vorschau
kommt aus der Datenbank, nicht aus der Anfrage.

- Migration legt die Spalte erst nullable an, fuellt sie mit der
  Standard-Domain und macht sie danach verpflichtend; der globale
  Slug-Index weicht dem zusammengesetzten aus (domainId, slug).
- Verwaltung -> Domains: registrieren, Gruppen zuteilen, Voreinstellung je
  Gruppe, abschalten. Domains mit Codes lassen sich weder loeschen noch
  einer Gruppe entziehen, die noch Codes darunter hat - beides wuerde
  gedruckte Codes ins Leere laufen lassen.

Ein Fehler dabei gefunden und behoben: die Migration kennt BASE_URL nicht
und legt die Standard-Domain mit einem Platzhalter-Hostnamen an. Korrigiert
wurde er bisher nur beim Oeffnen der Domain-Verwaltung - auf einer frischen
Installation waeren also Kurz-URLs auf 'standard.invalid' entstanden.
ensureDefaultDomain() laeuft jetzt vor jeder Verwendung.

Geprueft: 34 neue Pruefungen fuer Host-Aufloesung, Spoofing, Slug je Domain
und Zuteilung; dazu 280 bestehende bei ausgeschalteter Anmeldung.
2026-08-14 13:10:12 +02:00
Florian Wilde f1cdb3da13 Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
2026-08-14 12:31:16 +02:00
Florian Wilde 94a373ff48 README: Schritt-fuer-Schritt-Anleitung zum Start
Acht nummerierte Schritte vom leeren Server bis zum ersten QR-Code, mit
den erwarteten Ausgaben von 'docker compose ps' und den Protokollzeilen,
an denen man erkennt, dass die Migration durchgelaufen ist.

Dazu zwei Abschnitte, die vorher fehlten:

- Betrieb hinter dem Nginx Proxy Manager samt der Felder, die im Proxy Host
  einzutragen sind, und dem Hinweis, dass BASE_URL dann die oeffentliche
  https-Adresse sein muss.
- Fehlersuche fuer die Faelle, die tatsaechlich vorkommen: fehlende .env,
  belegter Port 3000, unhealthy Datenbank, Passwort in DATABASE_URL passt
  nicht zu POSTGRES_PASSWORD, und der haeufigste Fehler ueberhaupt - eine
  BASE_URL auf localhost, wodurch dynamische Codes auf dem Handy ins Leere
  fuehren.
2026-08-14 11:47:44 +02:00
Florian Wilde 7f7cb6fa20 Phase A: acht weitere QR-Typen
E-Mail, SMS, WhatsApp, WLAN, Standort, vCard, Zahlung und App-Link sind
umgesetzt. Damit sind 9 der 15 Typen nutzbar.

Erweiterungen am Typ-Vertrag, die dafuer noetig waren:

- supportsStatic: nicht jeder Typ kann statisch. Beim App-Link steht das
  Ziel erst beim Scannen fest, also gibt es ihn nur dynamisch.
- RedirectContext: die Weiterleitung reicht den User-Agent durch, damit der
  App-Link iOS, Android und alles Uebrige unterscheiden kann.
- assertDynamicPossible(): supportsDynamic ist eine Eigenschaft des Typs,
  aber nicht jede Eingabe taugt dafuer. Ein GiroCode ist kein Web-Ziel,
  obwohl der Typ 'Zahlung' als Zahlungslink sehr wohl dynamisch sein kann.
  Ohne diese Probe liess sich ein Code speichern, der erst beim Scannen
  aufgefallen waere - also nach dem Drucken. Greift beim Anlegen und beim
  Bearbeiten.

Sorgfalt bei den Formaten, gesammelt in src/lib/qr/encoding.ts:

- WLAN maskiert ; , : \\ und setzt rein hexadezimale Werte in
  Anfuehrungszeichen, sonst deuten Geraete sie als Hexzahl.
- vCard 3.0 mit CRLF, Maskierung und Faltung nach 75 Oktetten, oktettweise
  gezaehlt, damit kein mehrbytiges Zeichen zerrissen wird.
- GiroCode nach EPC069-12 mit fester Feldreihenfolge, IBAN-Pruefsumme nach
  Modulo 97 und der 331-Byte-Grenze.
- Rufnummern werden nach E.164 normalisiert, '(0)' faellt weg.
- Standort nimmt Koordinaten auch aus einem Google-Maps- oder Apple-Link an.

Formular-Bausteine (Select, Textarea, Checkbox, FieldRow, FieldSet) fuer die
mehrfeldrigen Typen ergaenzt.

Geprueft: 225 Pruefungen, davon 80 neu - Dekodierung je Typ aus dem fertigen
PNG, Validierung, Betriebsarten, geraeteabhaengige Weiterleitung und die
Formulare im Browser.
2026-08-14 11:30:09 +02:00
Florian Wilde c6da20f419 Verwaltung: QR-Typen einzeln freigeben oder ausblenden
Neue Seite Verwaltung -> QR-Typen mit einem Schalter je Typ. Abgeschaltete
Typen verschwinden vollstaendig von der Startseite und lassen sich nicht
mehr neu anlegen.

- Neue Tabelle QrTypeSetting; fehlender Eintrag heisst freigegeben, damit
  eine frische Installation ohne Vorbelegung laeuft und spaetere Typen
  automatisch erscheinen.
- Registry-Feld 'active' heisst jetzt 'implemented' und beschreibt nur noch,
  ob der Typ ausprogrammiert ist. Ob er erscheint, entscheidet zusaetzlich
  die Freigabe aus der Datenbank (src/lib/qr/availability.ts).
- Bestehende dynamische Codes bleiben von einer Abschaltung unberuehrt: die
  Weiterleitung funktioniert weiter und Bearbeiten bleibt moeglich. Ein
  gedruckter Code darf durch einen Schalter nicht ungueltig werden.
- resolveQrRequest ist dadurch asynchron geworden; QrRequestError liegt jetzt
  in type-module.ts, sonst gaebe es einen Ringschluss mit availability.ts.
- Startseite meldet jetzt auch den Fall, dass nur noch Platzhalter
  freigegeben sind - vorher standen dort 14 tote Kacheln ohne Erklaerung.
- Kopfzeile: Schriftzug auf schmalen Geraeten ausgeblendet, sonst passte die
  dritte Navigationsschaltflaeche nicht mehr in die Zeile (7px Ueberlauf).
2026-08-14 10:48:21 +02:00
111 changed files with 11098 additions and 424 deletions
+13
View File
@@ -13,6 +13,19 @@ 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
# --- PostgreSQL ------------------------------------------------------------ # --- PostgreSQL ------------------------------------------------------------
POSTGRES_USER=qrcode POSTGRES_USER=qrcode
POSTGRES_PASSWORD=bitte-aendern POSTGRES_PASSWORD=bitte-aendern
+484 -22
View File
@@ -3,18 +3,20 @@
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.
--- ---
## 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)
- [Technischer Aufbau](#technischer-aufbau) - [Technischer Aufbau](#technischer-aufbau)
- [Entwicklung ohne Docker](#entwicklung-ohne-docker) - [Entwicklung ohne Docker](#entwicklung-ohne-docker)
- [API](#api) - [API](#api)
@@ -22,28 +24,245 @@ vorbereitet.
--- ---
## 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. Trage deshalb die LAN-Adresse des Servers oder die
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.
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 | an |
| 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 zur Adresse passen, unter der Besucher die Anwendung
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
> dynamische Codes ins interne Netz statt auf die Domain.
### 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.
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 +321,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 +357,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 +439,33 @@ 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. In Version 1 gilt das nur für „Website“. | 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“.
> **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,14 +476,185 @@ 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**.
**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.
Dazu kommen
- 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.
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.
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
> Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
> Benutzerverwaltung**. Wer die Anwendung erreicht, sieht und ändert alle Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
> Codes. Die App gehört daher nicht ungeschützt ins offene Internet.
--- ---
### 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.
---
## Technischer Aufbau ## Technischer Aufbau
### Stack ### Stack
@@ -340,6 +800,8 @@ 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 |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
Beispiel: Beispiel:
+8
View File
@@ -28,9 +28,17 @@ 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}
ports: ports:
# Direktes Port-Mapping, bewusst ohne Reverse Proxy. # Direktes Port-Mapping, bewusst ohne Reverse Proxy.
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -44,3 +44,4 @@
"unrs-resolver": true "unrs-resolver": true
} }
} }
@@ -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;
+206 -4
View File
@@ -10,15 +10,151 @@ 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.
passwordHash String
/// 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)
expiresAt DateTime
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// 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[]
}
/// 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[]
}
/// 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
}
/// 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 /// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
slug String
type String type String
label String? label String?
data Json data Json
@@ -32,5 +168,71 @@ model QrCode {
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[]
@@unique([domainId, slug])
@@index([createdAt])
@@index([groupId])
@@index([domainId])
}
/// 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())
/// 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)
@@index([qrCodeId])
@@index([createdAt]) @@index([createdAt])
} }
/// 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])
}
+53
View File
@@ -0,0 +1,53 @@
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 { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Anmelden" };
export const dynamic = "force-dynamic";
export default async function SignInPage() {
// Ohne Anmeldung gibt es hier nichts zu tun.
if (!isAuthEnabled()) redirect("/");
const actor = await getActor();
if (actor.kind === "user") redirect("/");
const setup = await isSetupOpen();
return (
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
<div className="text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Icon name={setup ? "lock" : "qr"} className="size-6" />
</span>
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
{setup ? "Ersteinrichtung" : "Anmelden"}
</h1>
<p className="mt-2 text-sm text-slate-600">
{setup
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
</p>
</div>
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
{setup ? (
<Notice tone="info">
Der erste Benutzer wird Administrator der Installation und darf
Gruppen, Benutzer und die QR-Typen verwalten.
</Notice>
) : (
<p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
Benutzer zurücksetzen.
</p>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
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 { 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) {
await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
if (!state) {
notFound();
}
if (!state.enabled) {
return (
<Hinweis
icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="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."
/>
);
}
return <QrGenerator typeId={state.qrType.id} />;
}
+27
View File
@@ -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>
);
}
+133
View File
@@ -0,0 +1,133 @@
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";
export const metadata: Metadata = { title: "Konto" };
export const dynamic = "force-dynamic";
export default async function AccountPage() {
const actor = await requirePageActor();
// 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-white/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>{" "}
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
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="popular">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() ? (
<>
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
<SignOutCard />
</>
) : null}
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import Link from "next/link";
import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy";
// 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");
return (
<div className="flex flex-1 flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{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-indigo-100 text-[0.7rem] font-semibold text-indigo-700 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-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes
</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-white 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>
);
}
@@ -1,12 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list"; import { CodesList } from "@/components/codes-list";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" }; export const metadata: Metadata = { title: "Meine Codes" };
export default function MyCodesPage() { export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können.
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl"> <h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Meine Codes Meine Codes
+128
View File
@@ -0,0 +1,128 @@
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } 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() {
await requirePageActor();
const types = await getVisibleQrTypes();
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-indigo-50 text-indigo-600"
: "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>
{usable ? (
<Badge tone="popular">Beliebt</Badge>
) : (
<Badge tone="soon">Bald verfügbar</Badge>
)}
</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-white 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,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>
);
}
+39
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+116
View File
@@ -0,0 +1,116 @@
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 { 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-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
<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] = await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
]);
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-white/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>
in der <code className="font-mono text-xs">.env</code> setzen und
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
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"
/>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
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 { 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 }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
}),
);
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-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} />
</div>
);
}
+160
View File
@@ -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);
}
}
+107
View File
@@ -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);
}
}
+143
View File
@@ -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);
}
}
+90
View File
@@ -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);
}
}
+56
View File
@@ -0,0 +1,56 @@
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 {
getQrTypeAvailability,
setQrTypeEnabled,
} 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." }),
});
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
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));
}
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
} catch (error) {
return errorResponse(error);
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
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()}`,
})
.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);
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
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()}`,
}),
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);
}
}
+252
View File
@@ -0,0 +1,252 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
verifyPassword,
} from "@/lib/auth/password";
import {
createSession,
destroyAllSessions,
destroySession,
} 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";
/** Sperrdauer nach zu vielen Fehlversuchen. */
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
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." }),
});
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()}`,
}),
});
const logoutSchema = z.object({ action: z.literal("logout") });
const changePasswordSchema = z.object({
action: z.literal("change-password"),
currentPassword: z.string().min(1, {
error: "Bitte gib dein bisheriges Passwort ein.",
}),
newPassword: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
}),
});
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);
} 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);
}
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,
);
}
const user = await prisma.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash: await hashPassword(input.password),
isInstanceAdmin: true,
},
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 });
}
async function handleLogin(
input: z.infer<typeof loginSchema>,
): Promise<Response> {
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, "scrypt$32768$8$1$AAAA$AAAA");
throw invalid;
}
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
);
throw new QrRequestError(
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
429,
);
}
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;
}
await prisma.user.update({
where: { id: user.id },
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+11 -2
View File
@@ -1,5 +1,7 @@
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 { errorResponse } from "@/lib/http"; import { errorResponse } from "@/lib/http";
@@ -27,11 +29,18 @@ 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,
@@ -40,7 +49,7 @@ export async function GET(
margin: code.margin, margin: code.margin,
}); });
const content = shortUrl(code.slug); const content = 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))
+51 -12
View File
@@ -2,17 +2,27 @@ 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 { attachUploads } 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 } },
} 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(),
@@ -25,7 +35,10 @@ const updateSchema = z.object({
}); });
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 +51,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 +67,35 @@ 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);
requirePermission(actor, "codes:update", { groupId: existing.groupId });
const update: Prisma.QrCodeUpdateInput = {}; const update: Prisma.QrCodeUpdateInput = {};
/** Dateien, die nach der Änderung zu diesem Code gehören. */
let neueDateien: string[] | null = null;
if (parsed.data.data !== undefined) { if (parsed.data.data !== undefined) {
const qrType = getActiveQrType(existing.type); // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
if (!qrType) { // Verwaltung abgeschalteter Typ soll bestehende Codes nicht
throw new QrRequestError( // unbearbeitbar machen — sie sind längst gedruckt.
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.", 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) {
@@ -88,7 +113,18 @@ export async function PATCH(
return jsonError("Es wurden keine Änderungen übermittelt."); return jsonError("Es wurden keine Änderungen übermittelt.");
} }
const updated = await prisma.qrCode.update({ where: { id }, data: update }); const updated = await prisma.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, neueDateien);
}
return Response.json({ code: toQrCodeDto(updated) }); return Response.json({ code: toQrCodeDto(updated) });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -101,7 +137,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) {
+56 -17
View File
@@ -1,14 +1,19 @@
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 { 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 { generateSlug } from "@/lib/slug"; import { generateSlug } from "@/lib/slug";
import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +26,10 @@ 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(),
/** 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(),
}); });
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
@@ -32,10 +41,25 @@ 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 } },
},
});
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,30 +69,33 @@ 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.`, const qrType = await requireAvailableQrType(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);
} }
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
// 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 {
@@ -78,9 +105,21 @@ export async function POST(request: NextRequest): Promise<Response> {
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 } },
},
}); });
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) { } catch (error) {
if (!isUniqueViolation(error)) throw error; if (!isUniqueViolation(error)) throw error;
+95
View File
@@ -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);
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
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 { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { import {
@@ -17,6 +18,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,7 +36,7 @@ export async function POST(request: NextRequest): Promise<Response> {
); );
} }
const { qrType, data, content } = resolveQrRequest(parsed.data); const { qrType, data, content } = await resolveQrRequest(parsed.data);
const body = const body =
format === "png" format === "png"
+6 -1
View File
@@ -1,5 +1,6 @@
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 { 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,12 +11,16 @@ 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 svg = await renderSvg(content, parsed.data.design);
return Response.json({ return Response.json({
+49
View File
@@ -0,0 +1,49 @@
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'",
"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",
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, jsonError } from "@/lib/http";
import {
cleanupOrphanedUploads,
isUploadKind,
maxUploadBytes,
storeUpload,
} 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 {
const actor = await getActor();
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
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.");
}
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() });
}
-56
View File
@@ -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 -39
View File
@@ -1,7 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -13,46 +11,18 @@ export const metadata: Metadata = {
"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.",
}; };
/**
* Wurzel-Layout — bewusst nur `html` und `body`.
*
* 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.
*/
export default function RootLayout({ children }: LayoutProps<"/">) { 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">{children}</body>
<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">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
QR-Code-Generator
</Link>
<nav className="flex items-center gap-1 text-sm font-medium">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Erstellen
</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>
</html> </html>
); );
} }
-93
View File
@@ -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>
);
}
+37 -3
View File
@@ -1,6 +1,7 @@
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";
@@ -34,9 +35,13 @@ 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 { slug } = await context.params;
@@ -45,7 +50,25 @@ export async function GET(
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 code = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
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 +81,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/${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 {
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
export const dynamic = "force-dynamic";
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(slug: string) {
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
return prisma.qrCode.findUnique({
where: { domainId_slug: { domainId: domain.id, slug } },
});
}
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>
);
}
return (
<Landing
data={code.data}
label={code.label}
slug={code.slug}
codeId={code.id}
/>
);
}
+158
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}
+438 -3
View File
@@ -6,7 +6,16 @@ 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";
@@ -17,11 +26,45 @@ 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));
} }
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";
const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst",
aelteste: "Älteste zuerst",
name: "Name (A–Z)",
scans: "Meiste Scans",
};
/** 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() { export function CodesList() {
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);
@@ -38,6 +81,23 @@ 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");
// 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 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");
@@ -128,12 +188,154 @@ 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]);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) 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]);
/* ---------------------------------------------------------------- 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`;
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}
<Select
aria-label="Sortierung"
className="!w-auto"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABELS) as SortKey[]).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-white 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-indigo-50 text-indigo-700"
: "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 +361,234 @@ 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 zur Suche.{" "}
<button
type="button"
onClick={() => {
setQuery("");
setGroupFilter("alle");
}}
className="font-medium text-indigo-600 underline"
>
Filter zurücksetzen
</button>
</p>
</Card>
) : null}
{/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? (
<div className="rounded-2xl border border-slate-200 bg-white 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}
<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. */}
<span className="font-mono md:hidden" title={code.shortUrl}>
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
</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">
<div className="flex items-center gap-1.5">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-xs text-indigo-600 hover:underline"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\//, "")}
</a>
<button
type="button"
onClick={() => void copy(code)}
title="Kurz-URL kopieren"
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<Icon
name={copiedId === code.id ? "check" : "copy"}
className="size-3.5"
/>
</button>
</div>
</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="block truncate 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}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{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="Ziel bearbeiten"
aria-label={`Ziel von ${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 +596,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>
@@ -241,6 +667,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-white 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"
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Grant = { groupId: string; groupName: string; isDefault: boolean };
type AdminDomain = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
codeCount: number;
grants: Grant[];
};
type GroupOption = { id: string; name: string };
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
const [groups, setGroups] = useState<GroupOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ hostname: "", label: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminDomain | null>(null);
const [grants, setGrants] = useState<Grant[]>([]);
const [grantError, setGrantError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{
domains: AdminDomain[];
groups: GroupOption[];
}>("/api/admin/domains");
setDomains(result.domains);
setGroups(result.groups);
setError(null);
} catch (caught) {
setDomains([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Domains konnten nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/domains", { method: "POST", json: form });
setCreating(false);
setForm({ hostname: "", label: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht angelegt werden.",
);
}
};
const patch = async (id: string, body: unknown, onDone?: () => void) => {
setBusy(id);
setError(null);
try {
await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body });
await load();
onDone?.();
return true;
} catch (caught) {
const message =
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.";
if (onDone) setGrantError(message);
else setError(message);
return false;
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<Notice tone="info">
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404.
</Notice>
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
</div>
{domains.map((domain) => (
<Card key={domain.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-mono font-semibold text-slate-900">
{domain.hostname}
</h2>
{domain.hostname === baseUrlHostname ? (
<Badge tone="popular">aus BASE_URL</Badge>
) : null}
{!domain.isActive ? (
<Badge tone="soon">abgeschaltet</Badge>
) : null}
<Badge tone="neutral">
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{domain.label ? (
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
) : null}
<p className="mt-2 text-sm text-slate-600">
{domain.grants.length === 0
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
: domain.grants
.map(
(grant) =>
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => {
setEditing(domain);
setGrants(domain.grants);
setGrantError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Gruppen zuteilen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
>
{domain.isActive ? "Abschalten" : "Einschalten"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === domain.id}
onClick={() => setDeleting(domain)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* --------------------------------------------------- Registrieren */}
<Modal
open={creating}
title="Domain registrieren"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Registrieren
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field
label="Hostname"
htmlFor="new-domain-host"
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
>
<TextInput
id="new-domain-host"
className="font-mono"
placeholder="qr.example.com"
value={form.hostname}
onChange={(event) =>
setForm({ ...form, hostname: event.target.value })
}
/>
</Field>
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
<TextInput
id="new-domain-label"
placeholder="Kundenprojekt Nord"
value={form.label}
onChange={(event) => setForm({ ...form, label: event.target.value })}
/>
</Field>
</div>
</Modal>
{/* ------------------------------------------------------- Zuteilung */}
<Modal
open={editing !== null}
title={`Gruppen für ${editing?.hostname ?? ""}`}
onClose={() => setEditing(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={busy === editing?.id}
onClick={() =>
editing &&
void patch(
editing.id,
{
grants: grants.map((grant) => ({
groupId: grant.groupId,
isDefault: grant.isDefault,
})),
},
() => setEditing(null),
)
}
>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
<p className="text-sm text-slate-600">
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
</p>
<ul className="space-y-2">
{groups.map((group) => {
const grant = grants.find((entry) => entry.groupId === group.id);
return (
<li
key={group.id}
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
>
<Checkbox
id={`grant-${group.id}`}
label={group.name}
checked={Boolean(grant)}
onChange={(event) =>
setGrants((current) =>
event.target.checked
? [
...current,
{
groupId: group.id,
groupName: group.name,
isDefault: false,
},
]
: current.filter((entry) => entry.groupId !== group.id),
)
}
/>
{grant ? (
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
className="size-3.5 rounded border-slate-300 accent-indigo-600"
checked={grant.isDefault}
onChange={(event) =>
setGrants((current) =>
current.map((entry) =>
entry.groupId === group.id
? { ...entry, isDefault: event.target.checked }
: entry,
),
)
}
/>
Voreinstellung
</label>
) : null}
</li>
);
})}
</ul>
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Domain löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="font-mono text-slate-900">
{deleting.hostname}
</strong>{" "}
wird entfernt.
{deleting.codeCount > 0
? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.`
: " Es liegen keine Codes darunter."}
</p>
) : null}
</Modal>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Field,
Notice,
Select,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Role = "ADMIN" | "EDITOR" | "VIEWER";
const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Administrator",
EDITOR: "Bearbeiter",
VIEWER: "Betrachter",
};
const ROLE_HINTS: Record<Role, string> = {
ADMIN: "darf auch Mitglieder verwalten",
EDITOR: "darf Codes anlegen und ändern",
VIEWER: "darf nur ansehen",
};
type Member = { userId: string; email: string; name: string | null; role: Role };
type AdminGroup = {
id: string;
name: string;
description: string | null;
codeCount: number;
canManage: boolean;
members: Member[];
};
type PickerUser = { id: string; email: string; name: string | null };
export function GroupAdmin({ canCreate }: { canCreate: boolean }) {
const [groups, setGroups] = useState<AdminGroup[] | null>(null);
const [users, setUsers] = useState<PickerUser[]>([]);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", description: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [memberError, setMemberError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<AdminGroup | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ groups: AdminGroup[] }>("/api/admin/groups");
setGroups(result.groups);
setError(null);
} catch (caught) {
setGroups([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppen konnten nicht geladen werden.",
);
}
if (canCreate) {
try {
const result = await apiJson<{ users: PickerUser[] }>("/api/admin/users");
setUsers(result.users);
} catch {
// Ohne Benutzerliste lässt sich zwar niemand hinzufügen, die
// Gruppenübersicht bleibt aber brauchbar.
}
}
}, [canCreate]);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/groups", { method: "POST", json: form });
setCreating(false);
setForm({ name: "", description: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht angelegt werden.",
);
}
};
const saveMembers = async () => {
if (!editing) return;
setSaving(true);
setMemberError(null);
try {
await apiJson(`/api/admin/groups/${editing.id}`, {
method: "PATCH",
json: { members: members.map((m) => ({ userId: m.userId, role: m.role })) },
});
setEditing(null);
await load();
} catch (caught) {
setMemberError(
caught instanceof ApiError
? caught.message
: "Die Mitglieder konnten nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/groups/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (groups === null) {
return <p className="text-sm text-slate-500">Gruppen werden geladen …</p>;
}
const available = users.filter(
(user) => !members.some((member) => member.userId === user.id),
);
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{canCreate ? (
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Gruppe anlegen
</Button>
</div>
) : null}
{groups.map((group) => (
<Card key={group.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-slate-900">{group.name}</h2>
<Badge tone="neutral">
{group.codeCount} {group.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{group.description ? (
<p className="mt-1 text-sm text-slate-600">{group.description}</p>
) : null}
<ul className="mt-3 space-y-1 text-sm">
{group.members.length === 0 ? (
<li className="text-slate-500">Noch keine Mitglieder.</li>
) : (
group.members.map((member) => (
<li key={member.userId} className="text-slate-700">
{member.name?.trim() || member.email}
<span className="text-slate-500">
{" "}
— {ROLE_LABELS[member.role]}
</span>
</li>
))
)}
</ul>
<div className="mt-4 flex flex-wrap gap-2">
{group.canManage ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setEditing(group);
setMembers(group.members);
setMemberError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Mitglieder verwalten
</Button>
) : null}
{canCreate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => setDeleting(group)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
) : null}
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------- Anlegen */}
<Modal
open={creating}
title="Gruppe anlegen"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Anlegen
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field label="Name" htmlFor="new-group-name">
<TextInput
id="new-group-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</Field>
<Field label="Beschreibung (optional)" htmlFor="new-group-description">
<TextInput
id="new-group-description"
value={form.description}
onChange={(event) =>
setForm({ ...form, description: event.target.value })
}
/>
</Field>
</div>
</Modal>
{/* ---------------------------------------------------- Mitglieder */}
<Modal
open={editing !== null}
title={`Mitglieder von „${editing?.name ?? ""}“`}
onClose={() => setEditing(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setEditing(null)}
disabled={saving}
>
Abbrechen
</Button>
<Button type="button" onClick={() => void saveMembers()} disabled={saving}>
{saving ? "Speichert …" : "Speichern"}
</Button>
</>
}
>
<div className="space-y-4">
{memberError ? <Notice tone="error">{memberError}</Notice> : null}
{members.length === 0 ? (
<p className="text-sm text-slate-500">
Diese Gruppe hat noch keine Mitglieder.
</p>
) : (
<ul className="space-y-2">
{members.map((member) => (
<li
key={member.userId}
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-2.5"
>
<span className="min-w-0 flex-1 truncate text-sm text-slate-800">
{member.name?.trim() || member.email}
</span>
<Select
aria-label={`Rolle von ${member.email}`}
className="!w-auto"
value={member.role}
onChange={(event) =>
setMembers((current) =>
current.map((entry) =>
entry.userId === member.userId
? { ...entry, role: event.target.value as Role }
: entry,
),
)
}
>
{(Object.keys(ROLE_LABELS) as Role[]).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]} — {ROLE_HINTS[role]}
</option>
))}
</Select>
<button
type="button"
aria-label={`${member.email} entfernen`}
onClick={() =>
setMembers((current) =>
current.filter((entry) => entry.userId !== member.userId),
)
}
className="rounded p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Icon name="trash" className="size-4" />
</button>
</li>
))}
</ul>
)}
{available.length > 0 ? (
<Field label="Mitglied hinzufügen" htmlFor="add-member">
<Select
id="add-member"
value=""
onChange={(event) => {
const user = users.find((u) => u.id === event.target.value);
if (!user) return;
setMembers((current) => [
...current,
{
userId: user.id,
email: user.email,
name: user.name,
role: "EDITOR",
},
]);
}}
>
<option value="">Benutzer wählen …</option>
{available.map((user) => (
<option key={user.id} value={user.id}>
{user.name?.trim() || user.email}
</option>
))}
</Select>
</Field>
) : null}
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Gruppe löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="text-slate-900">{deleting.name}</strong> wird
gelöscht und alle Mitgliedschaften entfallen.
{deleting.codeCount > 0
? ` Die Gruppe enthält noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt.`
: " Sie enthält keine Codes."}
</p>
) : null}
</Modal>
</div>
);
}
+43
View File
@@ -115,6 +115,49 @@ const PATHS = {
</> </>
), ),
check: <path d="m4.5 12.5 5 5 10-11" />, check: <path d="m4.5 12.5 5 5 10-11" />,
search: (
<>
<circle cx="10.8" cy="10.8" r="6.3" />
<path d="m15.5 15.5 5 5" />
</>
),
viewList: (
<>
<rect x="3" y="4.5" width="18" height="4" rx="1.2" />
<rect x="3" y="10" width="18" height="4" rx="1.2" />
<rect x="3" y="15.5" width="18" height="4" rx="1.2" />
</>
),
viewGrid: (
<>
<rect x="3.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.5" />
</>
),
eye: (
<>
<path d="M2.5 12s3.6-6.5 9.5-6.5S21.5 12 21.5 12s-3.6 6.5-9.5 6.5S2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</>
),
eyeOff: (
<>
<path d="M10.8 6.1A8.6 8.6 0 0 1 12 5.5c5.9 0 9.5 6.5 9.5 6.5a16 16 0 0 1-3 3.8" />
<path d="M6.5 6.9A15.7 15.7 0 0 0 2.5 12s3.6 6.5 9.5 6.5a8.8 8.8 0 0 0 4-1" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
<path d="m3 3 18 18" />
</>
),
dice: (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="3" />
<circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
</>
),
copy: ( copy: (
<> <>
<rect x="9" y="9" width="12" height="12" rx="2" /> <rect x="9" y="9" width="12" height="12" rx="2" />
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
export function FeedbackLanding({ data, codeId }: LandingProps) {
const { title, question, scale, allowComment, commentLabel, thankYou } =
feedbackDataSchema.parse(data);
const [note, setNote] = useState<number | null>(null);
const [kommentar, setKommentar] = useState("");
const [laeuft, setLaeuft] = useState(false);
const [fertig, setFertig] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const symbole = FEEDBACK_SCALES[scale].symbols;
async function absenden() {
if (note === null) return;
setLaeuft(true);
setFehler(null);
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId, rating: note, comment: kommentar }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
setFehler(body?.error ?? "Die Rückmeldung konnte nicht gesendet werden.");
return;
}
setFertig(true);
} catch {
setFehler(
"Die Rückmeldung konnte nicht gesendet werden. Besteht die Verbindung noch?",
);
} finally {
setLaeuft(false);
}
}
if (fertig) {
return (
<LandingShell title={title}>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-10 text-center">
<p className="text-4xl" aria-hidden="true">
✓
</p>
<p className="mt-3 text-lg font-medium text-emerald-900">
{thankYou}
</p>
</div>
</LandingShell>
);
}
return (
<LandingShell title={title}>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<fieldset>
<legend className="text-center text-base font-medium text-slate-900">
{question}
</legend>
<div className="mt-5 flex justify-center gap-1.5 sm:gap-2">
{symbole.map((symbol, index) => {
const wert = index + 1;
const gewaehlt = note !== null && note >= wert;
return (
<button
key={wert}
type="button"
aria-label={`${wert} von 5`}
aria-pressed={note === wert}
disabled={laeuft}
onClick={() => setNote(wert)}
className={
scale === "stars"
? `rounded-lg px-1 text-4xl transition-transform hover:scale-110 sm:text-5xl ${
gewaehlt ? "text-amber-400" : "text-slate-200"
}`
: `rounded-lg px-1 text-3xl transition-transform hover:scale-110 sm:text-4xl ${
note === wert
? "scale-110"
: "opacity-40 grayscale"
}`
}
>
{symbol}
</button>
);
})}
</div>
{note !== null ? (
<p className="mt-2 text-center text-sm text-slate-500">
{note} von 5
</p>
) : null}
</fieldset>
{allowComment ? (
<div className="mt-6">
<label
htmlFor="feedback-comment"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
{commentLabel}
</label>
<textarea
id="feedback-comment"
rows={3}
maxLength={1000}
value={kommentar}
disabled={laeuft}
onChange={(event) => setKommentar(event.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
placeholder="Optional"
/>
</div>
) : null}
{fehler ? (
<p role="alert" className="mt-4 text-sm text-red-600">
{fehler}
</p>
) : null}
<button
type="button"
disabled={note === null || laeuft}
onClick={() => void absenden()}
className="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{laeuft ? "Wird gesendet …" : "Absenden"}
</button>
<p className="mt-3 text-center text-xs text-slate-400">
Deine Rückmeldung wird ohne Namen und ohne IP-Adresse gespeichert.
</p>
</div>
</LandingShell>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { imageDataSchema } from "@/lib/qr/types/image";
export function ImageLanding({ data }: LandingProps) {
const { title, description, images } = imageDataSchema.parse(data);
return (
<LandingShell title={title || "Bilder"} subtitle={description || null}>
<div className="space-y-4">
{images.map((image, index) => (
<figure
key={image.fileId}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
>
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung ohne bekannte Maße; der Optimierer von Next
brächte hier nichts. */}
<img
src={`/api/uploads/${image.fileId}`}
alt={image.caption || `Bild ${index + 1}`}
// Das erste Bild sieht man sofort, die übrigen erst beim Scrollen.
loading={index === 0 ? "eager" : "lazy"}
className="block w-full"
/>
{image.caption ? (
<figcaption className="border-t border-slate-100 px-4 py-3 text-sm text-slate-600">
{image.caption}
</figcaption>
) : null}
</figure>
))}
</div>
</LandingShell>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import { FeedbackLanding } from "@/components/landing/feedback-landing";
import { ImageLanding } from "@/components/landing/image-landing";
import { LinksLanding } from "@/components/landing/links-landing";
import { PdfLanding } from "@/components/landing/pdf-landing";
import { SocialLanding } from "@/components/landing/social-landing";
import type { LandingProps } from "@/components/landing/types";
import { VideoLanding } from "@/components/landing/video-landing";
import type { QrTypeId } from "@/lib/qr/type-module";
/**
* Zuordnung QR-Typ → öffentliche Landingpage.
*
* Dritter Teil des Musters aus Typ-Registry (`src/lib/qr/registry.ts`) und
* Formular-Registry (`src/components/qr-forms/index.ts`): Ein Typ, der
* `hasLandingPage` meldet, braucht hier genau einen Eintrag.
*/
export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>> =
{
links: LinksLanding,
social: SocialLanding,
pdf: PdfLanding,
image: ImageLanding,
feedback: FeedbackLanding,
video: VideoLanding,
};
export type { LandingProps };
+34
View File
@@ -0,0 +1,34 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { linksDataSchema } from "@/lib/qr/types/links";
export function LinksLanding({ data }: LandingProps) {
const { title, description, links } = linksDataSchema.parse(data);
return (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{links.map((link, index) => (
<li key={`${index}-${link.url}`}>
<a
href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
>
<span className="min-w-0 flex-1 font-medium break-words">
{link.label}
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
))}
</ul>
</LandingShell>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { pdfDataSchema } from "@/lib/qr/types/pdf";
import { formatBytes } from "@/lib/uploads";
export function PdfLanding({ data }: LandingProps) {
const { fileId, filename, size, title, description } =
pdfDataSchema.parse(data);
const href = `/api/uploads/${fileId}`;
return (
<LandingShell
title={title || filename || "PDF-Dokument"}
subtitle={description || null}
>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-4">
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
<Icon name="document" className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-slate-900">{filename}</p>
<p className="text-sm text-slate-500">
PDF{size > 0 ? ` · ${formatBytes(size)}` : ""}
</p>
</div>
</div>
<div className="mt-5 grid gap-2 sm:grid-cols-2">
<a
href={href}
className="inline-flex items-center justify-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"
>
<Icon name="document" className="size-4" />
Dokument öffnen
</a>
<a
href={`${href}?download`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-4" />
Herunterladen
</a>
</div>
</div>
</LandingShell>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
/**
* Rahmen aller öffentlichen Landingpages.
*
* Diese Seiten sehen Leute, die einen Code gescannt haben — meist auf dem
* Handy, oft unterwegs, ohne Bezug zu dieser Anwendung. Deshalb: eine Spalte,
* große Schrift, keine Navigation, kein Hinweis auf die Verwaltung.
*/
export function LandingShell({
title,
subtitle,
children,
footer,
}: {
title: string;
subtitle?: string | null;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col bg-slate-50">
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-10 sm:px-6 sm:py-14">
<header className="mb-7 text-center">
<h1 className="text-2xl font-bold tracking-tight text-balance text-slate-900 sm:text-3xl">
{title}
</h1>
{subtitle ? (
<p className="mt-2.5 text-pretty text-slate-600">{subtitle}</p>
) : null}
</header>
{children}
</main>
{footer ? (
<footer className="px-4 pb-8 text-center text-xs text-slate-400">
{footer}
</footer>
) : null}
</div>
);
}
/**
* Ein Fehlerbild im selben Gewand — etwa wenn ein Code gelöscht wurde.
* Bewusst ohne Schuldzuweisung: wer hier landet, kann nichts dafür.
*/
export function LandingNotice({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className="grid flex-1 place-items-center bg-slate-50 px-4 py-16">
<div className="max-w-md text-center">
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl">
{title}
</h1>
<p className="mt-3 leading-relaxed text-pretty text-slate-600">
{children}
</p>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import {
getSocialNetwork,
socialHandleLabel,
} from "@/lib/qr/social-networks";
import { socialDataSchema } from "@/lib/qr/types/social";
export function SocialLanding({ data }: LandingProps) {
const { title, description, profiles } = socialDataSchema.parse(data);
return (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{profiles.map((profile) => {
const network = getSocialNetwork(profile.network);
return (
<li key={profile.network}>
<a
href={profile.url}
rel="noopener noreferrer"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: network?.color ?? "#475569" }}
>
{network?.monogram ?? "?"}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">
{network?.label ?? profile.network}
</span>
<span className="block truncate text-sm text-slate-500">
{socialHandleLabel(profile.url)}
</span>
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
);
})}
</ul>
</LandingShell>
);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Was eine Landingpage über den Code weiß, den jemand gescannt hat.
*
* `data` ist bereits vom Typ-Modul geprüft; die Komponente parst es nur noch
* einmal mit ihrem eigenen Schema, um typisiert damit zu arbeiten.
*/
export type LandingProps = {
data: unknown;
/** Interner Name des Codes — nicht öffentlich, nur als Rückfallebene. */
label: string | null;
slug: string;
/** Datenbank-Id des Codes, für Landingpages mit Rückmeldung. */
codeId: string;
};
+67
View File
@@ -0,0 +1,67 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { EmbeddedVideo } from "@/components/landing/video-player";
import { resolveVideoSource, videoDataSchema } from "@/lib/qr/types/video";
export function VideoLanding({ data }: LandingProps) {
const { url, title, description } = videoDataSchema.parse(data);
const quelle = resolveVideoSource(url);
return (
<LandingShell
title={title || "Video"}
subtitle={description || null}
footer={
quelle.kind === "youtube" || quelle.kind === "vimeo" ? (
<a
href={url}
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-slate-600"
>
Bei {quelle.label} öffnen
</a>
) : null
}
>
{quelle.kind === "youtube" || quelle.kind === "vimeo" ? (
<EmbeddedVideo
embedUrl={quelle.embedUrl}
provider={quelle.label}
title={title || "Video"}
/>
) : quelle.kind === "file" ? (
<video
controls
// Nicht von allein laden: Wer unterwegs scannt, zahlt das Volumen.
preload="none"
className="aspect-video w-full rounded-2xl bg-black shadow-sm"
>
<source src={url} />
Dein Browser kann dieses Video nicht abspielen.
</video>
) : (
<a
href={url}
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
<Icon name="video" className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">Video ansehen</span>
<span className="block truncate text-sm text-slate-500">
{quelle.label}
</span>
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
)}
</LandingShell>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Icon } from "@/components/icons";
/**
* Eingebettetes Video mit Zwei-Klick-Lösung.
*
* Ein Rahmen von YouTube oder Vimeo baut beim Laden eine Verbindung zum
* Anbieter auf — auf einer Seite, die Fremde nach einem Scan zu sehen
* bekommen, ist das ohne Zustimmung nicht in Ordnung. Deshalb erscheint der
* Rahmen erst nach einem Klick, und es steht vorher da, was dabei passiert.
*/
export function EmbeddedVideo({
embedUrl,
provider,
title,
}: {
embedUrl: string;
provider: string;
title: string;
}) {
const [gestartet, setGestartet] = useState(false);
if (gestartet) {
return (
<div className="aspect-video overflow-hidden rounded-2xl bg-black shadow-sm">
<iframe
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
);
}
return (
<button
type="button"
onClick={() => setGestartet(true)}
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
>
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
<Icon name="video" className="size-8" />
</span>
<span className="font-medium text-white">Video abspielen</span>
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
werden Daten an den Anbieter übertragen.
</span>
</button>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, type InputHTMLAttributes } from "react";
import { Icon } from "@/components/icons";
import { CONTROL_BASE, cx } from "@/components/ui";
/**
* Passwortfeld: verdeckt als Voreinstellung, mit Auge zum Aufdecken.
*
* Der Umschalter ist kein Beiwerk. Wer ein Passwort für jemand anderen setzt,
* muss es ablesen können, um es weiterzugeben — sichtbar sein soll es aber
* nur auf ausdrücklichen Wunsch und nicht schon beim Tippen vor fremden Augen
* oder in einer Bildschirmübertragung.
*/
export function PasswordInput({
invalid,
className,
revealLabel = "Passwort anzeigen",
hideLabel = "Passwort verbergen",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
invalid?: boolean;
revealLabel?: string;
hideLabel?: string;
}) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
type={visible ? "text" : "password"}
className={cx(
CONTROL_BASE,
"pr-11",
visible ? "font-mono" : undefined,
invalid ? "border-red-400" : "border-slate-300",
className,
)}
{...props}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
aria-label={visible ? hideLabel : revealLabel}
aria-pressed={visible}
title={visible ? hideLabel : revealLabel}
className="absolute top-1/2 right-1 -translate-y-1/2 rounded-md p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<Icon name={visible ? "eyeOff" : "eye"} className="size-4" />
</button>
</div>
);
}
/**
* Erzeugt ein zufälliges, gut vorlesbares Passwort.
*
* Ohne leicht verwechselbare Zeichen (0/O, 1/l/I), dafür in Vierergruppen —
* ein Administrator muss es am Telefon durchgeben können.
*/
export function generatePassword(groups = 4): string {
const alphabet = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = new Uint32Array(groups * 4);
crypto.getRandomValues(bytes);
const chars = Array.from(bytes, (value) => alphabet[value % alphabet.length]);
return (chars.join("").match(/.{1,4}/g) ?? []).join("-");
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Checkbox, Field, Select, TextArea, TextInput } from "@/components/ui";
import { FEEDBACK_SCALES } from "@/lib/qr/types/feedback";
export function FeedbackForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const question = typeof value.question === "string" ? value.question : "";
const scale = value.scale === "faces" ? "faces" : "stars";
const allowComment = value.allowComment !== false;
const commentLabel =
typeof value.commentLabel === "string" ? value.commentLabel : "";
const thankYou = typeof value.thankYou === "string" ? value.thankYou : "";
return (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="feedback-title"
error={error}
hint="Wo hängt der Code? Zum Beispiel „Gastraum“ oder „Werkstatt“."
>
<TextInput
id="feedback-title"
value={title}
invalid={Boolean(error)}
disabled={disabled}
maxLength={80}
placeholder="Wie war dein Besuch?"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Frage"
htmlFor="feedback-question"
hint="Steht über den Bewertungssymbolen."
>
<TextInput
id="feedback-question"
value={question}
disabled={disabled}
maxLength={160}
placeholder="Wie zufrieden bist du?"
onChange={(event) =>
onChange({ ...value, question: event.target.value })
}
/>
</Field>
<Field
label="Bewertungsskala"
htmlFor="feedback-scale"
hint="In beiden Fällen wird 1 bis 5 gespeichert."
>
<Select
id="feedback-scale"
value={scale}
disabled={disabled}
onChange={(event) => onChange({ ...value, scale: event.target.value })}
>
{Object.entries(FEEDBACK_SCALES).map(([id, eintrag]) => (
<option key={id} value={id}>
{eintrag.label}
</option>
))}
</Select>
</Field>
<Checkbox
id="feedback-allow-comment"
checked={allowComment}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, allowComment: event.target.checked })
}
label="Zusätzlich einen freien Text zulassen"
hint="Sonst gibt es nur die Note — das senkt die Hemmschwelle."
/>
{allowComment ? (
<Field label="Beschriftung des Textfelds" htmlFor="feedback-comment-label">
<TextInput
id="feedback-comment-label"
value={commentLabel}
disabled={disabled}
maxLength={120}
placeholder="Möchtest du uns noch etwas sagen?"
onChange={(event) =>
onChange({ ...value, commentLabel: event.target.value })
}
/>
</Field>
) : null}
<Field
label="Text nach dem Absenden"
htmlFor="feedback-thanks"
hint="Das Letzte, was jemand von dieser Seite sieht."
>
<TextArea
id="feedback-thanks"
rows={2}
value={thankYou}
disabled={disabled}
maxLength={200}
placeholder="Danke für deine Rückmeldung!"
onChange={(event) =>
onChange({ ...value, thankYou: event.target.value })
}
/>
</Field>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useId, useRef, useState } from "react";
import { Icon } from "@/components/icons";
import { Button } from "@/components/ui";
import { UPLOAD_KINDS, type UploadKind } from "@/lib/uploads.shared";
export type UploadedFile = {
fileId: string;
filename: string;
size: number;
};
/**
* Datei auswählen und sofort hochladen.
*
* Der Upload passiert vor dem Speichern des Codes, weil das Formular die Id
* der Datei braucht. Bleibt der Code ungespeichert, räumt der Server die
* Datei später von selbst weg.
*/
export function FileField({
kind,
onUploaded,
disabled,
label,
}: {
kind: UploadKind;
onUploaded: (file: UploadedFile) => void;
disabled?: boolean;
label: string;
}) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function hochladen(file: File) {
setFehler(null);
setLaeuft(true);
try {
const form = new FormData();
form.set("kind", kind);
form.set("file", file);
const response = await fetch("/api/uploads", {
method: "POST",
body: form,
});
const body = await response.json().catch(() => null);
if (!response.ok) {
setFehler(body?.error ?? "Die Datei konnte nicht hochgeladen werden.");
return;
}
onUploaded({
fileId: body.upload.id,
filename: body.upload.filename,
size: body.upload.size,
});
} catch {
setFehler(
"Die Datei konnte nicht hochgeladen werden. Besteht die Verbindung noch?",
);
} finally {
setLaeuft(false);
// Damit dieselbe Datei erneut gewählt werden kann.
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div>
<input
ref={inputRef}
id={inputId}
type="file"
accept={UPLOAD_KINDS[kind].accept}
disabled={disabled || laeuft}
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void hochladen(file);
}}
/>
<Button
type="button"
variant="secondary"
disabled={disabled || laeuft}
onClick={() => inputRef.current?.click()}
>
{laeuft ? (
"Wird hochgeladen …"
) : (
<>
<Icon name="download" className="size-4 rotate-180" />
{label}
</>
)}
</Button>
{fehler ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{fehler}
</p>
) : null}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Field, TextArea, TextInput } from "@/components/ui";
import { MAX_IMAGES, type ImageEntry } from "@/lib/qr/types/image";
import { formatBytes } from "@/lib/uploads.shared";
function readImages(value: unknown): ImageEntry[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const record = (entry ?? {}) as Record<string, unknown>;
if (typeof record.fileId !== "string" || record.fileId.length === 0) {
return [];
}
return [
{
fileId: record.fileId,
filename: typeof record.filename === "string" ? record.filename : "bild",
size: typeof record.size === "number" ? record.size : 0,
caption: typeof record.caption === "string" ? record.caption : "",
},
];
});
}
export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const images = readImages(value.images);
const setImages = (next: ImageEntry[]) => onChange({ ...value, images: next });
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= images.length) return;
const next = [...images];
[next[index], next[target]] = [next[target], next[index]];
setImages(next);
};
return (
<div className="space-y-5">
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Bilder</span>
<span className="text-xs text-slate-500">
{images.length} von {MAX_IMAGES}
</span>
</div>
{images.length > 0 ? (
<ul className="mb-3 space-y-3">
{images.map((image, index) => (
<li
key={image.fileId}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-3">
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung, unbekannte Maße */}
<img
src={`/api/uploads/${image.fileId}`}
alt=""
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-slate-600">
{image.filename}
{image.size > 0 ? ` · ${formatBytes(image.size)}` : ""}
</p>
<TextInput
aria-label={`Bildunterschrift von Bild ${index + 1}`}
value={image.caption}
disabled={disabled}
maxLength={120}
placeholder="Bildunterschrift (optional)"
onChange={(event) =>
setImages(
images.map((entry, i) =>
i === index
? { ...entry, caption: event.target.value }
: entry,
),
)
}
/>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Bild ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
<button
type="button"
disabled={disabled || index === images.length - 1}
onClick={() => move(index, 1)}
aria-label={`Bild ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
setImages(images.filter((_, i) => i !== index))
}
aria-label={`Bild ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</div>
</li>
))}
</ul>
) : null}
{images.length < MAX_IMAGES ? (
<FileField
kind="image"
label={images.length === 0 ? "Bild auswählen" : "Weiteres Bild"}
disabled={disabled}
onUploaded={(file) =>
setImages([
...images,
{
fileId: file.fileId,
filename: file.filename,
size: file.size,
caption: "",
},
])
}
/>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : (
<p className="mt-2 text-sm text-slate-500">
PNG, JPEG, WebP, GIF oder AVIF. Mehrere Bilder erscheinen
untereinander als Galerie.
</p>
)}
</div>
<Field
label="Titel (optional)"
htmlFor="image-title"
hint="Steht über den Bildern."
>
<TextInput
id="image-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Impressionen vom Hoffest"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="image-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="image-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}
+33 -3
View File
@@ -1,18 +1,48 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { WebsiteForm } from "@/components/qr-forms/website-form"; import { FeedbackForm } from "@/components/qr-forms/feedback-form";
import { ImageForm } from "@/components/qr-forms/image-form";
import { LinksForm } from "@/components/qr-forms/links-form";
import { PaymentForm } from "@/components/qr-forms/payment-form";
import { PdfForm } from "@/components/qr-forms/pdf-form";
import {
AppForm,
EmailForm,
LocationForm,
SmsForm,
WhatsappForm,
WifiForm,
} from "@/components/qr-forms/simple-forms";
import { SocialForm } from "@/components/qr-forms/social-form";
import type { QrFormProps } from "@/components/qr-forms/types"; import type { QrFormProps } from "@/components/qr-forms/types";
import { VCardForm } from "@/components/qr-forms/vcard-form";
import { VideoForm } from "@/components/qr-forms/video-form";
import { WebsiteForm } from "@/components/qr-forms/website-form";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
/** /**
* Zuordnung QR-Typ → Formular-Komponente. * Zuordnung QR-Typ → Formular-Komponente.
* *
* Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die * Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein V2-Typ braucht * Logik (Validierung, Kodierung), hier die Eingabemaske. Ein neuer Typ
* genau einen zusätzlichen Eintrag an beiden Stellen. * braucht genau einen zusätzlichen Eintrag an beiden Stellen.
*/ */
export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = { export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
website: WebsiteForm, website: WebsiteForm,
vcard: VCardForm,
app: AppForm,
links: LinksForm,
social: SocialForm,
pdf: PdfForm,
image: ImageForm,
feedback: FeedbackForm,
video: VideoForm,
whatsapp: WhatsappForm,
email: EmailForm,
sms: SmsForm,
location: LocationForm,
payment: PaymentForm,
wifi: WifiForm,
}; };
export type { QrFormProps }; export type { QrFormProps };
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { Icon } from "@/components/icons";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Button, Field, TextArea, TextInput } from "@/components/ui";
import { MAX_LINKS, type LinkEntry } from "@/lib/qr/types/links";
/**
* Liest die Linkliste aus den Formulardaten heraus.
*
* Die Daten kommen als `QrFormData` — ein flaches Objekt ohne Garantien.
* Beim Bearbeiten eines gespeicherten Codes stammen sie aus der Datenbank und
* können nach einer Schema-Änderung unvollständig sein; deshalb hier prüfen
* statt annehmen.
*/
function readLinks(value: unknown): LinkEntry[] {
if (!Array.isArray(value)) return [{ label: "", url: "" }];
const entries = value.map((entry) => {
const record = (entry ?? {}) as Record<string, unknown>;
return {
label: typeof record.label === "string" ? record.label : "",
url: typeof record.url === "string" ? record.url : "",
};
});
return entries.length > 0 ? entries : [{ label: "", url: "" }];
}
export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const links = readLinks(value.links);
const setLinks = (next: LinkEntry[]) => onChange({ ...value, links: next });
const updateLink = (index: number, patch: Partial<LinkEntry>) =>
setLinks(links.map((link, i) => (i === index ? { ...link, ...patch } : link)));
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= links.length) return;
const next = [...links];
[next[index], next[target]] = [next[target], next[index]];
setLinks(next);
};
return (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="links-title"
hint="Steht groß über der Linkliste, z. B. der Projektname."
>
<TextInput
id="links-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Projekt Nordwind"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="links-description"
hint="Eine Zeile zur Einordnung — was findet man hier?"
>
<TextArea
id="links-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
placeholder="Alle Unterlagen zum Projekt auf einen Blick."
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Links</span>
<span className="text-xs text-slate-500">
{links.length} von {MAX_LINKS}
</span>
</div>
<ul className="space-y-3">
{links.map((link, index) => (
<li
key={index}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<TextInput
aria-label={`Beschriftung von Link ${index + 1}`}
value={link.label}
disabled={disabled}
maxLength={80}
placeholder="Beschriftung, z. B. Preisliste"
onChange={(event) =>
updateLink(index, { label: event.target.value })
}
/>
<TextInput
aria-label={`Adresse von Link ${index + 1}`}
inputMode="url"
spellCheck={false}
value={link.url}
disabled={disabled}
maxLength={2048}
placeholder="https://example.com/preisliste.pdf"
onChange={(event) =>
updateLink(index, { url: event.target.value })
}
/>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 rotate-90" />
</button>
<button
type="button"
disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="arrowLeft" className="size-4 -rotate-90" />
</button>
<button
type="button"
disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</div>
</li>
))}
</ul>
{links.length < MAX_LINKS ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={() => setLinks([...links, { label: "", url: "" }])}
className="mt-3"
>
Link hinzufügen
</Button>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : null}
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { Field, FieldRow, Notice, Select, TextInput } from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
import { formatIban } from "@/lib/qr/encoding";
import {
PAYMENT_VARIANTS,
PAYMENT_VARIANT_LABELS,
type PaymentVariant,
} from "@/lib/qr/types/payment";
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
export function PaymentForm({ value, onChange, error, disabled }: QrFormProps) {
const variante = (
PAYMENT_VARIANTS.includes(value.variante as PaymentVariant)
? value.variante
: "girocode"
) as PaymentVariant;
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field label="Zahlungsart" htmlFor="pay-variante">
<Select
id="pay-variante"
value={variante}
disabled={disabled}
onChange={(event) => set("variante", event.target.value)}
>
{PAYMENT_VARIANTS.map((option) => (
<option key={option} value={option}>
{PAYMENT_VARIANT_LABELS[option]}
</option>
))}
</Select>
</Field>
{variante === "girocode" ? (
<>
<Field label="Empfänger" htmlFor="pay-empfaenger" error={error}>
<TextInput
id="pay-empfaenger"
placeholder="Name des Kontoinhabers"
value={text(value, "empfaenger")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("empfaenger", event.target.value)}
/>
</Field>
<Field
label="IBAN"
htmlFor="pay-iban"
hint="Wird beim Verlassen des Feldes in Vierergruppen dargestellt."
>
<TextInput
id="pay-iban"
spellCheck={false}
placeholder="DE02 1203 0000 0000 2020 51"
className="font-mono"
value={text(value, "iban")}
disabled={disabled}
onChange={(event) => set("iban", event.target.value.toUpperCase())}
onBlur={(event) => set("iban", formatIban(event.target.value))}
/>
</Field>
<FieldRow>
<Field label="BIC (optional)" htmlFor="pay-bic">
<TextInput
id="pay-bic"
spellCheck={false}
className="font-mono uppercase"
placeholder="BYLADEM1001"
value={text(value, "bic")}
disabled={disabled}
onChange={(event) => set("bic", event.target.value.toUpperCase())}
/>
</Field>
<Field
label="Betrag in Euro (optional)"
htmlFor="pay-betrag"
hint="Leer lassen, damit der Zahlende ihn selbst einträgt."
>
<TextInput
id="pay-betrag"
inputMode="decimal"
placeholder="49,90"
value={text(value, "betrag")}
disabled={disabled}
onChange={(event) => set("betrag", event.target.value)}
/>
</Field>
</FieldRow>
<Field label="Verwendungszweck (optional)" htmlFor="pay-zweck">
<TextInput
id="pay-zweck"
maxLength={140}
placeholder="Rechnung 2026-0815"
value={text(value, "verwendungszweck")}
disabled={disabled}
onChange={(event) => set("verwendungszweck", event.target.value)}
/>
</Field>
<Notice tone="info">
Der GiroCode wird von Banking-Apps gelesen und füllt die
Überweisung vor. Er funktioniert nur als statischer Code — für
einen dynamischen Code die Variante „Zahlungslink“ wählen.
</Notice>
</>
) : (
<Field
label="Zahlungsadresse"
htmlFor="pay-link"
error={error}
hint="Zum Beispiel PayPal.Me, ein Stripe-Zahlungslink oder die Zahlungsseite deines Shops."
>
<TextInput
id="pay-link"
placeholder="https://paypal.me/name/49.90"
value={text(value, "zahlungslink")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("zahlungslink", event.target.value)}
/>
</Field>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Field, TextArea, TextInput } from "@/components/ui";
import { formatBytes } from "@/lib/uploads.shared";
export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
const fileId = typeof value.fileId === "string" ? value.fileId : "";
const filename = typeof value.filename === "string" ? value.filename : "";
const size = typeof value.size === "number" ? value.size : 0;
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
return (
<div className="space-y-5">
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
PDF-Datei
</span>
{fileId ? (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-600">
<Icon name="document" className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{filename || "Dokument"}
</p>
<p className="text-xs text-slate-500">
{size > 0 ? formatBytes(size) : "PDF"}
</p>
</div>
<button
type="button"
disabled={disabled}
onClick={() =>
onChange({ ...value, fileId: "", filename: "", size: 0 })
}
aria-label="Datei entfernen"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
>
<Icon name="trash" className="size-4" />
</button>
</div>
) : (
<FileField
kind="pdf"
label="PDF auswählen"
disabled={disabled}
onUploaded={(file) =>
onChange({
...value,
fileId: file.fileId,
filename: file.filename,
size: file.size,
})
}
/>
)}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : (
<p className="mt-2 text-sm text-slate-500">
Die Datei liegt anschließend hier und ist über den Code erreichbar.
Sie lässt sich jederzeit austauschen, ohne den Code neu zu drucken.
</p>
)}
</div>
<Field
label="Titel (optional)"
htmlFor="pdf-title"
hint="Steht über dem Dokument. Ohne Angabe gilt der Dateiname."
>
<TextInput
id="pdf-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Preisliste 2026"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="pdf-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="pdf-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}
+356
View File
@@ -0,0 +1,356 @@
"use client";
import {
Checkbox,
Field,
FieldRow,
Notice,
Select,
TextArea,
TextInput,
} from "@/components/ui";
import { PasswordInput } from "@/components/password-input";
import type { QrFormProps } from "@/components/qr-forms/types";
import { parseCoordinates } from "@/lib/qr/encoding";
import {
WIFI_SECURITY,
WIFI_SECURITY_LABELS,
type WifiSecurity,
} from "@/lib/qr/types/wifi";
/** Liest ein Textfeld aus den generischen Formulardaten. */
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
/* ------------------------------------------------------------------ E-Mail */
export function EmailForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field label="Empfänger" htmlFor="email-empfaenger" error={error}>
<TextInput
id="email-empfaenger"
type="email"
inputMode="email"
autoComplete="email"
placeholder="name@example.com"
value={text(value, "empfaenger")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("empfaenger", event.target.value)}
/>
</Field>
<Field label="Betreff (optional)" htmlFor="email-betreff">
<TextInput
id="email-betreff"
value={text(value, "betreff")}
disabled={disabled}
onChange={(event) => set("betreff", event.target.value)}
/>
</Field>
<Field
label="Nachricht (optional)"
htmlFor="email-text"
hint="Wird als vorbereiteter Text in die E-Mail übernommen."
>
<TextArea
id="email-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* --------------------------------------------------------------------- SMS */
export function SmsForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field
label="Rufnummer"
htmlFor="sms-nummer"
error={error}
hint="International mit Ländervorwahl, z. B. +49 151 23456789"
>
<TextInput
id="sms-nummer"
type="tel"
inputMode="tel"
placeholder="+49 151 23456789"
value={text(value, "nummer")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("nummer", event.target.value)}
/>
</Field>
<Field label="Nachricht (optional)" htmlFor="sms-text">
<TextArea
id="sms-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* ---------------------------------------------------------------- WhatsApp */
export function WhatsappForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Field
label="WhatsApp-Nummer"
htmlFor="wa-nummer"
error={error}
hint="Mit Ländervorwahl und ohne führende Null, z. B. +49 151 23456789"
>
<TextInput
id="wa-nummer"
type="tel"
inputMode="tel"
placeholder="+49 151 23456789"
value={text(value, "nummer")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("nummer", event.target.value)}
/>
</Field>
<Field
label="Vorbereitete Nachricht (optional)"
htmlFor="wa-text"
hint="Steht beim Öffnen des Chats bereits im Eingabefeld."
>
<TextArea
id="wa-text"
value={text(value, "text")}
disabled={disabled}
onChange={(event) => set("text", event.target.value)}
/>
</Field>
</div>
);
}
/* -------------------------------------------------------------------- WLAN */
export function WifiForm({ value, onChange, error, disabled }: QrFormProps) {
const security = (
WIFI_SECURITY.includes(value.verschluesselung as WifiSecurity)
? value.verschluesselung
: "WPA"
) as WifiSecurity;
return (
<div className="flex flex-col gap-4">
<Field label="Netzwerkname (SSID)" htmlFor="wifi-ssid" error={error}>
<TextInput
id="wifi-ssid"
autoComplete="off"
spellCheck={false}
placeholder="Gaeste-WLAN"
value={text(value, "ssid")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => onChange({ ...value, ssid: event.target.value })}
/>
</Field>
<Field label="Verschlüsselung" htmlFor="wifi-security">
<Select
id="wifi-security"
value={security}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, verschluesselung: event.target.value })
}
>
{WIFI_SECURITY.map((option) => (
<option key={option} value={option}>
{WIFI_SECURITY_LABELS[option]}
</option>
))}
</Select>
</Field>
{security !== "nopass" ? (
<Field label="Passwort" htmlFor="wifi-password">
<PasswordInput
id="wifi-password"
autoComplete="off"
spellCheck={false}
revealLabel="WLAN-Passwort anzeigen"
hideLabel="WLAN-Passwort verbergen"
value={text(value, "passwort")}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, passwort: event.target.value })
}
/>
</Field>
) : null}
<Checkbox
id="wifi-hidden"
label="Netzwerk ist versteckt"
hint="Nur ankreuzen, wenn die SSID nicht gesendet wird."
checked={value.versteckt === true}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, versteckt: event.target.checked })
}
/>
<Notice tone="info">
Der Code enthält das Passwort im Klartext — wer ihn scannt oder
abfotografiert, kennt es. Für Gästenetze ist das gewollt, für das
eigene Netz eher nicht.
</Notice>
</div>
);
}
/* ---------------------------------------------------------------- Standort */
export function LocationForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
/** Erlaubt es, einen Kartenlink einzufügen, statt Zahlen abzutippen. */
const handlePaste = (raw: string) => {
const parsed = parseCoordinates(raw);
if (parsed) {
onChange({
...value,
breitengrad: String(parsed.latitude),
laengengrad: String(parsed.longitude),
});
return true;
}
return false;
};
return (
<div className="flex flex-col gap-4">
<Field
label="Kartenlink einfügen (optional)"
htmlFor="geo-paste"
hint="Adresse aus Google Maps, Apple Karten oder OpenStreetMap — die Koordinaten werden übernommen."
>
<TextInput
id="geo-paste"
placeholder="https://www.google.com/maps/@52.516,13.377,15z"
disabled={disabled}
onChange={(event) => {
if (handlePaste(event.target.value)) {
event.target.value = "";
}
}}
/>
</Field>
<FieldRow>
<Field label="Breitengrad" htmlFor="geo-lat" error={error}>
<TextInput
id="geo-lat"
inputMode="decimal"
placeholder="52.516"
value={text(value, "breitengrad")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("breitengrad", event.target.value)}
/>
</Field>
<Field label="Längengrad" htmlFor="geo-lon">
<TextInput
id="geo-lon"
inputMode="decimal"
placeholder="13.377"
value={text(value, "laengengrad")}
disabled={disabled}
onChange={(event) => set("laengengrad", event.target.value)}
/>
</Field>
</FieldRow>
</div>
);
}
/* ---------------------------------------------------------------- App-Link */
export function AppForm({ value, onChange, error, disabled }: QrFormProps) {
const set = (key: string, next: string) =>
onChange({ ...value, [key]: next });
return (
<div className="flex flex-col gap-4">
<Notice tone="info">
Der App-Link gibt es nur als dynamischen Code: erst beim Scannen steht
fest, welches Gerät ihn öffnet — und damit, wohin es geht.
</Notice>
<Field
label="iOS — App Store"
htmlFor="app-ios"
error={error}
hint="https://apps.apple.com/…"
>
<TextInput
id="app-ios"
placeholder="https://apps.apple.com/de/app/…"
value={text(value, "ios")}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => set("ios", event.target.value)}
/>
</Field>
<Field
label="Android — Google Play"
htmlFor="app-android"
hint="https://play.google.com/store/apps/…"
>
<TextInput
id="app-android"
placeholder="https://play.google.com/store/apps/details?id=…"
value={text(value, "android")}
disabled={disabled}
onChange={(event) => set("android", event.target.value)}
/>
</Field>
<Field
label="Alle übrigen Geräte"
htmlFor="app-fallback"
hint="Wird auf Rechnern und unbekannten Geräten geöffnet — meist die Website der App."
>
<TextInput
id="app-fallback"
placeholder="https://example.com"
value={text(value, "fallback")}
disabled={disabled}
onChange={(event) => set("fallback", event.target.value)}
/>
</Field>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { Icon } from "@/components/icons";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Button, Field, Select, TextArea, TextInput } from "@/components/ui";
import { SOCIAL_NETWORKS, getSocialNetwork } from "@/lib/qr/social-networks";
import { MAX_PROFILES } from "@/lib/qr/types/social";
type Profile = { network: string; url: string };
function readProfiles(value: unknown): Profile[] {
if (!Array.isArray(value)) return [{ network: "instagram", url: "" }];
const entries = value.map((entry) => {
const record = (entry ?? {}) as Record<string, unknown>;
return {
network: typeof record.network === "string" ? record.network : "website",
url: typeof record.url === "string" ? record.url : "",
};
});
return entries.length > 0 ? entries : [{ network: "instagram", url: "" }];
}
export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const profiles = readProfiles(value.profiles);
const belegt = new Set(profiles.map((profile) => profile.network));
const naechstesFreies =
SOCIAL_NETWORKS.find((network) => !belegt.has(network.id))?.id ?? "website";
const setProfiles = (next: Profile[]) =>
onChange({ ...value, profiles: next });
const updateProfile = (index: number, patch: Partial<Profile>) =>
setProfiles(
profiles.map((profile, i) => (i === index ? { ...profile, ...patch } : profile)),
);
return (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="social-title"
hint="Meist der Name, unter dem man euch kennt."
>
<TextInput
id="social-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Bäckerei Sonnenschein"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="social-description"
hint="Eine Zeile darunter, z. B. „Folge uns für Neues aus der Backstube“."
>
<TextArea
id="social-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Profile</span>
<span className="text-xs text-slate-500">
{profiles.length} von {MAX_PROFILES}
</span>
</div>
<ul className="space-y-3">
{profiles.map((profile, index) => {
const network = getSocialNetwork(profile.network);
return (
<li
key={index}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<Select
aria-label={`Netzwerk von Profil ${index + 1}`}
value={profile.network}
disabled={disabled}
onChange={(event) =>
updateProfile(index, { network: event.target.value })
}
>
{SOCIAL_NETWORKS.map((entry) => (
<option
key={entry.id}
value={entry.id}
// Doppelte Netzwerke lehnt die Prüfung ohnehin ab —
// besser, sie stehen gar nicht erst zur Wahl.
disabled={
entry.id !== profile.network && belegt.has(entry.id)
}
>
{entry.label}
</option>
))}
</Select>
<TextInput
aria-label={`Profil ${index + 1}`}
inputMode="url"
spellCheck={false}
value={profile.url}
disabled={disabled}
maxLength={2048}
placeholder={network?.placeholder ?? "https://example.com"}
onChange={(event) =>
updateProfile(index, { url: event.target.value })
}
/>
</div>
<button
type="button"
disabled={disabled || profiles.length === 1}
onClick={() =>
setProfiles(profiles.filter((_, i) => i !== index))
}
aria-label={`Profil ${index + 1} entfernen`}
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</li>
);
})}
</ul>
{profiles.length < MAX_PROFILES &&
profiles.length < SOCIAL_NETWORKS.length ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={() =>
setProfiles([...profiles, { network: naechstesFreies, url: "" }])
}
className="mt-3"
>
Profil hinzufügen
</Button>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : null}
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import {
Field,
FieldRow,
FieldSet,
TextArea,
TextInput,
} from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
function text(value: Record<string, unknown>, key: string): string {
const raw = value[key];
return typeof raw === "string" ? raw : "";
}
export function VCardForm({ value, onChange, error, disabled }: QrFormProps) {
const field = (key: string) => ({
value: text(value, key),
disabled,
onChange: (event: { target: { value: string } }) =>
onChange({ ...value, [key]: event.target.value }),
});
return (
<div className="flex flex-col gap-5">
<FieldRow>
<Field label="Vorname" htmlFor="vc-vorname">
<TextInput id="vc-vorname" autoComplete="given-name" {...field("vorname")} />
</Field>
<Field label="Nachname" htmlFor="vc-nachname" error={error}>
<TextInput
id="vc-nachname"
autoComplete="family-name"
invalid={Boolean(error)}
{...field("nachname")}
/>
</Field>
</FieldRow>
<FieldSet legend="Firma">
<FieldRow>
<Field label="Organisation" htmlFor="vc-org">
<TextInput id="vc-org" autoComplete="organization" {...field("organisation")} />
</Field>
<Field label="Position" htmlFor="vc-position">
<TextInput
id="vc-position"
autoComplete="organization-title"
{...field("position")}
/>
</Field>
</FieldRow>
</FieldSet>
<FieldSet legend="Kontakt">
<FieldRow>
<Field label="Telefon" htmlFor="vc-tel">
<TextInput id="vc-tel" type="tel" inputMode="tel" {...field("telefon")} />
</Field>
<Field label="Mobil" htmlFor="vc-mobil">
<TextInput id="vc-mobil" type="tel" inputMode="tel" {...field("mobil")} />
</Field>
</FieldRow>
<Field label="E-Mail" htmlFor="vc-email">
<TextInput
id="vc-email"
type="email"
inputMode="email"
autoComplete="email"
{...field("email")}
/>
</Field>
<Field label="Webseite" htmlFor="vc-web">
<TextInput id="vc-web" placeholder="https://example.com" {...field("webseite")} />
</Field>
</FieldSet>
<FieldSet legend="Anschrift">
<Field label="Straße und Hausnummer" htmlFor="vc-strasse">
<TextInput id="vc-strasse" autoComplete="street-address" {...field("strasse")} />
</Field>
<FieldRow>
<Field label="Postleitzahl" htmlFor="vc-plz">
<TextInput id="vc-plz" autoComplete="postal-code" {...field("plz")} />
</Field>
<Field label="Ort" htmlFor="vc-ort">
<TextInput id="vc-ort" autoComplete="address-level2" {...field("ort")} />
</Field>
</FieldRow>
<Field label="Land" htmlFor="vc-land">
<TextInput id="vc-land" autoComplete="country-name" {...field("land")} />
</Field>
</FieldSet>
<FieldSet legend="Sonstiges">
<Field
label="Notiz"
htmlFor="vc-notiz"
hint="Landet im Notizfeld des Kontakts."
>
<TextArea id="vc-notiz" {...field("notiz")} />
</Field>
</FieldSet>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Field, TextArea, TextInput } from "@/components/ui";
import { resolveVideoSource } from "@/lib/qr/types/video";
const HINWEIS: Record<string, string> = {
youtube: "Erkannt als YouTube-Video — es wird eingebettet abgespielt.",
vimeo: "Erkannt als Vimeo-Video — es wird eingebettet abgespielt.",
file: "Erkannt als Videodatei — sie wird direkt auf der Seite abgespielt.",
link:
"Diese Adresse lässt sich nicht einbetten. Auf der Seite erscheint stattdessen ein Link dorthin.",
};
export function VideoForm({ value, onChange, error, disabled }: QrFormProps) {
const url = typeof value.url === "string" ? value.url : "";
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const erkannt = url.trim().length > 0 ? resolveVideoSource(url.trim()) : null;
return (
<div className="space-y-5">
<Field
label="Adresse des Videos"
htmlFor="video-url"
error={error}
hint={
erkannt
? HINWEIS[erkannt.kind]
: "YouTube, Vimeo oder eine direkte Videodatei (.mp4, .webm)."
}
>
<TextInput
id="video-url"
inputMode="url"
spellCheck={false}
value={url}
invalid={Boolean(error)}
disabled={disabled}
placeholder="https://www.youtube.com/watch?v=…"
onChange={(event) => onChange({ ...value, url: event.target.value })}
/>
</Field>
<Field
label="Titel (optional)"
htmlFor="video-title"
hint="Steht über dem Video."
>
<TextInput
id="video-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Rundgang durch die Werkstatt"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="video-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="video-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}
+12 -4
View File
@@ -48,7 +48,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const [data, setData] = useState<QrFormData>(qrType.defaultData); const [data, setData] = useState<QrFormData>(qrType.defaultData);
const [design, setDesign] = useState<QrDesign>(defaultDesign); const [design, setDesign] = useState<QrDesign>(defaultDesign);
const [isDynamic, setIsDynamic] = useState(false); // Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null); const [saved, setSaved] = useState<QrCodeDto | null>(null);
@@ -282,10 +283,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button <button
type="button" type="button"
data-mode="static"
aria-pressed={!isDynamic} aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic}
onClick={() => setIsDynamic(false)} onClick={() => setIsDynamic(false)}
className={cx( className={cx(
"rounded-xl border p-4 text-left transition-colors", "rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic !isDynamic
? "border-indigo-600 bg-indigo-50/60" ? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50", : "border-slate-300 bg-white hover:bg-slate-50",
@@ -296,12 +299,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
Statisch Statisch
</span> </span>
<span className="mt-1 block text-sm text-slate-600"> <span className="mt-1 block text-sm text-slate-600">
Funktioniert ohne diesen Server, dafür unveränderlich. {qrType.supportsStatic
? "Funktioniert ohne diesen Server, dafür unveränderlich."
: `Für „${qrType.title}“ nicht möglich.`}
</span> </span>
</button> </button>
<button <button
type="button" type="button"
data-mode="dynamic"
aria-pressed={isDynamic} aria-pressed={isDynamic}
disabled={!qrType.supportsDynamic} disabled={!qrType.supportsDynamic}
onClick={() => setIsDynamic(true)} onClick={() => setIsDynamic(true)}
@@ -318,7 +324,9 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Badge tone="neutral">mit Statistik</Badge> <Badge tone="neutral">mit Statistik</Badge>
</span> </span>
<span className="mt-1 block text-sm text-slate-600"> <span className="mt-1 block text-sm text-slate-600">
Ziel jederzeit änderbar, Scans werden gezählt. {qrType.supportsDynamic
? "Ziel jederzeit änderbar, Scans werden gezählt."
: `Für „${qrType.title}“ nicht möglich — die Daten stecken im Code selbst.`}
</span> </span>
</button> </button>
</div> </div>
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module";
export type TypeSetting = {
id: QrTypeId;
title: string;
description: string;
icon: IconName;
implemented: boolean;
enabled: boolean;
};
function Toggle({
checked,
disabled,
label,
onChange,
}: {
checked: boolean;
disabled: boolean;
label: string;
onChange: (next: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-indigo-600" : "bg-slate-300",
)}
>
<span
className={cx(
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-5.5" : "translate-x-0.5",
)}
/>
</button>
);
}
export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
const [types, setTypes] = useState(initial);
const [pending, setPending] = useState<QrTypeId | null>(null);
const [error, setError] = useState<string | null>(null);
const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented,
).length;
const toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen.
setTypes((current) =>
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)),
);
setPending(id);
setError(null);
try {
await apiJson("/api/admin/types", {
method: "PATCH",
json: { type: id, enabled },
});
} catch (caught) {
setTypes(before);
setError(
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setPending(null);
}
};
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{visibleCount === 0 ? (
<Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
lassen sich keine neuen Codes anlegen. Bereits gespeicherte
dynamische Codes leiten weiterhin ganz normal weiter.
</Notice>
) : usableCount === 0 ? (
<Notice tone="warning">
Es ist kein nutzbarer Typ freigegeben. Die freigegebenen Typen sind in
dieser Version noch nicht ausprogrammiert.
</Notice>
) : null}
<Card className="!p-0">
<ul className="divide-y divide-slate-200">
{types.map((entry) => (
<li
key={entry.id}
className="flex items-start gap-4 p-4 sm:items-center sm:px-5"
>
<span
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
entry.enabled && entry.implemented
? "bg-indigo-50 text-indigo-600"
: "bg-slate-100 text-slate-400",
)}
>
<Icon name={entry.icon} className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cx(
"font-medium",
entry.enabled ? "text-slate-900" : "text-slate-500",
)}
>
{entry.title}
</span>
{entry.implemented ? (
<Badge tone="success">verfügbar</Badge>
) : (
<Badge tone="soon">noch nicht umgesetzt</Badge>
)}
</div>
<p className="mt-0.5 text-sm text-slate-500">
{entry.enabled
? entry.implemented
? "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p>
</div>
<Toggle
checked={entry.enabled}
disabled={pending === entry.id}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)}
/>
</li>
))}
</ul>
</Card>
<p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
Startseite sichtbar.
</p>
</div>
);
}
+102 -1
View File
@@ -1,4 +1,10 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react"; import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from "react";
export function cx(...classes: Array<string | false | null | undefined>): string { export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
@@ -129,6 +135,101 @@ export function TextInput({
); );
} }
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
export const CONTROL_BASE =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
export function TextArea({
invalid,
className,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }) {
return (
<textarea
rows={3}
className={cx(
CONTROL_BASE,
"resize-y",
invalid ? "border-red-400" : "border-slate-300",
className,
)}
{...props}
/>
);
}
export function Select({
invalid,
className,
children,
...props
}: SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) {
return (
<select
className={cx(
CONTROL_BASE,
"cursor-pointer appearance-none bg-[length:1.1rem] bg-[right_0.7rem_center] bg-no-repeat pr-9",
invalid ? "border-red-400" : "border-slate-300",
className,
)}
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
}}
{...props}
>
{children}
</select>
);
}
export function Checkbox({
label,
hint,
id,
...props
}: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string }) {
return (
<div className="flex items-start gap-2.5">
<input
id={id}
type="checkbox"
className="mt-0.5 size-4 shrink-0 cursor-pointer rounded border-slate-300 accent-indigo-600"
{...props}
/>
<label htmlFor={id} className="cursor-pointer text-sm text-slate-700">
{label}
{hint ? <span className="block text-slate-500">{hint}</span> : null}
</label>
</div>
);
}
/** Zwei Felder nebeneinander, ab „sm“ — für Paare wie Vor- und Nachname. */
export function FieldRow({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>
);
}
/** Abschnitt innerhalb eines Formulars mit eigener Überschrift. */
export function FieldSet({
legend,
children,
}: {
legend: string;
children: ReactNode;
}) {
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
{legend}
</legend>
<div className="mt-3 flex flex-col gap-4">{children}</div>
</fieldset>
);
}
/* ------------------------------------------------------------------- Badge */ /* ------------------------------------------------------------------- Badge */
export function Badge({ export function Badge({
+434
View File
@@ -0,0 +1,434 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { generatePassword, PasswordInput } from "@/components/password-input";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type AdminUser = {
id: string;
email: string;
name: string | null;
isInstanceAdmin: boolean;
isActive: boolean;
isLocked: boolean;
lastLoginAt: string | null;
createdAt: string;
activeSessions: number;
memberships: { groupId: string; groupName: string; role: string }[];
};
const dateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
});
const formatDate = (value: string | null) =>
value ? dateFormat.format(new Date(value)) : "—";
export function UserAdmin({
minPasswordLength,
currentUserId,
}: {
minPasswordLength: number;
currentUserId: string | null;
}) {
const [users, setUsers] = useState<AdminUser[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
email: "",
name: "",
password: "",
isInstanceAdmin: false,
});
const [formError, setFormError] = useState<string | null>(null);
const [resetting, setResetting] = useState<AdminUser | null>(null);
const [newPassword, setNewPassword] = useState("");
const [deleting, setDeleting] = useState<AdminUser | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ users: AdminUser[] }>("/api/admin/users");
setUsers(result.users);
setError(null);
} catch (caught) {
setUsers([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Benutzer konnten nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const act = async (id: string, body: unknown, onDone?: () => void) => {
setBusy(id);
setError(null);
try {
await apiJson(`/api/admin/users/${id}`, { method: "PATCH", json: body });
await load();
onDone?.();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setBusy(null);
}
};
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/users", { method: "POST", json: form });
setCreating(false);
setForm({ email: "", name: "", password: "", isInstanceAdmin: false });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Der Benutzer konnte nicht angelegt werden.",
);
}
};
const remove = async () => {
if (!deleting) return;
setBusy(deleting.id);
try {
await apiJson(`/api/admin/users/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Der Benutzer konnte nicht gelöscht werden.",
);
setDeleting(null);
} finally {
setBusy(null);
}
};
if (users === null) {
return <p className="text-sm text-slate-500">Benutzer werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Benutzer anlegen
</Button>
</div>
{users.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Es gibt noch keine Benutzer. Solange die Anmeldung ausgeschaltet
ist, wird auch keiner gebraucht.
</p>
</Card>
) : null}
{users.map((user) => (
<Card key={user.id}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-900">
{user.name?.trim() || user.email}
</span>
{user.isInstanceAdmin ? (
<Badge tone="popular">Administrator</Badge>
) : null}
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
{user.isLocked ? (
<Badge tone="neutral">vorübergehend gesperrt</Badge>
) : null}
{user.id === currentUserId ? (
<Badge tone="success">das bist du</Badge>
) : null}
</div>
{user.name?.trim() ? (
<p className="mt-0.5 text-sm text-slate-500">{user.email}</p>
) : null}
<dl className="mt-2.5 flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
<span>Letzte Anmeldung: {formatDate(user.lastLoginAt)}</span>
<span>Angelegt: {formatDate(user.createdAt)}</span>
{user.activeSessions > 0 ? (
<span>
{user.activeSessions} offene{" "}
{user.activeSessions === 1 ? "Sitzung" : "Sitzungen"}
</span>
) : null}
</dl>
<p className="mt-2 text-sm text-slate-600">
{user.memberships.length === 0
? "In keiner Gruppe."
: user.memberships
.map((m) => `${m.groupName} (${m.role})`)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() => {
setResetting(user);
setNewPassword("");
}}
>
<Icon name="lock" className="size-3.5" />
Passwort setzen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() =>
void act(user.id, { isActive: !user.isActive })
}
>
{user.isActive ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() =>
void act(user.id, { isInstanceAdmin: !user.isInstanceAdmin })
}
>
{user.isInstanceAdmin
? "Adminrechte entziehen"
: "Zum Administrator machen"}
</Button>
{user.isLocked ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() => void act(user.id, { unlock: true })}
>
Sperre aufheben
</Button>
) : null}
{user.id !== currentUserId ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === user.id}
onClick={() => setDeleting(user)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
) : null}
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------- Anlegen */}
<Modal
open={creating}
title="Benutzer anlegen"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Anlegen
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="new-user-email">
<TextInput
id="new-user-email"
type="email"
value={form.email}
onChange={(event) =>
setForm({ ...form, email: event.target.value })
}
/>
</Field>
<Field label="Name (optional)" htmlFor="new-user-name">
<TextInput
id="new-user-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</Field>
<Field
label="Passwort"
htmlFor="new-user-password"
hint={`Mindestens ${minPasswordLength} Zeichen. Gib es der Person weiter — sie kann es danach unter „Konto“ selbst ändern.`}
>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<PasswordInput
id="new-user-password"
autoComplete="new-password"
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
</div>
<Button
type="button"
variant="secondary"
title="Zufallspasswort erzeugen"
onClick={() => setForm({ ...form, password: generatePassword() })}
>
<Icon name="dice" className="size-4" />
Erzeugen
</Button>
</div>
</Field>
<Checkbox
id="new-user-admin"
label="Administrator der Installation"
hint="Darf Benutzer, Gruppen und QR-Typen verwalten."
checked={form.isInstanceAdmin}
onChange={(event) =>
setForm({ ...form, isInstanceAdmin: event.target.checked })
}
/>
</div>
</Modal>
{/* ---------------------------------------------- Passwort setzen */}
<Modal
open={resetting !== null}
title="Passwort setzen"
onClose={() => setResetting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setResetting(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={newPassword.length < minPasswordLength}
onClick={() =>
resetting &&
void act(resetting.id, { password: newPassword }, () =>
setResetting(null),
)
}
>
Passwort setzen
</Button>
</>
}
>
{resetting ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Neues Passwort für{" "}
<strong className="text-slate-900">{resetting.email}</strong>.
Alle offenen Sitzungen dieses Benutzers werden dabei beendet.
</p>
<Field
label="Neues Passwort"
htmlFor="reset-password"
hint={`Mindestens ${minPasswordLength} Zeichen. Zum Weitergeben lässt es sich über das Auge kurz sichtbar machen.`}
>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<PasswordInput
id="reset-password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
<Button
type="button"
variant="secondary"
title="Zufallspasswort erzeugen"
onClick={() => setNewPassword(generatePassword())}
>
<Icon name="dice" className="size-4" />
Erzeugen
</Button>
</div>
</Field>
</div>
) : null}
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Benutzer löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="text-slate-900">{deleting.email}</strong> wird
gelöscht und aus allen Gruppen entfernt. Die von dieser Person
angelegten QR-Codes bleiben bestehen — sie verlieren nur den
Vermerk, wer sie erstellt hat.
</p>
) : null}
</Modal>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import "server-only";
import { prisma } from "@/lib/db";
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Wer gerade handelt.
*
* Der Umschalter für die Anmeldung lebt genau hier: ist sie ausgeschaltet,
* liefert `getActor()` einen `system`-Actor mit Vollzugriff. Dadurch braucht
* keine einzige Route ein `if (anmeldungAktiv)` — sie fragen alle nur die
* Policy, und die kennt beide Betriebsarten.
*/
export type Actor =
| { kind: "system" }
| { kind: "user"; user: SessionUser }
| { kind: "anonymous" };
/**
* Ist die Anmeldung eingeschaltet?
*
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
* `AUTH_ENABLED=false` in der .env und startet neu.
*/
export function isAuthEnabled(): boolean {
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
}
export async function getActor(): Promise<Actor> {
if (!isAuthEnabled()) return { kind: "system" };
const user = await getSessionUser();
return user ? { kind: "user", user } : { kind: "anonymous" };
}
/** Gibt es überhaupt schon einen Benutzer? Steuert die Ersteinrichtung. */
export async function hasAnyUser(): Promise<boolean> {
return (await prisma.user.count()) > 0;
}
/**
* Die Ersteinrichtung ist nur offen, solange die Anmeldung aktiv ist und es
* noch keinen Benutzer gibt. Danach schließt sie sich von selbst — sonst
* könnte sich jederzeit jemand einen Administrator anlegen.
*/
export async function isSetupOpen(): Promise<boolean> {
return isAuthEnabled() && !(await hasAnyUser());
}
export function actorLabel(actor: Actor): string {
if (actor.kind === "system") return "Ohne Anmeldung";
if (actor.kind === "anonymous") return "Nicht angemeldet";
return actor.user.name?.trim() || actor.user.email;
}
/** Wirft, wenn niemand angemeldet ist. */
export function requireSignedIn(actor: Actor): void {
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
}
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import { prisma } from "@/lib/db";
import type { Actor } from "@/lib/auth/actor";
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Die Gruppe, in der Codes landen, wenn niemand angemeldet ist. Sie wird von
* der Migration angelegt; hier steht der Notnagel für den Fall, dass jemand
* sie gelöscht hat — sonst ließe sich plötzlich kein Code mehr anlegen.
*/
export const DEFAULT_GROUP_ID = "grp_standard";
export async function ensureDefaultGroup(): Promise<string> {
const existing = await prisma.group.findUnique({
where: { id: DEFAULT_GROUP_ID },
select: { id: true },
});
if (existing) return existing.id;
const created = await prisma.group.upsert({
where: { name: "Standard" },
create: { id: DEFAULT_GROUP_ID, name: "Standard" },
update: {},
select: { id: true },
});
return created.id;
}
export type GroupOption = { id: string; name: string; canWrite: boolean };
/** Gruppen, die der Actor in Auswahllisten sehen soll. */
export async function listGroupsForActor(actor: Actor): Promise<GroupOption[]> {
const visible = visibleGroupIds(actor);
const writable = writableGroupIds(actor);
const groups = await prisma.group.findMany({
where: visible === null ? undefined : { id: { in: visible } },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return groups.map((group) => ({
...group,
canWrite: writable === null || writable.includes(group.id),
}));
}
/**
* Bestimmt die Gruppe, in der ein neuer Code angelegt wird.
*
* Ohne Anmeldung ist das immer die Standardgruppe. Mit Anmeldung entscheidet
* die Auswahl des Benutzers; fehlt sie und gibt es genau eine beschreibbare
* Gruppe, wird diese genommen — bei mehreren muss der Benutzer wählen.
*/
export async function resolveTargetGroup(
actor: Actor,
requestedGroupId?: string,
): Promise<string> {
if (actor.kind === "system") {
return requestedGroupId ?? (await ensureDefaultGroup());
}
if (requestedGroupId) {
if (!can(actor, "codes:create", { groupId: requestedGroupId })) {
throw new QrRequestError(
"In dieser Gruppe darfst du keine Codes anlegen.",
403,
);
}
return requestedGroupId;
}
const writable = writableGroupIds(actor);
if (writable === null) {
// Instanz-Administrator ohne Auswahl: die Standardgruppe.
return ensureDefaultGroup();
}
if (writable.length === 0) {
throw new QrRequestError(
"Du bist in keiner Gruppe, in der du Codes anlegen darfst. Bitte wende dich an einen Administrator.",
403,
);
}
if (writable.length > 1) {
throw new QrRequestError(
"Bitte wähle aus, in welcher Gruppe der Code angelegt werden soll.",
);
}
return writable[0];
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { redirect } from "next/navigation";
import { getActor, isAuthEnabled, type Actor } from "@/lib/auth/actor";
import { can, type Action, type Scope } from "@/lib/auth/policy";
/**
* Wächter für Seiten (nicht für API-Routen — die werfen stattdessen).
*
* Ist die Anmeldung aus, kommt ein `system`-Actor zurück und alles ist
* erlaubt. Ist sie an und niemand angemeldet, geht es zur Anmeldeseite.
*/
export async function requirePageActor(): Promise<Actor> {
const actor = await getActor();
if (actor.kind === "anonymous") {
redirect("/anmelden");
}
return actor;
}
/** Wie `requirePageActor`, prüft zusätzlich eine Berechtigung. */
export async function requirePagePermission(
action: Action,
scope: Scope = {},
): Promise<Actor> {
const actor = await requirePageActor();
if (!can(actor, action, scope)) {
redirect("/kein-zugriff");
}
return actor;
}
export { isAuthEnabled };
+111
View File
@@ -0,0 +1,111 @@
import "server-only";
import {
randomBytes,
scrypt,
timingSafeEqual,
type ScryptOptions,
} from "node:crypto";
// promisify() greift bei scrypt die Überladung ohne Optionen ab — deshalb
// hier von Hand eingepackt.
function scryptAsync(
password: string,
salt: Buffer,
keyLength: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keyLength, options, (error, derived) => {
if (error) reject(error);
else resolve(derived);
});
});
}
/**
* Passwörter mit scrypt aus der Node-Standardbibliothek.
*
* Warum nicht Argon2id, das heute meist zuerst empfohlen wird: dafür bräuchte
* es ein natives Modul, das zur Plattform des Containers passen muss. scrypt
* ist ebenfalls speicherhart, gilt als solide und kostet null Abhängigkeiten
* und null Build-Risiko — bei einer selbst gehosteten Anwendung wiegt das
* schwerer als der Unterschied zwischen zwei guten Verfahren.
*
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
* entwerten.
*/
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(PARAMS.saltLength);
const derived = await scryptAsync(
password.normalize("NFKC"),
salt,
PARAMS.keyLength,
{
N: PARAMS.N,
r: PARAMS.r,
p: PARAMS.p,
// Der Standardwert reicht für N = 32768 nicht aus.
maxmem: 256 * 1024 * 1024,
},
);
return [
"scrypt",
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString("base64"),
derived.toString("base64"),
].join("$");
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
return false;
}
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[4], "base64");
expected = Buffer.from(parts[5], "base64");
} catch {
return false;
}
let derived: Buffer;
try {
derived = await scryptAsync(password.normalize("NFKC"), salt, expected.length, {
N,
r,
p,
maxmem: 256 * 1024 * 1024,
});
} catch {
return false;
}
// Vergleich in konstanter Zeit — ein früher Abbruch würde verraten, wie
// viele Bytes bereits stimmen.
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
/** Mindestanforderungen an ein Passwort. */
export const MIN_PASSWORD_LENGTH = 10;
export function describePasswordRules(): string {
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import type { Role } from "@/generated/prisma/client";
import type { Actor } from "@/lib/auth/actor";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Sämtliche Berechtigungsregeln der Anwendung — an einer Stelle.
*
* Jeder schreibende oder lesende Zugriff geht durch `can` bzw. `require`.
* Die Oberfläche blendet Knöpfe zwar zusätzlich aus, aber das ist nur
* Bequemlichkeit: verlassen wird sich ausschließlich auf diese Prüfungen.
*/
export type Action =
/** Codes einer Gruppe sehen und Bilder herunterladen. */
| "codes:read"
/** Dynamische Codes anlegen. */
| "codes:create"
/** Ziel, Name oder Gestaltung ändern. */
| "codes:update"
/** Codes löschen. */
| "codes:delete"
/** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage";
export type Scope = { groupId?: string };
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
export const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Administrator",
EDITOR: "Bearbeiter",
VIEWER: "Betrachter",
};
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: "Darf alles in der Gruppe, einschließlich Mitglieder und Rollen.",
EDITOR: "Darf Codes anlegen, ändern und löschen.",
VIEWER: "Darf Codes ansehen und herunterladen, aber nichts ändern.",
};
/** Rolle des Actors in einer bestimmten Gruppe, falls vorhanden. */
export function roleInGroup(actor: Actor, groupId: string): Role | null {
if (actor.kind !== "user") return null;
return (
actor.user.memberships.find((m) => m.groupId === groupId)?.role ?? null
);
}
/** Alle Gruppen, die der Actor sehen darf. `null` heißt: alle. */
export function visibleGroupIds(actor: Actor): string[] | null {
if (actor.kind === "system") return null;
if (actor.kind === "anonymous") return [];
if (actor.user.isInstanceAdmin) return null;
return actor.user.memberships.map((m) => m.groupId);
}
/** Gruppen, in denen der Actor Codes anlegen darf. */
export function writableGroupIds(actor: Actor): string[] | null {
if (actor.kind === "system") return null;
if (actor.kind === "anonymous") return [];
if (actor.user.isInstanceAdmin) return null;
return actor.user.memberships
.filter((m) => ROLE_RANK[m.role] >= ROLE_RANK.EDITOR)
.map((m) => m.groupId);
}
export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
if (actor.kind === "system") return true;
if (actor.kind === "anonymous") return false;
// Der Instanz-Administrator darf alles — er verwaltet die Installation.
if (actor.user.isInstanceAdmin) return true;
if (action === "instance:manage") return false;
// Alle übrigen Aktionen beziehen sich auf eine Gruppe.
if (!scope.groupId) return false;
const role = roleInGroup(actor, scope.groupId);
if (!role) return false;
switch (action) {
case "codes:read":
return true; // jede Rolle in der Gruppe darf lesen
case "codes:create":
case "codes:update":
case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
case "group:manage":
return role === "ADMIN";
default:
return false;
}
}
const DENIED_MESSAGES: Record<Action, string> = {
"codes:read": "Du hast keinen Zugriff auf die Codes dieser Gruppe.",
"codes:create":
"Zum Anlegen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:update":
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage":
"Diesen Bereich darf nur ein Administrator der Installation verwalten.",
};
/** Wie `can`, wirft aber mit passendem Status und deutscher Meldung. */
export function requirePermission(
actor: Actor,
action: Action,
scope: Scope = {},
): void {
if (can(actor, action, scope)) return;
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
throw new QrRequestError(DENIED_MESSAGES[action], 403);
}
+130
View File
@@ -0,0 +1,130 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
export const SESSION_COOKIE = "qrgen_session";
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
const SESSION_DAYS = 30;
/**
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
* Wer die Sitzungstabelle liest, kann sich damit also nicht anmelden.
*/
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function expiryFromNow(): Date {
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
}
export type SessionUser = {
id: string;
email: string;
name: string | null;
isInstanceAdmin: boolean;
memberships: { groupId: string; groupName: string; role: "ADMIN" | "EDITOR" | "VIEWER" }[];
};
/** Legt eine Sitzung an und setzt den Cookie. */
export async function createSession(userId: string): Promise<void> {
const token = randomBytes(32).toString("base64url");
await prisma.session.create({
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
});
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
expires: expiryFromNow(),
});
}
/** Beendet die aktuelle Sitzung und löscht den Cookie. */
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session
.deleteMany({ where: { tokenHash: hashToken(token) } })
.catch(() => undefined);
}
store.delete(SESSION_COOKIE);
}
/** Beendet alle Sitzungen eines Benutzers — etwa beim Deaktivieren. */
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/**
* Liest die aktuelle Sitzung. Liefert `null`, wenn kein oder ein ungültiger
* Cookie vorliegt, die Sitzung abgelaufen ist oder der Benutzer inzwischen
* deaktiviert wurde.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: {
user: {
include: { memberships: { include: { group: { select: { name: true } } } } },
},
},
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null;
// Gleitendes Ablaufdatum, aber höchstens einmal pro Stunde schreiben —
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
const anHourAgo = Date.now() - 60 * 60 * 1000;
if (session.lastSeenAt.getTime() < anHourAgo) {
await prisma.session
.update({
where: { id: session.id },
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
})
.catch(() => undefined);
}
return {
id: session.user.id,
email: session.user.email,
name: session.user.name,
isInstanceAdmin: session.user.isInstanceAdmin,
memberships: session.user.memberships.map((membership) => ({
groupId: membership.groupId,
groupName: membership.group.name,
role: membership.role,
})),
};
}
/** Räumt abgelaufene Sitzungen auf. */
export async function pruneExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}
+23 -4
View File
@@ -2,7 +2,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client"; import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { shortUrl } from "@/lib/env"; import { landingUrl, shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design"; import type { EcLevel } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -26,7 +26,14 @@ function asRecord(value: unknown): Record<string, unknown> {
: {}; : {};
} }
export function toQrCodeDto(code: QrCode): QrCodeDto { /** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */
export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null;
domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null;
};
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
const data = asRecord(code.data); const data = asRecord(code.data);
const qrType = getQrType(code.type); const qrType = getQrType(code.type);
@@ -34,7 +41,12 @@ export function toQrCodeDto(code: QrCode): QrCodeDto {
let summary = ""; let summary = "";
if (qrType) { if (qrType) {
try { try {
target = qrType.redirectTarget(data); // Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
// sich anzusehen.
target = qrType.hasLandingPage
? landingUrl(code.slug, code.domain?.hostname)
: qrType.redirectTarget(data);
summary = qrType.summary(data); summary = qrType.summary(data);
} catch { } catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem // Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
@@ -51,7 +63,7 @@ export function toQrCodeDto(code: QrCode): QrCodeDto {
type: code.type as QrTypeId, type: code.type as QrTypeId,
label: code.label, label: code.label,
data, data,
shortUrl: shortUrl(code.slug), shortUrl: shortUrl(code.slug, code.domain?.hostname),
target, target,
summary, summary,
design: { design: {
@@ -65,5 +77,12 @@ export function toQrCodeDto(code: QrCode): QrCodeDto {
lastScanAt: code.lastScanAt?.toISOString() ?? null, lastScanAt: code.lastScanAt?.toISOString() ?? null,
createdAt: code.createdAt.toISOString(), createdAt: code.createdAt.toISOString(),
updatedAt: code.updatedAt.toISOString(), updatedAt: code.updatedAt.toISOString(),
groupId: code.groupId,
groupName: code.group?.name ?? null,
domainId: code.domainId,
domainHostname: code.domain?.hostname ?? null,
createdByName: code.createdBy
? (code.createdBy.name?.trim() || code.createdBy.email)
: null,
}; };
} }
+228
View File
@@ -0,0 +1,228 @@
import "server-only";
import { prisma } from "@/lib/db";
import type { Actor } from "@/lib/auth/actor";
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
import { getBaseUrl } from "@/lib/env";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Domains für Kurz-URLs.
*
* Die Anwendung läuft hinter einem Reverse Proxy und kann mehrere Domains
* bedienen. Welche gemeint ist, verrät der `Host`-Header der Anfrage. Weil
* dieser Header fälschbar ist, wird er **immer** gegen diese Tabelle geprüft:
* ein unbekannter Hostname führt zu 404. Damit bringt Fälschen nichts —
* ansprechen lässt sich nur, was ohnehin registriert ist.
*/
/** Von der Migration angelegte Standard-Domain. */
export const DEFAULT_DOMAIN_ID = "dom_standard";
const PLACEHOLDER_HOSTNAME = "standard.invalid";
/** Normalisiert einen Hostnamen: klein, ohne Schema, ohne abschließenden Punkt. */
export function normalizeHostname(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
.replace(/\/.*$/, "")
.replace(/\.$/, "");
}
/** Hostname (ggf. mit Port) aus BASE_URL. */
export function baseUrlHostname(): string {
try {
return new URL(getBaseUrl()).host.toLowerCase();
} catch {
return "localhost:3000";
}
}
/**
* Sorgt dafür, dass es die Standard-Domain gibt und ihr Hostname zu BASE_URL
* passt. Die Migration kann BASE_URL nicht kennen und setzt deshalb erst
* einen Platzhalter.
*/
export async function ensureDefaultDomain(): Promise<{
id: string;
hostname: string;
}> {
const hostname = baseUrlHostname();
const byHostname = await prisma.domain.findUnique({
where: { hostname },
select: { id: true, hostname: true },
});
if (byHostname) return byHostname;
const standard = await prisma.domain.findUnique({
where: { id: DEFAULT_DOMAIN_ID },
select: { id: true, hostname: true },
});
// Nur den unveränderten Platzhalter überschreiben — hat jemand die
// Standard-Domain bewusst umbenannt, bleibt das so.
if (standard && standard.hostname === PLACEHOLDER_HOSTNAME) {
return prisma.domain.update({
where: { id: DEFAULT_DOMAIN_ID },
data: { hostname },
select: { id: true, hostname: true },
});
}
if (standard) return standard;
return prisma.domain.create({
data: { id: DEFAULT_DOMAIN_ID, hostname, label: "Standard-Domain" },
select: { id: true, hostname: true },
});
}
/**
* Ermittelt die Domain, unter der eine Anfrage hereinkam.
*
* `X-Forwarded-Host` hat Vorrang, weil hinter einem Reverse Proxy dort der
* ursprüngliche Hostname steht. Beide Werte werden gegen die Domain-Tabelle
* geprüft — ohne Treffer gibt es kein Ergebnis.
*/
export async function resolveRequestDomain(
headers: Headers,
): Promise<{ id: string; hostname: string; isActive: boolean } | null> {
const candidates = [
headers.get("x-forwarded-host"),
headers.get("host"),
]
.filter((value): value is string => Boolean(value))
// Bei mehreren Proxies steht in X-Forwarded-Host eine Liste.
.map((value) => normalizeHostname(value.split(",")[0]));
for (const hostname of candidates) {
if (!hostname) continue;
const domain = await prisma.domain.findUnique({
where: { hostname },
select: { id: true, hostname: true, isActive: true },
});
if (domain) return domain;
}
return null;
}
export type DomainOption = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
isDefault: boolean;
};
/** Domains, die einer Gruppe zugeteilt sind. */
export async function listDomainsForGroup(
groupId: string,
): Promise<DomainOption[]> {
const grants = await prisma.domainGrant.findMany({
where: { groupId, domain: { isActive: true } },
include: { domain: true },
orderBy: [{ isDefault: "desc" }, { domain: { hostname: "asc" } }],
});
return grants.map((grant) => ({
id: grant.domain.id,
hostname: grant.domain.hostname,
label: grant.domain.label,
isActive: grant.domain.isActive,
isDefault: grant.isDefault,
}));
}
/** Alle Domains, die der Actor in Auswahllisten sehen darf. */
export async function listDomainsForActor(
actor: Actor,
): Promise<DomainOption[]> {
const visible = visibleGroupIds(actor);
const domains = await prisma.domain.findMany({
where:
visible === null
? undefined
: { grants: { some: { groupId: { in: visible } } } },
orderBy: { hostname: "asc" },
include: visible === null ? undefined : { grants: true },
});
return domains.map((domain) => ({
id: domain.id,
hostname: domain.hostname,
label: domain.label,
isActive: domain.isActive,
isDefault: domain.id === DEFAULT_DOMAIN_ID,
}));
}
/**
* Bestimmt die Domain für einen neuen Code und prüft, dass die Gruppe sie
* überhaupt nutzen darf.
*/
export async function resolveTargetDomain(
actor: Actor,
groupId: string,
requestedDomainId?: string,
): Promise<string> {
// Muss vor jeder Verwendung laufen: die Migration legt die Standard-Domain
// mit einem Platzhalter-Hostnamen an, weil sie BASE_URL nicht kennt. Ohne
// diesen Aufruf entstünden auf einer frischen Installation Kurz-URLs auf
// "standard.invalid" — also gedruckte Codes, die ins Leere führen.
await ensureDefaultDomain();
const allowed = await listDomainsForGroup(groupId);
if (requestedDomainId) {
const match = allowed.find((entry) => entry.id === requestedDomainId);
if (!match) {
// Instanz-Administratoren dürfen jede aktive Domain verwenden.
if (can(actor, "instance:manage")) {
const domain = await prisma.domain.findUnique({
where: { id: requestedDomainId },
select: { id: true, isActive: true },
});
if (domain?.isActive) return domain.id;
}
throw new QrRequestError(
"Diese Domain steht der gewählten Gruppe nicht zur Verfügung.",
403,
);
}
return match.id;
}
const preferred = allowed.find((entry) => entry.isDefault) ?? allowed[0];
if (preferred) return preferred.id;
// Der Gruppe wurde noch keine Domain zugeteilt — die Standard-Domain
// springt ein, damit das Anlegen nicht ohne Not scheitert.
const fallback = await ensureDefaultDomain();
return fallback.id;
}
/** Gruppen, in denen der Actor schreiben darf — für die Auswahl im Generator. */
export async function writableGroupsWithDomains(actor: Actor): Promise<
{
id: string;
name: string;
domains: DomainOption[];
}[]
> {
const writable = writableGroupIds(actor);
const groups = await prisma.group.findMany({
where: writable === null ? undefined : { id: { in: writable } },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return Promise.all(
groups.map(async (group) => ({
...group,
domains: await listDomainsForGroup(group.id),
})),
);
}
+7
View File
@@ -28,4 +28,11 @@ export type QrCodeDto = {
lastScanAt: string | null; lastScanAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Gruppe, der der Code gehört. */
groupId: string;
groupName: string | null;
domainId: string;
domainHostname: string | null;
/** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */
createdByName: string | null;
}; };
+38 -3
View File
@@ -15,9 +15,44 @@ export function getBaseUrl(): string {
return raw.replace(/\/+$/, ""); return raw.replace(/\/+$/, "");
} }
/** Kurz-URL eines dynamischen Codes. */ /**
export function shortUrl(slug: string): string { * Kurz-URL eines dynamischen Codes.
return `${getBaseUrl()}/r/${slug}`; *
* Ohne Hostname gilt BASE_URL. Mit Hostname — dem der Code zugeordnet ist —
* wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus
* BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS
* beendet.
*/
export function shortUrl(slug: string, hostname?: string): string {
return publicUrl(`/r/${slug}`, hostname);
}
/**
* Adresse der Landingpage eines Codes — also der Seite, die diese Anwendung
* selbst ausliefert, statt auf ein fremdes Ziel weiterzuleiten.
*/
export function landingUrl(slug: string, hostname?: string): string {
return publicUrl(`/s/${slug}`, hostname);
}
/**
* Öffentliche Adresse eines Pfades.
*
* Ohne Hostname gilt BASE_URL. Mit Hostname — dem, dem der Code zugeordnet
* ist — wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus
* BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS
* beendet.
*/
export function publicUrl(path: string, hostname?: string): string {
if (!hostname) return `${getBaseUrl()}${path}`;
let scheme = "http";
try {
scheme = new URL(getBaseUrl()).protocol.replace(":", "");
} catch {
// Unbrauchbare BASE_URL — http ist die verträglichere Annahme.
}
return `${scheme}://${hostname}${path}`;
} }
export function getDatabaseUrl(): string { export function getDatabaseUrl(): string {
+30 -9
View File
@@ -1,23 +1,44 @@
import { QrRenderError } from "@/lib/qr/render";
import { QrRequestError } from "@/lib/qr/request";
import { QrTypeError } from "@/lib/qr/type-module";
export function jsonError(message: string, status = 400): Response { export function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status }); return Response.json({ error: message }, { status });
} }
/**
* Erkennt Fehler, deren Meldung an den Aufrufer gehen darf.
*
* Bewusst über eine Eigenschaft statt über `instanceof`: die Fehlerklassen
* stehen in einem Modul, das sowohl im Server- als auch im Client-Bündel
* landet. Dann existieren zwei Klassenidentitäten, und ein
* `instanceof`-Vergleich würde eine saubere 400er-Antwort stillschweigend zu
* einem 500er machen.
*/
function asUserFacing(
error: unknown,
): { message: string; status: number } | null {
if (
typeof error !== "object" ||
error === null ||
(error as { userFacing?: unknown }).userFacing !== true
) {
return null;
}
const message = (error as { message?: unknown }).message;
if (typeof message !== "string") return null;
const status = (error as { status?: unknown }).status;
return { message, status: typeof status === "number" ? status : 400 };
}
/** /**
* Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung. * Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung.
* Unerwartete Fehler werden protokolliert, nach außen aber generisch * Unerwartete Fehler werden protokolliert, nach außen aber generisch
* gemeldet. * gemeldet.
*/ */
export function errorResponse(error: unknown): Response { export function errorResponse(error: unknown): Response {
if (error instanceof QrRequestError) { const userFacing = asUserFacing(error);
return jsonError(error.message, error.status); if (userFacing) {
} return jsonError(userFacing.message, userFacing.status);
if (error instanceof QrRenderError || error instanceof QrTypeError) {
return jsonError(error.message, 400);
} }
console.error("Unerwarteter Fehler in einer API-Route:", error); console.error("Unerwarteter Fehler in einer API-Route:", error);
return jsonError( return jsonError(
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.", "Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
+141
View File
@@ -0,0 +1,141 @@
import "server-only";
import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry";
import {
isQrTypeId,
QrRequestError,
type QrTypeId,
type QrTypeModule,
} from "@/lib/qr/type-module";
/**
* Freigabe der QR-Typen durch die Verwaltung.
*
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
*
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
* ausprogrammiert ist. In V1 gilt das nur für „Website“.
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
*
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
* Verwaltung darf sie nicht ins Leere laufen lassen.
*/
export type QrTypeAvailability = {
qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */
enabled: boolean;
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
announced: boolean;
};
/** Abschaltungen aus der Datenbank; alles nicht Genannte ist freigegeben. */
async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
try {
const rows = await prisma.qrTypeSetting.findMany({
where: { enabled: false },
select: { type: true },
});
return new Set(rows.map((row) => row.type));
} catch (error) {
// Ohne Datenbank ist die Anwendung für statische Codes weiterhin
// brauchbar. Lieber alles anzeigen als eine leere Startseite.
console.error(
"Freigaben der QR-Typen konnten nicht geladen werden — es gilt die Voreinstellung (alle freigegeben):",
error,
);
return new Set();
}
}
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
const disabled = await loadDisabledTypes();
return qrTypeList.map((qrType) => {
const enabled = !disabled.has(qrType.id);
return {
qrType,
implemented: qrType.implemented,
enabled,
usable: enabled && qrType.implemented,
announced: enabled && !qrType.implemented,
};
});
}
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
}
export async function getQrTypeState(
id: string,
): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return undefined;
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id);
}
/**
* Liefert den Typ für das Anlegen neuer Codes — oder wirft mit einer
* Meldung, die unverändert in die Oberfläche gehen kann.
*/
export async function requireAvailableQrType(
id: string,
): Promise<QrTypeModule> {
const state = await getQrTypeState(id);
if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404);
}
if (!state.implemented) {
throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in dieser Version noch nicht verfügbar.`,
404,
);
}
if (!state.enabled) {
throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
403,
);
}
return state.qrType;
}
/**
* Typ eines bestehenden Codes. Hier zählt nur, ob der Typ ausprogrammiert
* ist — eine Abschaltung in der Verwaltung darf bestehende Codes nicht
* unbearbeitbar machen.
*/
export function requireImplementedQrType(id: string): QrTypeModule {
const qrType = getQrType(id);
if (!qrType?.implemented) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
404,
);
}
return qrType;
}
/** Freigabe eines Typs setzen. */
export async function setQrTypeEnabled(
id: QrTypeId,
enabled: boolean,
): Promise<void> {
await prisma.qrTypeSetting.upsert({
where: { type: id },
create: { type: id, enabled },
update: { enabled },
});
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Bausteine für die Kodierung der QR-Nutzdaten.
*
* Die Formate stammen aus unterschiedlichen Welten und maskieren jeweils
* anders. Diese Datei sammelt die Regeln an einer Stelle, damit sie nicht in
* jedem Typ-Modul neu — und leicht falsch — entstehen.
*/
/* -------------------------------------------------------------- Rufnummern */
/**
* Bringt eine Rufnummer in die Form, die `tel:`, `SMSTO:` und wa.me erwarten:
* nur Ziffern, führendes Pluszeichen erhalten.
*
* `(0)` in Schreibweisen wie „+49 (0)30 …“ wird entfernt — die Null gehört
* dort ausdrücklich nicht in die internationale Nummer.
*/
export function normalizePhone(value: string): string {
const cleaned = value.trim().replace(/\(0\)/g, "");
const digits = cleaned.replace(/[^\d+]/g, "");
const plus = digits.startsWith("+");
return (plus ? "+" : "") + digits.replace(/\+/g, "");
}
/** Nur Ziffern, ohne Pluszeichen — das erwartet wa.me. */
export function phoneDigits(value: string): string {
return normalizePhone(value).replace(/^\+/, "");
}
/**
* Eine international eindeutige Nummer im Format E.164: führendes Plus,
* Ländervorwahl, insgesamt 8 bis 15 Ziffern.
*/
export function isE164(value: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(normalizePhone(value));
}
/* -------------------------------------------------------------------- URLs */
export function isHttpUrl(value: string): boolean {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
parsed.hostname.length > 0
);
}
/** Baut eine Abfragezeichenfolge; leere Werte fallen weg. */
export function queryString(
params: Record<string, string | undefined>,
): string {
const parts = Object.entries(params)
.filter(([, value]) => value !== undefined && value.length > 0)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`,
);
return parts.length > 0 ? `?${parts.join("&")}` : "";
}
/* -------------------------------------------------------------------- WLAN */
/**
* Maskierung für das WIFI-Format. Sonderzeichen müssen mit einem
* Rückwärtsstrich versehen werden, sonst bricht das Feld ab — mit Abstand die
* häufigste Fehlerquelle bei WLAN-Codes.
*/
export function escapeWifi(value: string): string {
return value.replace(/([\\;,:"])/g, "\\$1");
}
/**
* Besteht ein Wert nur aus Hexadezimalziffern, deuten manche Geräte ihn als
* Hexzahl statt als Text. Anführungszeichen erzwingen die Textlesart.
*/
export function quoteWifiIfHex(value: string): string {
const escaped = escapeWifi(value);
return /^[0-9a-fA-F]+$/.test(value) && value.length > 0
? `"${escaped}"`
: escaped;
}
/* ------------------------------------------------------------------ vCard */
/** Maskierung für vCard-Werte nach RFC 6350. */
export function escapeVCard(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/**
* Faltet eine vCard-Zeile nach 75 Oktetten, wie es die Spezifikation
* verlangt: Umbruch, dann ein führendes Leerzeichen in der Folgezeile.
*
* Gezählt werden Oktette, nicht Zeichen — ein Umbruch mitten in einem
* mehrbytigen Zeichen würde die Datei zerstören.
*/
export function foldVCardLine(line: string, limit = 75): string {
const encoder = new TextEncoder();
if (encoder.encode(line).length <= limit) return line;
const out: string[] = [];
let current = "";
let currentBytes = 0;
// Ab der zweiten Zeile kostet das führende Leerzeichen ein Oktett.
let budget = limit;
for (const char of line) {
const size = encoder.encode(char).length;
if (currentBytes + size > budget) {
out.push(current);
current = char;
currentBytes = size;
budget = limit - 1;
} else {
current += char;
currentBytes += size;
}
}
out.push(current);
return out.join("\r\n ");
}
/** Setzt vCard-Zeilen zusammen: gefaltet und mit CRLF getrennt. */
export function joinVCardLines(lines: string[]): string {
return lines.map((line) => foldVCardLine(line)).join("\r\n");
}
/* ------------------------------------------------------------------- IBAN */
/** Prüft eine IBAN über die Modulo-97-Prüfsumme nach ISO 13616. */
export function isValidIban(value: string): boolean {
const iban = value.replace(/\s+/g, "").toUpperCase();
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) return false;
// Die ersten vier Zeichen ans Ende, Buchstaben zu Zahlen (A=10 … Z=35).
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (char) =>
String(char.charCodeAt(0) - 55),
);
// Stückweise rechnen — die Zahl wäre für einen JS-Number zu groß.
let remainder = 0;
for (const digit of numeric) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder === 1;
}
export function formatIban(value: string): string {
return (
value
.replace(/\s+/g, "")
.toUpperCase()
.match(/.{1,4}/g)
?.join(" ") ?? ""
);
}
/* ------------------------------------------------------------- Geokoordinaten */
/**
* Zieht Koordinaten aus einem Kartenlink oder einer Texteingabe.
* Erkannt werden unter anderem:
* https://www.google.com/maps/@52.5163,13.3777,15z
* https://www.google.com/maps?q=52.5163,13.3777
* https://maps.apple.com/?ll=52.5163,13.3777
* 52.5163, 13.3777
*/
export function parseCoordinates(
input: string,
): { latitude: number; longitude: number } | null {
const patterns = [
/[@!]\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/,
/(?:^|[?&])(?:q|ll|daddr|sll)=(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/,
/^\s*(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)\s*$/,
];
for (const pattern of patterns) {
const match = pattern.exec(input);
if (!match) continue;
const latitude = Number(match[1]);
const longitude = Number(match[2]);
if (
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
Math.abs(latitude) <= 90 &&
Math.abs(longitude) <= 180
) {
return { latitude, longitude };
}
}
return null;
}
/** Koordinate ohne unnötige Nullen, auf sechs Nachkommastellen begrenzt. */
export function formatCoordinate(value: number): string {
return String(Number(value.toFixed(6)));
}
+44 -91
View File
@@ -1,107 +1,54 @@
import { import {
defineComingSoonType,
isQrTypeId, isQrTypeId,
QR_TYPE_IDS, QR_TYPE_IDS,
type QrTypeId, type QrTypeId,
type QrTypeModule, type QrTypeModule,
} from "@/lib/qr/type-module"; } from "@/lib/qr/type-module";
import { appType } from "@/lib/qr/types/app";
import { emailType } from "@/lib/qr/types/email";
import { feedbackType } from "@/lib/qr/types/feedback";
import { imageType } from "@/lib/qr/types/image";
import { linksType } from "@/lib/qr/types/links";
import { locationType } from "@/lib/qr/types/location";
import { paymentType } from "@/lib/qr/types/payment";
import { pdfType } from "@/lib/qr/types/pdf";
import { smsType } from "@/lib/qr/types/sms";
import { socialType } from "@/lib/qr/types/social";
import { vcardType } from "@/lib/qr/types/vcard";
import { videoType } from "@/lib/qr/types/video";
import { websiteType } from "@/lib/qr/types/website"; import { websiteType } from "@/lib/qr/types/website";
import { whatsappType } from "@/lib/qr/types/whatsapp";
import { wifiType } from "@/lib/qr/types/wifi";
/** /**
* Zentrale Registry aller QR-Typen. Die Reihenfolge bestimmt die Reihenfolge * Zentrale Registry aller QR-Typen. Die Reihenfolge bestimmt die Reihenfolge
* der Kacheln auf der Startseite. * der Kacheln auf der Startseite.
* *
* Ein neuer Typ in V2: den `defineComingSoonType`-Eintrag durch ein echtes * Ein neuer Typ besteht aus einem Modul (siehe `types/website.ts`), einem
* Modul (siehe `types/website.ts`) ersetzen und die zugehörige Formular- * Eintrag hier, einer Formular-Komponente in
* Komponente in `src/components/qr-forms/index.tsx` registrieren. Am * `src/components/qr-forms/index.ts` und — falls er eine eigene Seite
* mitbringt — einer Landingpage in `src/components/landing/index.ts`. Am
* restlichen Code ändert sich nichts. * restlichen Code ändert sich nichts.
*
* Für einen angekündigten, aber noch nicht gebauten Typ gibt es weiterhin
* `defineComingSoonType` in `type-module.ts`.
*/ */
const MODULES: readonly QrTypeModule[] = [ const MODULES: readonly QrTypeModule[] = [
websiteType, websiteType,
defineComingSoonType({ vcardType,
id: "vcard", appType,
title: "vCard", linksType,
description: "Digitale Visitenkarte mit Kontaktdaten.", socialType,
icon: "contact", whatsappType,
}), pdfType,
defineComingSoonType({ imageType,
id: "app", videoType,
title: "App-Link", emailType,
description: "Leite je nach Gerät in den passenden App-Store.", smsType,
icon: "smartphone", locationType,
}), paymentType,
defineComingSoonType({ wifiType,
id: "links", feedbackType,
title: "Mehrere Links",
description: "Eine Landingpage mit mehreren Zielen.",
icon: "list",
}),
defineComingSoonType({
id: "social",
title: "Social Media",
description: "Bündle deine Profile in einem Code.",
icon: "share",
}),
defineComingSoonType({
id: "whatsapp",
title: "WhatsApp",
description: "Starte einen Chat mit vorbereiteter Nachricht.",
icon: "chat",
}),
defineComingSoonType({
id: "pdf",
title: "PDF",
description: "Stelle ein PDF-Dokument zum Abruf bereit.",
icon: "document",
}),
defineComingSoonType({
id: "image",
title: "Bild",
description: "Zeige ein Bild oder eine Galerie.",
icon: "image",
}),
defineComingSoonType({
id: "video",
title: "Video",
description: "Verlinke ein Video oder einen Stream.",
icon: "video",
}),
defineComingSoonType({
id: "email",
title: "E-Mail",
description: "Öffne eine vorbereitete E-Mail.",
icon: "mail",
}),
defineComingSoonType({
id: "sms",
title: "SMS",
description: "Öffne eine vorbereitete Kurznachricht.",
icon: "message",
}),
defineComingSoonType({
id: "location",
title: "Standort",
description: "Zeige einen Ort auf der Karte.",
icon: "pin",
}),
defineComingSoonType({
id: "payment",
title: "Zahlung",
description: "Fordere eine Zahlung per QR-Code an.",
icon: "card",
}),
defineComingSoonType({
id: "wifi",
title: "WLAN",
description: "Verbinde Gäste ohne Passworteingabe.",
icon: "wifi",
}),
defineComingSoonType({
id: "feedback",
title: "Feedback",
description: "Sammle Bewertungen und Rückmeldungen.",
icon: "star",
}),
]; ];
export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries( export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries(
@@ -120,10 +67,16 @@ export function getQrType(id: string): QrTypeModule | undefined {
return isQrTypeId(id) ? qrTypes[id] : undefined; return isQrTypeId(id) ? qrTypes[id] : undefined;
} }
/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */ /**
export function getActiveQrType(id: string): QrTypeModule | undefined { * Liefert den Typ nur, wenn er in dieser Version ausprogrammiert ist.
*
* Achtung: das ist noch keine Freigabe. Ob ein Typ neu angelegt werden darf,
* entscheidet zusätzlich die Verwaltung — dafür `requireAvailableQrType` aus
* `src/lib/qr/availability.ts` verwenden.
*/
export function getImplementedQrType(id: string): QrTypeModule | undefined {
const qrType = getQrType(id); const qrType = getQrType(id);
return qrType?.active ? qrType : undefined; return qrType?.implemented ? qrType : undefined;
} }
export { QR_TYPE_IDS, isQrTypeId }; export { QR_TYPE_IDS, isQrTypeId };
+2 -1
View File
@@ -3,9 +3,10 @@ import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode"; import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import type { QrDesign } from "@/lib/qr/design"; import type { QrDesign } from "@/lib/qr/design";
import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */ /** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
export class QrRenderError extends Error {} export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions { function toOptions(design: QrDesign): QRCodeRenderersOptions {
return { return {
+71 -23
View File
@@ -1,8 +1,16 @@
import "server-only";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db";
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 {
import { QR_TYPE_IDS, type QrTypeModule } from "@/lib/qr/type-module"; QR_TYPE_IDS,
QrRequestError,
QrTypeError,
type QrTypeModule,
} from "@/lib/qr/type-module";
import { shortUrl } from "@/lib/env"; import { shortUrl } from "@/lib/env";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
@@ -26,17 +34,45 @@ export const qrRequestSchema = z.object({
mode: qrModeSchema.default("static"), mode: qrModeSchema.default("static"),
/** Slug eines bereits gespeicherten dynamischen Codes. */ /** Slug eines bereits gespeicherten dynamischen Codes. */
slug: z.string().trim().optional(), slug: z.string().trim().optional(),
/** Domain, unter der die Kurz-URL entsteht. */
domainId: z.string().trim().optional(),
}); });
export type QrRequest = z.infer<typeof qrRequestSchema>; export type QrRequest = z.infer<typeof qrRequestSchema>;
/** Fehler mit einer Meldung, die unverändert an die Oberfläche geht. */ // Liegt in type-module.ts, damit auch availability.ts ihn werfen kann, ohne
export class QrRequestError extends Error { // dass ein Ringschluss zwischen den beiden Modulen entsteht.
constructor( export { QrRequestError };
message: string,
readonly status: number = 400, /**
) { * Prüft, ob sich aus diesen Daten überhaupt ein Weiterleitungsziel bilden
super(message); * lässt — und zwar, indem es einmal gebildet wird.
*
* `supportsDynamic` ist eine Eigenschaft des Typs, aber nicht jede Eingabe
* eines dynamikfähigen Typs taugt dafür: ein GiroCode etwa ist kein Web-Ziel,
* obwohl der Typ „Zahlung“ in seiner Link-Variante sehr wohl dynamisch sein
* kann. Ohne diese Probe entstünde ein gespeicherter Code, der erst beim
* Scannen auffällt — also genau dann, wenn er schon gedruckt ist.
*/
export function assertDynamicPossible(
qrType: QrTypeModule,
data: Record<string, unknown>,
): void {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
// Typen mit eigener Landingpage haben kein fremdes Ziel, das scheitern
// könnte — die Probe wäre gegenstandslos.
if (qrType.hasLandingPage) return;
try {
qrType.redirectTarget(data, {});
} catch (error) {
if (error instanceof QrTypeError) {
throw new QrRequestError(error.message);
}
throw error;
} }
} }
@@ -51,14 +87,10 @@ export type ResolvedQrRequest = {
* Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt: * Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt:
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL. * bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
*/ */
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest { export async function resolveQrRequest(
const qrType = getActiveQrType(request.type); request: QrRequest,
if (!qrType) { ): Promise<ResolvedQrRequest> {
throw new QrRequestError( const qrType = await requireAvailableQrType(request.type);
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const validated = qrType.validate(request.data); const validated = qrType.validate(request.data);
if (!validated.ok) { if (!validated.ok) {
@@ -66,22 +98,38 @@ export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
} }
if (request.mode === "dynamic") { if (request.mode === "dynamic") {
if (!qrType.supportsDynamic) { assertDynamicPossible(qrType, validated.data);
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
const slug = request.slug; const slug = request.slug;
if (slug !== undefined && !isValidSlug(slug)) { if (slug !== undefined && !isValidSlug(slug)) {
throw new QrRequestError("Ungültige Kurz-URL."); throw new QrRequestError("Ungültige Kurz-URL.");
} }
// Der Hostname kommt aus der Datenbank, nicht aus der Anfrage: sonst
// ließe sich eine beliebige Fremddomain in einen Code kodieren.
let hostname: string | undefined;
if (request.domainId) {
const domain = await prisma.domain.findUnique({
where: { id: request.domainId },
select: { hostname: true },
});
if (!domain) {
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
}
hostname = domain.hostname;
}
return { return {
qrType, qrType,
data: validated.data, data: validated.data,
content: shortUrl(slug ?? PREVIEW_SLUG), content: shortUrl(slug ?? PREVIEW_SLUG, hostname),
}; };
} }
if (!qrType.supportsStatic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
);
}
return { return {
qrType, qrType,
data: validated.data, data: validated.data,
+192
View File
@@ -0,0 +1,192 @@
/**
* Katalog der Netzwerke, die der Typ „Social Media“ kennt.
*
* Gemeinsame Grundlage von Formular, Validierung und Landingpage. Ein
* weiteres Netzwerk ist genau ein Eintrag mehr.
*
* Zu den Zeichen: Statt nachgebauter Markenlogos steht ein Kreis in der
* Hausfarbe mit ein bis zwei Buchstaben. Ein schlecht nachgezeichnetes Logo
* sieht schlechter aus als ein sauberes Monogramm — und die Netzwerknamen
* stehen ohnehin daneben.
*/
export type SocialNetwork = {
id: string;
label: string;
/** Hausfarbe für das Monogramm. */
color: string;
/** Ein bis zwei Buchstaben. */
monogram: string;
/**
* Wird einem eingegebenen Kürzel vorangestellt, wenn jemand statt der
* vollen Adresse nur „@name“ eintippt. Fehlt er, ist nur eine vollständige
* Adresse zulässig.
*/
prefix?: string;
/** Beispiel für das Eingabefeld. */
placeholder: string;
};
export const SOCIAL_NETWORKS: readonly SocialNetwork[] = [
{
id: "instagram",
label: "Instagram",
color: "#C13584",
monogram: "Ig",
prefix: "https://instagram.com/",
placeholder: "@firma oder https://instagram.com/firma",
},
{
id: "facebook",
label: "Facebook",
color: "#1877F2",
monogram: "f",
prefix: "https://facebook.com/",
placeholder: "firma oder https://facebook.com/firma",
},
{
id: "linkedin",
label: "LinkedIn",
color: "#0A66C2",
monogram: "in",
prefix: "https://linkedin.com/company/",
placeholder: "firma oder https://linkedin.com/company/firma",
},
{
id: "xing",
label: "Xing",
color: "#0698A0",
monogram: "Xi",
prefix: "https://xing.com/pages/",
placeholder: "firma oder https://xing.com/pages/firma",
},
{
id: "youtube",
label: "YouTube",
color: "#FF0000",
monogram: "Yt",
prefix: "https://youtube.com/@",
placeholder: "@firma oder https://youtube.com/@firma",
},
{
id: "tiktok",
label: "TikTok",
color: "#111827",
monogram: "Tt",
prefix: "https://tiktok.com/@",
placeholder: "@firma oder https://tiktok.com/@firma",
},
{
id: "x",
label: "X (Twitter)",
color: "#0F172A",
monogram: "X",
prefix: "https://x.com/",
placeholder: "@firma oder https://x.com/firma",
},
{
id: "threads",
label: "Threads",
color: "#0F172A",
monogram: "Th",
prefix: "https://threads.net/@",
placeholder: "@firma oder https://threads.net/@firma",
},
{
id: "mastodon",
label: "Mastodon",
color: "#6364FF",
monogram: "Ma",
placeholder: "https://mastodon.social/@firma",
},
{
id: "whatsapp",
label: "WhatsApp",
color: "#25D366",
monogram: "Wa",
prefix: "https://wa.me/",
placeholder: "4915112345678 oder https://wa.me/4915112345678",
},
{
id: "telegram",
label: "Telegram",
color: "#229ED9",
monogram: "Tg",
prefix: "https://t.me/",
placeholder: "@firma oder https://t.me/firma",
},
{
id: "pinterest",
label: "Pinterest",
color: "#BD081C",
monogram: "Pi",
prefix: "https://pinterest.com/",
placeholder: "firma oder https://pinterest.com/firma",
},
{
id: "snapchat",
label: "Snapchat",
color: "#B45309",
monogram: "Sc",
prefix: "https://snapchat.com/add/",
placeholder: "firma oder https://snapchat.com/add/firma",
},
{
id: "github",
label: "GitHub",
color: "#24292F",
monogram: "Gh",
prefix: "https://github.com/",
placeholder: "firma oder https://github.com/firma",
},
{
id: "website",
label: "Website",
color: "#4F46E5",
monogram: "Web",
placeholder: "https://example.com",
},
];
const BY_ID = new Map(SOCIAL_NETWORKS.map((network) => [network.id, network]));
export function getSocialNetwork(id: string): SocialNetwork | undefined {
return BY_ID.get(id);
}
export const SOCIAL_NETWORK_IDS = SOCIAL_NETWORKS.map((network) => network.id);
/**
* Macht aus einer Eingabe eine vollständige Adresse.
*
* Wer „@firma“ eintippt, meint das Profil bei dem gewählten Netzwerk — das
* ist die naheliegende Eingabe und soll nicht als Fehler enden. Eine bereits
* vollständige Adresse bleibt unangetastet, damit auch abweichende
* Profil-Adressen (eigene Domain, Untergruppe) möglich bleiben.
*/
export function expandSocialHandle(networkId: string, input: string): string {
const value = input.trim();
if (value.length === 0) return value;
if (/^https?:\/\//i.test(value)) return value;
const network = getSocialNetwork(networkId);
if (!network?.prefix) return value;
const handle = value.replace(/^[@/]+/, "");
if (handle.length === 0) return value;
return network.prefix + handle;
}
/**
* Kurzform der Adresse für die zweite Zeile — ohne Schema und ohne „www.“,
* denn dort steht das Wesentliche: bei wem man landet.
*/
export function socialHandleLabel(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/+$/, "");
const host = parsed.host.replace(/^www\./, "");
return path && path !== "/" ? `${host}${path}` : host;
} catch {
return url;
}
}
+98 -11
View File
@@ -44,7 +44,43 @@ export type ValidationResult =
* Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche * Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche
* angezeigt werden kann. * angezeigt werden kann.
*/ */
export class QrTypeError extends Error {} /**
* Basisklasse für alle Fehler, deren Meldung unverändert an die Oberfläche
* gehen darf.
*
* Das Merkmal `userFacing` ist bewusst eine Eigenschaft und keine Frage von
* `instanceof`: dieses Modul landet sowohl im Server- als auch im
* Client-Bündel, und dann gibt es zwei Klassenidentitäten. Ein
* `instanceof`-Vergleich schlägt in dem Fall fehl, und eine saubere
* 400er-Antwort würde stillschweigend zu einem 500er.
*/
export class UserFacingError extends Error {
readonly userFacing = true;
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
this.name = new.target.name;
}
}
export class QrTypeError extends UserFacingError {}
/**
* Was über den Zugriff bekannt ist, der die Weiterleitung ausgelöst hat.
* Typen wie der App-Link entscheiden daran, wohin es geht.
*/
export type RedirectContext = {
userAgent?: string;
};
/**
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
* dem passenden HTTP-Status.
*/
export class QrRequestError extends UserFacingError {}
/** /**
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ * Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ
@@ -57,10 +93,32 @@ export interface QrTypeModule {
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly icon: IconName; readonly icon: IconName;
/** Ist der Typ in dieser Version nutzbar? */ /**
readonly active: boolean; * Ist der Typ in dieser Version ausprogrammiert?
*
* Nicht zu verwechseln mit der Freigabe: ob ein Typ in der Oberfläche
* erscheint, entscheidet zusätzlich die Verwaltung — siehe
* `src/lib/qr/availability.ts`.
*/
readonly implemented: boolean;
/**
* Unterstützt der Typ statische Codes? Für Typen, deren Ziel erst beim
* Scannen feststeht — etwa der App-Link, der je nach Gerät woandershin
* führt — ist das nicht möglich.
*/
readonly supportsStatic: boolean;
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */ /** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
readonly supportsDynamic: boolean; readonly supportsDynamic: boolean;
/**
* Führt ein dynamischer Code dieses Typs auf eine Seite, die diese
* Anwendung selbst ausliefert?
*
* Solche Typen — eine Linksammlung, ein hinterlegtes PDF, ein
* Feedback-Formular — haben kein fremdes Ziel, auf das man weiterleiten
* könnte. Statt `redirectTarget` zu befragen, schickt `/r/[slug]` sie auf
* die Landingpage unter `/s/[slug]`.
*/
readonly hasLandingPage: boolean;
/** Startwerte für ein leeres Formular. */ /** Startwerte für ein leeres Formular. */
readonly defaultData: QrFormData; readonly defaultData: QrFormData;
/** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */ /** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */
@@ -68,9 +126,16 @@ export interface QrTypeModule {
/** Inhalt, der bei einem statischen Code in den QR-Code kodiert wird. */ /** Inhalt, der bei einem statischen Code in den QR-Code kodiert wird. */
encode(data: unknown): string; encode(data: unknown): string;
/** Ziel, auf das ein dynamischer Code weiterleitet. */ /** Ziel, auf das ein dynamischer Code weiterleitet. */
redirectTarget(data: unknown): string; redirectTarget(data: unknown, context?: RedirectContext): string;
/** Einzeilige Zusammenfassung für Listen und Übersichten. */ /** Einzeilige Zusammenfassung für Listen und Übersichten. */
summary(data: unknown): string; summary(data: unknown): string;
/**
* Ids hochgeladener Dateien, auf die diese Daten verweisen.
*
* Daran hängt, welche Datei zu welchem Code gehört — und damit, was beim
* Löschen mitgeht und was als verwaist weggeräumt wird.
*/
uploadIds(data: unknown): string[];
} }
type QrTypeSpec<TSchema extends z.ZodType> = { type QrTypeSpec<TSchema extends z.ZodType> = {
@@ -78,18 +143,31 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
title: string; title: string;
description: string; description: string;
icon: IconName; icon: IconName;
active: boolean; implemented: boolean;
/** Voreinstellung: true. */
supportsStatic?: boolean;
supportsDynamic: boolean; supportsDynamic: boolean;
/** Voreinstellung: false. Siehe `QrTypeModule.hasLandingPage`. */
hasLandingPage?: boolean;
schema: TSchema; schema: TSchema;
defaultData: QrFormData; defaultData: QrFormData;
/** Inhalt des statischen QR-Codes. */ /**
* Inhalt des statischen QR-Codes. Typen ohne statische Unterstützung
* werfen hier — sie werden nur über `redirectTarget` ausgeliefert.
*/
encode: (data: z.output<TSchema>) => string; encode: (data: z.output<TSchema>) => string;
/** /**
* Weiterleitungsziel eines dynamischen Codes. Fällt auf `encode` zurück — * Weiterleitungsziel eines dynamischen Codes. Fällt auf `encode` zurück —
* für URL-basierte Typen ist beides identisch. * für URL-basierte Typen ist beides identisch. Der Kontext enthält, was
* über den scannenden Zugriff bekannt ist.
*/ */
redirectTarget?: (data: z.output<TSchema>) => string; redirectTarget?: (
data: z.output<TSchema>,
context: RedirectContext,
) => string;
summary: (data: z.output<TSchema>) => string; summary: (data: z.output<TSchema>) => string;
/** Voreinstellung: keine. Siehe `QrTypeModule.uploadIds`. */
uploadIds?: (data: z.output<TSchema>) => string[];
}; };
function firstIssueMessage(error: z.ZodError): string { function firstIssueMessage(error: z.ZodError): string {
@@ -120,8 +198,10 @@ export function defineQrType<TSchema extends z.ZodType>(
title: spec.title, title: spec.title,
description: spec.description, description: spec.description,
icon: spec.icon, icon: spec.icon,
active: spec.active, implemented: spec.implemented,
supportsStatic: spec.supportsStatic ?? true,
supportsDynamic: spec.supportsDynamic, supportsDynamic: spec.supportsDynamic,
hasLandingPage: spec.hasLandingPage ?? false,
defaultData: spec.defaultData, defaultData: spec.defaultData,
validate(input) { validate(input) {
const result = spec.schema.safeParse(input); const result = spec.schema.safeParse(input);
@@ -130,8 +210,12 @@ export function defineQrType<TSchema extends z.ZodType>(
: { ok: false, message: firstIssueMessage(result.error) }; : { ok: false, message: firstIssueMessage(result.error) };
}, },
encode: (data) => spec.encode(parse(data)), encode: (data) => spec.encode(parse(data)),
redirectTarget: (data) => (spec.redirectTarget ?? spec.encode)(parse(data)), redirectTarget: (data, context = {}) =>
spec.redirectTarget
? spec.redirectTarget(parse(data), context)
: spec.encode(parse(data)),
summary: (data) => spec.summary(parse(data)), summary: (data) => spec.summary(parse(data)),
uploadIds: (data) => (spec.uploadIds ? spec.uploadIds(parse(data)) : []),
}; };
} }
@@ -157,8 +241,10 @@ export function defineComingSoonType(spec: {
title: spec.title, title: spec.title,
description: spec.description, description: spec.description,
icon: spec.icon, icon: spec.icon,
active: false, implemented: false,
supportsStatic: false,
supportsDynamic: false, supportsDynamic: false,
hasLandingPage: false,
defaultData: {}, defaultData: {},
validate: () => ({ validate: () => ({
ok: false, ok: false,
@@ -167,5 +253,6 @@ export function defineComingSoonType(spec: {
encode: notAvailable, encode: notAvailable,
redirectTarget: notAvailable, redirectTarget: notAvailable,
summary: () => "Noch nicht verfügbar", summary: () => "Noch nicht verfügbar",
uploadIds: () => [],
}; };
} }
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
const storeUrl = (feld: string) =>
z
.string()
.trim()
.max(2048, { error: `${feld} ist zu lang.` })
.default("")
.refine((value) => value.length === 0 || isHttpUrl(value), {
error: `${feld} muss mit http:// oder https:// beginnen.`,
});
export const appDataSchema = z
.object({
ios: storeUrl("Die iOS-Adresse"),
android: storeUrl("Die Android-Adresse"),
fallback: z
.string()
.trim()
.min(1, { error: "Bitte gib eine Adresse für alle übrigen Geräte ein." })
.refine(isHttpUrl, {
error:
"Die Ausweichadresse muss mit http:// oder https:// beginnen, z. B. https://example.com",
}),
})
.refine((data) => data.ios.length > 0 || data.android.length > 0, {
error:
"Bitte gib mindestens eine Store-Adresse an — sonst landen alle auf der Ausweichadresse.",
path: ["ios"],
});
export type AppData = z.infer<typeof appDataSchema>;
/**
* Erkennt das Betriebssystem am User-Agent.
*
* Bewusst grob gehalten: es geht nur um die Frage „iOS, Android oder etwas
* anderes“, und im Zweifel gewinnt die Ausweichadresse. iPadOS meldet sich
* seit Version 13 als Macintosh, lässt sich aber am Berührungsbildschirm
* erkennen — das steht im User-Agent allerdings nicht, weshalb iPads auf dem
* Desktop-Weg landen können. Für einen App-Store-Link ist das hinnehmbar.
*/
export function detectPlatform(
userAgent: string | undefined,
): "ios" | "android" | "other" {
if (!userAgent) return "other";
const ua = userAgent.toLowerCase();
if (/android/.test(ua)) return "android";
if (/iphone|ipad|ipod/.test(ua)) return "ios";
// iPadOS ab 13 gibt sich als Macintosh aus und ergänzt "mobile".
if (/macintosh/.test(ua) && /mobile/.test(ua)) return "ios";
return "other";
}
export function resolveAppTarget(
data: AppData,
userAgent: string | undefined,
): string {
const platform = detectPlatform(userAgent);
if (platform === "ios" && data.ios) return data.ios;
if (platform === "android" && data.android) return data.android;
return data.fallback;
}
export const appType = defineQrType({
id: "app",
title: "App-Link",
description: "Leite je nach Gerät in den passenden App-Store.",
icon: "smartphone",
implemented: true,
// Das Ziel steht erst beim Scannen fest — je nach Gerät ein anderes.
// Ein statischer Code könnte immer nur eine der Adressen enthalten.
supportsStatic: false,
supportsDynamic: true,
schema: appDataSchema,
defaultData: { ios: "", android: "", fallback: "" },
encode: () => {
throw new QrTypeError(
"Ein App-Link funktioniert nur als dynamischer Code — erst beim Scannen steht fest, welches Gerät ihn öffnet.",
);
},
redirectTarget: (data, context) => resolveAppTarget(data, context.userAgent),
summary: (data) => {
const stores = [data.ios && "iOS", data.android && "Android"].filter(
Boolean,
);
return stores.length > 0
? `App-Link (${stores.join(" + ")})`
: "App-Link";
},
});
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { queryString } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
export const emailDataSchema = z.object({
empfaenger: z
.string()
.trim()
.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, z. B. name@example.com",
}),
betreff: z
.string()
.trim()
.max(200, { error: "Der Betreff darf höchstens 200 Zeichen lang sein." })
.default(""),
text: z
.string()
.max(1000, { error: "Der Text darf höchstens 1000 Zeichen lang sein." })
.default(""),
});
export type EmailData = z.infer<typeof emailDataSchema>;
export const emailType = defineQrType({
id: "email",
title: "E-Mail",
description: "Öffne eine vorbereitete E-Mail.",
icon: "mail",
implemented: true,
supportsDynamic: true,
schema: emailDataSchema,
defaultData: { empfaenger: "", betreff: "", text: "" },
encode: (data) =>
`mailto:${data.empfaenger}${queryString({
subject: data.betreff,
body: data.text,
})}`,
summary: (data) =>
data.betreff ? `${data.empfaenger} — ${data.betreff}` : data.empfaenger,
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from "zod";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
/** Wie die Bewertung dargestellt wird. */
export const FEEDBACK_SCALES = {
stars: { label: "Sterne (1–5)", symbols: ["★", "★", "★", "★", "★"] },
faces: { label: "Gesichter", symbols: ["😞", "🙁", "😐", "🙂", "😀"] },
} as const;
export type FeedbackScale = keyof typeof FEEDBACK_SCALES;
export const feedbackDataSchema = z.object({
title: z
.string()
.trim()
.min(1, { error: "Bitte gib der Seite eine Überschrift." })
.max(80, { error: "Die Überschrift ist zu lang (höchstens 80 Zeichen)." }),
question: z
.string()
.trim()
.min(1, { error: "Bitte formuliere eine Frage." })
.max(160, { error: "Die Frage ist zu lang (höchstens 160 Zeichen)." })
.default("Wie zufrieden bist du?"),
scale: z.enum(["stars", "faces"]).default("stars"),
/** Darf zusätzlich zur Note etwas geschrieben werden? */
allowComment: z.boolean().default(true),
commentLabel: z
.string()
.trim()
.max(120, { error: "Der Text ist zu lang (höchstens 120 Zeichen)." })
.default("Möchtest du uns noch etwas sagen?"),
thankYou: z
.string()
.trim()
.max(200, { error: "Der Text ist zu lang (höchstens 200 Zeichen)." })
.default("Danke für deine Rückmeldung!"),
});
export type FeedbackData = z.infer<typeof feedbackDataSchema>;
export const feedbackType = defineQrType({
id: "feedback",
title: "Feedback",
description: "Sammle Bewertungen und Rückmeldungen.",
icon: "star",
implemented: true,
// Ein Formular braucht eine Seite — und eine Adresse, an die es die
// Antworten schickt. Beides entsteht erst beim Speichern.
supportsStatic: false,
supportsDynamic: true,
hasLandingPage: true,
schema: feedbackDataSchema,
defaultData: {
title: "",
question: "Wie zufrieden bist du?",
scale: "stars",
allowComment: true,
commentLabel: "Möchtest du uns noch etwas sagen?",
thankYou: "Danke für deine Rückmeldung!",
},
encode: () => {
throw new QrTypeError(
"Ein Feedback-Bogen funktioniert nur als dynamischer Code — die Antworten brauchen eine Adresse, an die sie gehen.",
);
},
summary: (data) => data.title,
});
+64
View File
@@ -0,0 +1,64 @@
import { z } from "zod";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
export const MAX_IMAGES = 10;
export const imageEntrySchema = z.object({
fileId: z.string().trim().min(1, { error: "Bitte lade ein Bild hoch." }),
filename: z.string().trim().max(200).default("bild"),
size: z.number().int().nonnegative().default(0),
/** Bildunterschrift — auch als Alternativtext für Screenreader. */
caption: z
.string()
.trim()
.max(120, { error: "Die Bildunterschrift ist zu lang (höchstens 120 Zeichen)." })
.default(""),
});
export const imageDataSchema = z.object({
title: z
.string()
.trim()
.max(80, { error: "Der Titel ist zu lang (höchstens 80 Zeichen)." })
.default(""),
description: z
.string()
.trim()
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
.default(""),
images: z
.array(imageEntrySchema)
.min(1, { error: "Bitte lade mindestens ein Bild hoch." })
.max(MAX_IMAGES, {
error: `Mehr als ${MAX_IMAGES} Bilder sind nicht vorgesehen.`,
}),
});
export type ImageData = z.infer<typeof imageDataSchema>;
export type ImageEntry = z.infer<typeof imageEntrySchema>;
export const imageType = defineQrType({
id: "image",
title: "Bild",
description: "Zeige ein Bild oder eine Galerie.",
icon: "image",
implemented: true,
supportsStatic: false,
supportsDynamic: true,
hasLandingPage: true,
schema: imageDataSchema,
defaultData: { title: "", description: "", images: [] },
encode: () => {
throw new QrTypeError(
"Hinterlegte Bilder funktionieren nur als dynamischer Code — sie brauchen eine Seite, die sie zeigt.",
);
},
summary: (data) => {
if (data.title) return data.title;
return data.images.length === 1
? data.images[0].filename || "Ein Bild"
: `${data.images.length} Bilder`;
},
uploadIds: (data) => data.images.map((image) => image.fileId),
});
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
/** Mehr passt auf eine Handy-Seite ohnehin nicht sinnvoll. */
export const MAX_LINKS = 20;
export const linkEntrySchema = z.object({
label: z
.string()
.trim()
.min(1, { error: "Bitte gib jedem Link eine Beschriftung." })
.max(80, { error: "Die Beschriftung ist zu lang (höchstens 80 Zeichen)." }),
url: z
.string()
.trim()
.min(1, { error: "Bitte gib zu jeder Beschriftung auch eine Adresse an." })
.max(2048, { error: "Die Adresse ist zu lang." })
.refine(isHttpUrl, {
error:
"Jede Adresse muss mit http:// oder https:// beginnen, z. B. https://example.com",
}),
});
export const linksDataSchema = z.object({
title: z
.string()
.trim()
.min(1, { error: "Bitte gib der Seite eine Überschrift." })
.max(80, { error: "Die Überschrift ist zu lang (höchstens 80 Zeichen)." }),
description: z
.string()
.trim()
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
.default(""),
links: z
.array(linkEntrySchema)
.min(1, { error: "Bitte lege mindestens einen Link an." })
.max(MAX_LINKS, {
error: `Mehr als ${MAX_LINKS} Links sind nicht vorgesehen.`,
}),
});
export type LinksData = z.infer<typeof linksDataSchema>;
export type LinkEntry = z.infer<typeof linkEntrySchema>;
export const linksType = defineQrType({
id: "links",
title: "Mehrere Links",
description: "Eine Landingpage mit mehreren Zielen.",
icon: "list",
implemented: true,
// Eine Linksammlung braucht eine Seite, die sie anzeigt. In einen statischen
// Code passt immer nur eine einzige Adresse.
supportsStatic: false,
supportsDynamic: true,
hasLandingPage: true,
schema: linksDataSchema,
defaultData: {
title: "",
description: "",
links: [
{ label: "", url: "" },
{ label: "", url: "" },
],
},
encode: () => {
throw new QrTypeError(
"Eine Linksammlung funktioniert nur als dynamischer Code — sie braucht eine Seite, die die Links anzeigt.",
);
},
summary: (data) =>
`${data.title} (${data.links.length} ${data.links.length === 1 ? "Link" : "Links"})`,
});
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
import { formatCoordinate } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
const coordinate = (
max: number,
achse: "Breitengrad" | "Längengrad",
) =>
z.coerce
.number({ error: `Bitte gib den ${achse} als Zahl ein.` })
.refine((value) => Number.isFinite(value) && Math.abs(value) <= max, {
error: `Der ${achse} muss zwischen −${max} und ${max} liegen.`,
});
export const locationDataSchema = z.object({
breitengrad: coordinate(90, "Breitengrad"),
laengengrad: coordinate(180, "Längengrad"),
});
export type LocationData = z.infer<typeof locationDataSchema>;
export const locationType = defineQrType({
id: "location",
title: "Standort",
description: "Zeige einen Ort auf der Karte.",
icon: "pin",
implemented: true,
supportsDynamic: true,
schema: locationDataSchema,
defaultData: { breitengrad: "", laengengrad: "" },
// geo: ist das standardisierte Format (RFC 5870) und öffnet auf allen
// Geräten die jeweils eingerichtete Kartenanwendung.
encode: (data) =>
`geo:${formatCoordinate(data.breitengrad)},${formatCoordinate(data.laengengrad)}`,
// Für die Weiterleitung braucht es eine Webadresse — ein geo:-Ziel würde
// ein Browser nicht verstehen.
redirectTarget: (data) =>
`https://www.openstreetmap.org/?mlat=${formatCoordinate(data.breitengrad)}&mlon=${formatCoordinate(data.laengengrad)}#map=17/${formatCoordinate(data.breitengrad)}/${formatCoordinate(data.laengengrad)}`,
summary: (data) =>
`${formatCoordinate(data.breitengrad)}, ${formatCoordinate(data.laengengrad)}`,
});

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