Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e296b1e6c2 | ||
|
|
fdf6452b35 | ||
|
|
e627f7c372 | ||
|
|
b3d386b0d2 | ||
|
|
cdda55f9b0 | ||
|
|
6b2baa66c5 | ||
|
|
2a80f15e20 | ||
|
|
f1cdb3da13 | ||
|
|
94a373ff48 | ||
|
|
7f7cb6fa20 | ||
|
|
c6da20f419 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -339,7 +799,9 @@ deutschen Meldung.
|
|||||||
| `PATCH` | `/api/codes/{id}` | Ziel, Name oder Gestaltung ändern |
|
| `PATCH` | `/api/codes/{id}` | Ziel, Name oder Gestaltung ändern |
|
||||||
| `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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
+219
-17
@@ -10,27 +10,229 @@ 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
|
||||||
type String
|
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||||
label String?
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||||
data Json
|
slug String
|
||||||
fgColor String @default("#000000")
|
type String
|
||||||
bgColor String @default("#FFFFFF")
|
label String?
|
||||||
size Int @default(512)
|
data Json
|
||||||
ecLevel String @default("M")
|
fgColor String @default("#000000")
|
||||||
margin Int @default(4)
|
bgColor String @default("#FFFFFF")
|
||||||
scans Int @default(0)
|
size Int @default(512)
|
||||||
lastScanAt DateTime?
|
ecLevel String @default("M")
|
||||||
createdAt DateTime @default(now())
|
margin Int @default(4)
|
||||||
updatedAt DateTime @updatedAt
|
scans Int @default(0)
|
||||||
|
lastScanAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Kein Zugriff" };
|
||||||
|
|
||||||
|
export default function NoAccessPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 py-20 text-center sm:px-6">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<Icon name="lock" className="size-7" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold text-slate-900">Kein Zugriff</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
|
||||||
|
dass das ein Versehen ist, wende dich an einen Administrator.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { DomainAdmin } from "@/components/domain-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { baseUrlHostname } from "@/lib/domains";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Domains" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DomainsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/verwaltung"
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Domains
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain
|
||||||
|
kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain
|
||||||
|
eindeutig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DomainAdmin baseUrlHostname={baseUrlHostname()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { GroupAdmin } from "@/components/group-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import { can } from "@/lib/auth/policy";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Gruppen" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function GroupsPage() {
|
||||||
|
// Bewusst nur „angemeldet“: ein Gruppen-Administrator darf seine eigene
|
||||||
|
// Gruppe pflegen, auch ohne Rechte an der Installation.
|
||||||
|
const actor = await requirePageActor();
|
||||||
|
const canCreate = can(actor, "instance:manage");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href={canCreate ? "/verwaltung" : "/"}
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
{canCreate ? "Verwaltung" : "Zur Typ-Auswahl"}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Gruppen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. Jedes
|
||||||
|
Mitglied hat darin eine Rolle: Administrator, Bearbeiter oder
|
||||||
|
Betrachter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupAdmin canCreate={canCreate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { DEFAULT_DOMAIN_ID } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
label: z.string().trim().max(80).nullable().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Vollständige Liste der Gruppen, die diese Domain nutzen dürfen. Wer
|
||||||
|
* fehlt, verliert die Zuteilung.
|
||||||
|
*/
|
||||||
|
grants: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().trim().min(1),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({ where: { id } });
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label !== undefined || parsed.data.isActive !== undefined) {
|
||||||
|
await prisma.domain.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.label !== undefined
|
||||||
|
? { label: parsed.data.label || null }
|
||||||
|
: {}),
|
||||||
|
...(parsed.data.isActive !== undefined
|
||||||
|
? { isActive: parsed.data.isActive }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.grants !== undefined) {
|
||||||
|
const grants = parsed.data.grants;
|
||||||
|
|
||||||
|
const known = await prisma.group.findMany({
|
||||||
|
where: { id: { in: grants.map((g) => g.groupId) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== grants.length) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Mindestens eine der ausgewählten Gruppen existiert nicht mehr.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Gruppe, die diese Domain noch für Codes verwendet, darf die
|
||||||
|
// Zuteilung nicht verlieren — sonst zeigten deren Kurz-URLs auf eine
|
||||||
|
// Domain, die der Gruppe gar nicht mehr gehört.
|
||||||
|
const stillInUse = await prisma.qrCode.findMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
select: { group: { select: { name: true } } },
|
||||||
|
distinct: ["groupId"],
|
||||||
|
});
|
||||||
|
if (stillInUse.length > 0) {
|
||||||
|
const names = stillInUse.map((entry) => entry.group.name).join(", ");
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Diese Gruppen haben noch Codes unter dieser Domain: ${names}. Solange die Codes bestehen, muss die Zuteilung bleiben.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.domainGrant.deleteMany({
|
||||||
|
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
|
||||||
|
}),
|
||||||
|
...grants.map((grant) =>
|
||||||
|
prisma.domainGrant.upsert({
|
||||||
|
where: {
|
||||||
|
domainId_groupId: { domainId: id, groupId: grant.groupId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
domainId: id,
|
||||||
|
groupId: grant.groupId,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
},
|
||||||
|
update: { isDefault: grant.isDefault },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Je Gruppe darf nur eine Domain die Voreinstellung sein.
|
||||||
|
...grants
|
||||||
|
.filter((grant) => grant.isDefault)
|
||||||
|
.map((grant) =>
|
||||||
|
prisma.domainGrant.updateMany({
|
||||||
|
where: { groupId: grant.groupId, domainId: { not: id } },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (id === DEFAULT_DOMAIN_ID) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Standard-Domain lässt sich nicht löschen — sie fängt Aufrufe über nicht registrierte Adressen ab.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { codes: true } } },
|
||||||
|
});
|
||||||
|
if (!domain) {
|
||||||
|
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain._count.codes > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Unter dieser Domain liegen noch ${domain._count.codes} ${domain._count.codes === 1 ? "Code" : "Codes"}. Sie zu löschen würde gedruckte Codes ins Leere laufen lassen — schalte sie stattdessen ab, wenn sie nicht mehr genutzt werden soll.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.domain.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ensureDefaultDomain, normalizeHostname } from "@/lib/domains";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Hostname, optional mit Port. Bewusst ohne Schema und ohne Pfad — das
|
||||||
|
* ist der Wert, den ein Browser im `Host`-Header sendet.
|
||||||
|
*/
|
||||||
|
const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Hostnamen ein." })
|
||||||
|
.max(253, { error: "Der Hostname ist zu lang." })
|
||||||
|
.transform(normalizeHostname)
|
||||||
|
.refine((value) => /^[a-z0-9.-]+(:\d{1,5})?$/.test(value), {
|
||||||
|
error:
|
||||||
|
"Das ist kein gültiger Hostname. Erwartet wird z. B. qr.example.com — ohne https:// und ohne Pfad.",
|
||||||
|
})
|
||||||
|
.refine((value) => value.includes(".") || value.startsWith("localhost"), {
|
||||||
|
error: "Bitte gib den vollständigen Hostnamen an, z. B. qr.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
hostname: hostnameSchema,
|
||||||
|
label: z.string().trim().max(80).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
// Sorgt dafür, dass die Standard-Domain den Hostnamen aus BASE_URL trägt.
|
||||||
|
await ensureDefaultDomain();
|
||||||
|
|
||||||
|
const domains = await prisma.domain.findMany({
|
||||||
|
orderBy: { hostname: "asc" },
|
||||||
|
include: {
|
||||||
|
grants: { include: { group: { select: { id: true, name: true } } } },
|
||||||
|
_count: { select: { codes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
domains: domains.map((domain) => ({
|
||||||
|
id: domain.id,
|
||||||
|
hostname: domain.hostname,
|
||||||
|
label: domain.label,
|
||||||
|
isActive: domain.isActive,
|
||||||
|
codeCount: domain._count.codes,
|
||||||
|
grants: domain.grants.map((grant) => ({
|
||||||
|
groupId: grant.group.id,
|
||||||
|
groupName: grant.group.name,
|
||||||
|
isDefault: grant.isDefault,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.domain.findUnique({
|
||||||
|
where: { hostname: parsed.data.hostname },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError("Diese Domain ist bereits registriert.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
hostname: parsed.data.hostname,
|
||||||
|
label: parsed.data.label || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ domain: { id: domain.id, hostname: domain.hostname } },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80).optional(),
|
||||||
|
description: z.string().trim().max(300).nullable().optional(),
|
||||||
|
/**
|
||||||
|
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
|
||||||
|
* so bleibt der Zustand nach dem Speichern eindeutig.
|
||||||
|
*/
|
||||||
|
members: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
|
||||||
|
error: "Ungültige Rolle.",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({ where: { id } });
|
||||||
|
if (!group) {
|
||||||
|
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
|
||||||
|
// darf auch ein Administrator der Gruppe selbst pflegen.
|
||||||
|
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||||
|
requirePermission(actor, "instance:manage");
|
||||||
|
}
|
||||||
|
if (parsed.data.members !== undefined) {
|
||||||
|
requirePermission(actor, "group:manage", { groupId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
|
||||||
|
await prisma.group.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||||
|
...(parsed.data.description !== undefined
|
||||||
|
? { description: parsed.data.description || null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.members !== undefined) {
|
||||||
|
const members = parsed.data.members;
|
||||||
|
|
||||||
|
const known = await prisma.user.findMany({
|
||||||
|
where: { id: { in: members.map((m) => m.userId) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== members.length) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
|
||||||
|
// weil das Setzen der neuen Liste fehlgeschlagen ist.
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.membership.deleteMany({
|
||||||
|
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
|
||||||
|
}),
|
||||||
|
...members.map((member) =>
|
||||||
|
prisma.membership.upsert({
|
||||||
|
where: {
|
||||||
|
userId_groupId: { userId: member.userId, groupId: id },
|
||||||
|
},
|
||||||
|
create: { userId: member.userId, groupId: id, role: member.role },
|
||||||
|
update: { role: member.role },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
if (id === DEFAULT_GROUP_ID) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { codes: true } } },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group._count.codes > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.group.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
|
||||||
|
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
|
||||||
|
description: z.string().trim().max(300).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
if (actor.kind === "anonymous") {
|
||||||
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = visibleGroupIds(actor);
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
where: visible === null ? undefined : { id: { in: visible } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
memberships: {
|
||||||
|
include: { user: { select: { id: true, email: true, name: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
_count: { select: { codes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
groups: groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
codeCount: group._count.codes,
|
||||||
|
canManage: can(actor, "group:manage", { groupId: group.id }),
|
||||||
|
members: group.memberships.map((m) => ({
|
||||||
|
userId: m.user.id,
|
||||||
|
email: m.user.email,
|
||||||
|
name: m.user.name,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.group.findUnique({
|
||||||
|
where: { name: parsed.data.name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await prisma.group.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const feedbackSchema = z.object({
|
||||||
|
codeId: z.string().trim().min(1),
|
||||||
|
rating: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, { error: "Bitte wähle eine Bewertung." })
|
||||||
|
.max(5, { error: "Bitte wähle eine Bewertung." }),
|
||||||
|
comment: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1000, { error: "Der Text ist zu lang (höchstens 1000 Zeichen)." })
|
||||||
|
.default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grobe Bremse gegen Fluten.
|
||||||
|
*
|
||||||
|
* Im Arbeitsspeicher und damit pro Container — bei einem Dienst dieser Größe
|
||||||
|
* genügt das. Die Adresse wird nur als Schlüssel benutzt und nirgends
|
||||||
|
* gespeichert; nach dem Zeitfenster ist sie wieder weg.
|
||||||
|
*/
|
||||||
|
const FENSTER_MS = 60_000;
|
||||||
|
const MAX_PRO_FENSTER = 10;
|
||||||
|
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
||||||
|
|
||||||
|
function zuSchnell(schluessel: string): boolean {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
const eintrag = zaehler.get(schluessel);
|
||||||
|
|
||||||
|
if (!eintrag || eintrag.bis < jetzt) {
|
||||||
|
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
|
||||||
|
// Gelegentlich ausmisten, damit die Tabelle nicht wächst.
|
||||||
|
if (zaehler.size > 5000) {
|
||||||
|
for (const [key, wert] of zaehler) {
|
||||||
|
if (wert.bis < jetzt) zaehler.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
eintrag.anzahl += 1;
|
||||||
|
return eintrag.anzahl > MAX_PRO_FENSTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt eine Rückmeldung von einer öffentlichen Landingpage entgegen. */
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = feedbackSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const herkunft =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unbekannt";
|
||||||
|
|
||||||
|
if (zuSchnell(herkunft)) {
|
||||||
|
return jsonError(
|
||||||
|
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Codes, die tatsächlich Feedback einsammeln — sonst ließen sich
|
||||||
|
// beliebige Codes mit Einträgen zumüllen.
|
||||||
|
const code = await prisma.qrCode.findUnique({
|
||||||
|
where: { id: parsed.data.codeId },
|
||||||
|
select: { id: true, type: true },
|
||||||
|
});
|
||||||
|
if (!code || code.type !== "feedback") {
|
||||||
|
return jsonError("Zu diesem Code lässt sich keine Rückmeldung abgeben.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.feedback.create({
|
||||||
|
data: {
|
||||||
|
qrCodeId: code.id,
|
||||||
|
rating: parsed.data.rating,
|
||||||
|
comment: parsed.data.comment.length > 0 ? parsed.data.comment : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
|
||||||
import { Badge } from "@/components/ui";
|
|
||||||
import { qrTypeList } from "@/lib/qr/registry";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
|
|
||||||
Was möchtest du erstellen?
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-base text-slate-600">
|
|
||||||
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
|
|
||||||
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
|
|
||||||
später änderst, ohne den Code neu drucken zu müssen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{qrTypeList.map((type) => {
|
|
||||||
const tile = (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
|
||||||
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name={type.icon} className="size-6" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "font-semibold text-slate-900"
|
|
||||||
: "font-semibold text-slate-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.title}
|
|
||||||
</span>
|
|
||||||
{type.active ? (
|
|
||||||
<Badge tone="popular">Beliebt</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge tone="soon">Bald verfügbar</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
type.active
|
|
||||||
? "mt-1 block text-sm text-slate-600"
|
|
||||||
: "mt-1 block text-sm text-slate-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.description}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={type.id}>
|
|
||||||
{type.active ? (
|
|
||||||
<Link
|
|
||||||
href={`/erstellen/${type.id}`}
|
|
||||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
|
||||||
>
|
|
||||||
{tile}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
aria-disabled="true"
|
|
||||||
title={`„${type.title}“ folgt in Version 2`}
|
|
||||||
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
|
|
||||||
>
|
|
||||||
{tile}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p className="mt-8 text-sm text-slate-500">
|
|
||||||
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in
|
|
||||||
Version 2.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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,10 +81,21 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let target: string;
|
let target: string;
|
||||||
try {
|
if (qrType.hasLandingPage) {
|
||||||
target = qrType.redirectTarget(code.data);
|
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
||||||
} catch {
|
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
||||||
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
||||||
|
target = `/s/${slug}`;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// 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 {
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button, Card, CardTitle, Field, Notice } from "@/components/ui";
|
||||||
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ minLength }: { minLength: number }) {
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const mismatch = repeat.length > 0 && next !== repeat;
|
||||||
|
const tooShort = next.length > 0 && next.length < minLength;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
action: "change-password",
|
||||||
|
currentPassword: current,
|
||||||
|
newPassword: next,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setRepeat("");
|
||||||
|
setDone(true);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Das Passwort konnte nicht geändert werden.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Nach der Änderung wirst du auf allen anderen Geräten abgemeldet.">
|
||||||
|
Passwort ändern
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mismatch || tooShort) return;
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
{done ? (
|
||||||
|
<Notice tone="success">
|
||||||
|
Das Passwort wurde geändert. Andere Geräte wurden abgemeldet.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Bisheriges Passwort" htmlFor="current-password">
|
||||||
|
<PasswordInput
|
||||||
|
id="current-password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={current}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Neues Passwort"
|
||||||
|
htmlFor="next-password"
|
||||||
|
error={tooShort ? `Mindestens ${minLength} Zeichen.` : null}
|
||||||
|
hint={`Mindestens ${minLength} Zeichen.`}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="next-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={tooShort}
|
||||||
|
value={next}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setNext(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Neues Passwort wiederholen"
|
||||||
|
htmlFor="repeat-password"
|
||||||
|
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="repeat-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={mismatch}
|
||||||
|
value={repeat}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={pending || mismatch || tooShort}>
|
||||||
|
{pending ? "Wird geändert …" : "Passwort ändern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutCard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Beendet die Sitzung auf diesem Gerät.">Abmelden</CardTitle>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "logout" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// refresh() verwirft den serverseitig gerenderten Kopfbereich —
|
||||||
|
// sonst stünde dort noch der abgemeldete Name.
|
||||||
|
router.replace("/anmelden");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="lock" className="size-4" />
|
||||||
|
{pending ? "Wird abgemeldet …" : "Jetzt abmelden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
||||||
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
|
|
||||||
|
function useSubmit() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (json: unknown) => {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", { method: "POST", json });
|
||||||
|
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
|
||||||
|
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
|
||||||
|
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
|
||||||
|
router.replace("/");
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof ApiError
|
||||||
|
? caught.message
|
||||||
|
: "Die Anmeldung ist fehlgeschlagen.",
|
||||||
|
);
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { submit, pending, error, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const { submit, pending, error } = useSubmit();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit({ action: "login", email, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
<Field label="E-Mail-Adresse" htmlFor="login-email">
|
||||||
|
<TextInput
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Passwort" htmlFor="login-password">
|
||||||
|
<PasswordInput
|
||||||
|
id="login-password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird angemeldet …" : "Anmelden"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupForm({ minLength }: { minLength: number }) {
|
||||||
|
const { submit, pending, error } = useSubmit();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
|
||||||
|
const mismatch = repeat.length > 0 && password !== repeat;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mismatch) return;
|
||||||
|
void submit({ action: "setup", email, name, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
|
||||||
|
<Field label="Name (optional)" htmlFor="setup-name">
|
||||||
|
<TextInput
|
||||||
|
id="setup-name"
|
||||||
|
autoComplete="name"
|
||||||
|
value={name}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="E-Mail-Adresse" htmlFor="setup-email">
|
||||||
|
<TextInput
|
||||||
|
id="setup-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Passwort"
|
||||||
|
htmlFor="setup-password"
|
||||||
|
hint={`Mindestens ${minLength} Zeichen.`}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="setup-password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={minLength}
|
||||||
|
value={password}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Passwort wiederholen"
|
||||||
|
htmlFor="setup-repeat"
|
||||||
|
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="setup-repeat"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
invalid={mismatch}
|
||||||
|
value={repeat}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={pending || mismatch}>
|
||||||
|
{pending ? "Wird angelegt …" : "Administrator anlegen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "logout" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
router.replace("/anmelden");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "…" : "Abmelden"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("-");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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.`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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: () => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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"})`,
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user