Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6743dc007d | ||
|
|
367d9c540b | ||
|
|
1e0d04d0db | ||
|
|
4e2d222e86 | ||
|
|
fcc63ff69a | ||
|
|
1f4753b753 | ||
|
|
cdda7df17a | ||
|
|
c6891f66ff | ||
|
|
ff28352907 | ||
|
|
830d6d7379 | ||
|
|
a4bf3c77b6 | ||
|
|
43a0504f38 | ||
|
|
14b9ffc22f | ||
|
|
87ffdcae04 | ||
|
|
a7af9c9290 | ||
|
|
101f664cef | ||
|
|
3cb5421a20 | ||
|
|
c06f87e6d9 | ||
|
|
69c34c4f0c | ||
|
|
60f46bcdb5 | ||
|
|
142536140c | ||
|
|
1cf1df54df | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f | ||
|
|
7f67a35218 | ||
|
|
eeaf3820b7 | ||
|
|
46e7eb0b59 | ||
|
|
c38b27ca85 | ||
|
|
fcf41f56fe | ||
|
|
b828f38ca5 | ||
|
|
4046b72626 | ||
|
|
d0057f038c |
@@ -14,12 +14,26 @@ BASE_URL=http://localhost: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.
|
||||
# Administrator an. Das ist die Vorgabe, auch wenn die Zeile fehlt.
|
||||
# false — jeder, der die App erreicht, darf alles: Verwaltung, Uploads,
|
||||
# saemtliche Codes. Nur fuer Einzelplatz- oder LAN-Betrieb, und
|
||||
# nur, wenn niemand Fremdes an den Rechner kommt. Der Container
|
||||
# warnt beim Start deutlich, solange das so steht.
|
||||
# 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
|
||||
# Wer den Zugang verliert, setzt den Wert auf false und startet neu.
|
||||
AUTH_ENABLED=true
|
||||
|
||||
# Steht ein Reverse Proxy davor, der `X-Forwarded-For` selbst setzt?
|
||||
# false — Vorgabe. Die Kopfzeile wird nicht gelesen. Ohne Proxy koennte
|
||||
# sie jeder Aufrufer selbst waehlen; eine Bremse, die sich so
|
||||
# umgehen laesst, waere eine Behauptung.
|
||||
# true — Nur setzen, wenn wirklich ein Proxy davorsteht und die Kopfzeile
|
||||
# ueberschreibt. Sonst schenkst du jedem Aufrufer beliebig viele
|
||||
# Anmeldeversuche.
|
||||
# Unabhaengig davon gelten immer: die Wartezeit je Konto und die Obergrenze
|
||||
# fuer die ganze Installation.
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Änderungen
|
||||
|
||||
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
|
||||
Anwendung unter **Version und Änderungen**, erreichbar über die
|
||||
Versionsnummer in der Fußzeile.
|
||||
|
||||
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||
|
||||
## 1.7.1 — 21.08.2026
|
||||
|
||||
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
|
||||
|
||||
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
|
||||
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
|
||||
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
|
||||
|
||||
## 1.7.0 — 21.08.2026
|
||||
|
||||
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
|
||||
|
||||
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
|
||||
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
|
||||
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
|
||||
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
|
||||
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
|
||||
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
|
||||
|
||||
## 1.6.0 — 21.08.2026
|
||||
|
||||
Sichtbare Version mit Änderungsverlauf.
|
||||
|
||||
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
|
||||
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
|
||||
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
|
||||
|
||||
## 1.5.0 — 20.08.2026
|
||||
|
||||
Statische Codes lassen sich benennen und ablegen.
|
||||
|
||||
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
|
||||
- Der Name bestimmt den Dateinamen beim Herunterladen.
|
||||
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
|
||||
|
||||
## 1.4.0 — 17.08.2026
|
||||
|
||||
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
|
||||
|
||||
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
|
||||
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
|
||||
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
|
||||
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
|
||||
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
|
||||
|
||||
## 1.3.0 — 16.08.2026
|
||||
|
||||
Eigenes Erscheinungsbild und ein Dark Mode.
|
||||
|
||||
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
|
||||
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
|
||||
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
|
||||
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
|
||||
|
||||
## 1.2.0 — 15.08.2026
|
||||
|
||||
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
|
||||
|
||||
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
|
||||
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
|
||||
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
|
||||
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
|
||||
|
||||
## 1.1.0 — 14.08.2026
|
||||
|
||||
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
|
||||
|
||||
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
|
||||
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
|
||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
|
||||
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
|
||||
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
|
||||
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
|
||||
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
|
||||
|
||||
## 1.0.0 — 13.08.2026
|
||||
|
||||
Erste lauffähige Fassung.
|
||||
|
||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
|
||||
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
|
||||
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
|
||||
- Download als PNG und SVG, serverseitig gerendert.
|
||||
- Update-Skript für Linux, macOS und Windows.
|
||||
@@ -18,15 +18,28 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
||||
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||
Ziel ändern, löschen.
|
||||
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
|
||||
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
|
||||
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
|
||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||
erhoben, ohne Name und IP-Adresse.
|
||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||
Administrator, Bearbeiter und Betrachter.
|
||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
|
||||
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
|
||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
|
||||
für die ganze Installation oder je Gruppe.
|
||||
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||
Admin-Bereich einstellbar.
|
||||
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||
eigene Wahl.
|
||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
||||
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
|
||||
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
|
||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||
von selbst; alles Nötige steht in einer `.env`.
|
||||
|
||||
@@ -41,7 +54,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||
Rollen und der Weg zurück
|
||||
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
||||
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
|
||||
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
|
||||
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
|
||||
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||
Härtungen und was bewusst offen bleibt
|
||||
|
||||
@@ -120,9 +134,25 @@ BASE_URL=http://192.168.1.50:3000
|
||||
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
|
||||
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
|
||||
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
|
||||
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die
|
||||
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht
|
||||
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
||||
> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
|
||||
> umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
||||
>
|
||||
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
|
||||
>
|
||||
> | Wer scannt | Was in `BASE_URL` gehört |
|
||||
> | --- | --- |
|
||||
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
|
||||
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
|
||||
>
|
||||
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
|
||||
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
|
||||
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
|
||||
> Hinweis darauf, woran es liegt.
|
||||
>
|
||||
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
|
||||
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
|
||||
> über einen externen Anbieter, siehe
|
||||
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||
|
||||
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
|
||||
Servicename aus der `docker-compose.yml`, nicht dein Server.
|
||||
@@ -230,7 +260,7 @@ BASE_URL=https://qr.deine-domain.de
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||
| Forward Port | `3000` |
|
||||
| Websockets Support | an |
|
||||
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
|
||||
| Block Common Exploits | an |
|
||||
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
|
||||
|
||||
@@ -245,9 +275,84 @@ Port. Danach:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung
|
||||
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
|
||||
> dynamische Codes ins interne Netz statt auf die Domain.
|
||||
> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
|
||||
> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
|
||||
> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
|
||||
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
|
||||
> scheitert (siehe unten).
|
||||
|
||||
### Was der Proxy weiterreichen muss
|
||||
|
||||
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
|
||||
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
|
||||
sie eintragen.
|
||||
|
||||
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
|
||||
| --- | --- | --- |
|
||||
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
|
||||
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
|
||||
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Die Bremse je Absender entfällt; Wartezeit je Konto und Obergrenze der Instanz greifen weiter |
|
||||
|
||||
Für ein reines nginx (ohne NPM) heißt das:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
```
|
||||
|
||||
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
|
||||
von sich aus.
|
||||
|
||||
> **`TRUST_PROXY=true` erst setzen, wenn der Proxy wirklich steht.** Die
|
||||
> Anwendung liest `X-Forwarded-For` nur mit dieser Angabe. Ohne Proxy könnte
|
||||
> die Kopfzeile jeder Aufrufer selbst wählen und sich damit beliebig viele
|
||||
> Anmeldeversuche verschaffen — eine Bremse, die sich so umgehen lässt, wäre
|
||||
> eine Behauptung. Zwei Bremsen wirken unabhängig davon immer: die Wartezeit
|
||||
> je Konto und die Obergrenze für die ganze Installation. Näheres in
|
||||
> [docs/sicherheit.md](docs/sicherheit.md).
|
||||
|
||||
### Größe der Uploads
|
||||
|
||||
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
||||
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
|
||||
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
|
||||
Oberfläche.
|
||||
|
||||
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
|
||||
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
|
||||
**Advanced → Custom Nginx Configuration**:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 20m;
|
||||
```
|
||||
|
||||
### Nachprüfen, ob es stimmt
|
||||
|
||||
```bash
|
||||
# Kommt die Anwendung überhaupt an?
|
||||
curl -I https://qr.deine-domain.de/
|
||||
|
||||
# Kennt der Container dieselbe Adresse wie der Browser?
|
||||
docker compose exec app env | grep BASE_URL
|
||||
```
|
||||
|
||||
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
|
||||
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
|
||||
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
|
||||
|
||||
- jeder neue dynamische Code zeigt auf die interne Adresse, und
|
||||
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
|
||||
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
|
||||
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||
|
||||
### Erreichbar für wen?
|
||||
|
||||
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
|
||||
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
|
||||
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
|
||||
öffentlich auflösbar und der Proxy von außen erreichbar sein.
|
||||
|
||||
### Mehrere Domains
|
||||
|
||||
@@ -260,6 +365,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
|
||||
404 — über einen gefälschten Header lässt sich also keine fremde Domain
|
||||
ansprechen.
|
||||
|
||||
### Nachprüfen, ob eine Domain wirklich hierher führt
|
||||
|
||||
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
|
||||
Weg der Scanner nach und meldet vier Dinge:
|
||||
|
||||
| Schritt | Was geprüft wird |
|
||||
| --- | --- |
|
||||
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||
| Ziel | Steht dahinter **diese** Installation? |
|
||||
|
||||
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
|
||||
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
|
||||
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
|
||||
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
|
||||
irgendwohin“ von „die Domain zeigt hierher“.
|
||||
|
||||
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
|
||||
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
|
||||
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
|
||||
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
|
||||
> Dafür bleibt der Blick von einem fremden Netz aus.
|
||||
|
||||
Zwei Dinge, die daraus folgen:
|
||||
|
||||
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
|
||||
@@ -490,12 +619,32 @@ 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 |
|
||||
| **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
|
||||
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
||||
|
||||
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
||||
umgesetzt, erscheint als Kachel „Bald verfügbar“.
|
||||
|
||||
#### Freigabe je Gruppe
|
||||
|
||||
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
|
||||
|
||||
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
|
||||
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
|
||||
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
|
||||
|
||||
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
|
||||
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
|
||||
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
|
||||
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
|
||||
zu wissen, was er getan hat.
|
||||
|
||||
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
|
||||
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
|
||||
das noch einmal.
|
||||
|
||||
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
|
||||
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
|
||||
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
|
||||
@@ -525,13 +674,21 @@ 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.
|
||||
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
|
||||
- ein **Filter nach Art**, sobald dynamische und statische Codes
|
||||
nebeneinander liegen.
|
||||
|
||||
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
||||
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
||||
Kartenansicht.
|
||||
|
||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
||||
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
|
||||
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
|
||||
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
|
||||
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
|
||||
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||
|
||||
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||
@@ -871,8 +1028,10 @@ deutschen Meldung.
|
||||
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
||||
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
||||
| `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 |
|
||||
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
||||
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
||||
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
|
||||
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
|
||||
|
||||
Beispiel:
|
||||
|
||||
|
||||
@@ -37,7 +37,12 @@ services:
|
||||
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
# Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich
|
||||
# AUTH_ENABLED=false setzt — siehe .env.example.
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-true}
|
||||
# Nur einschalten, wenn wirklich ein Proxy davorsteht — siehe
|
||||
# .env.example.
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||
|
||||
@@ -5,5 +5,17 @@ set -e
|
||||
echo "→ Datenbankmigrationen werden angewendet …"
|
||||
node /app/node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
# Eine Anwendung ohne Anmeldung sieht genauso aus wie eine mit — deshalb
|
||||
# muss sie es sagen. Wer das hier im Protokoll sieht und nicht erwartet hat,
|
||||
# soll sofort wissen, woran er ist.
|
||||
if [ "$(printf '%s' "${AUTH_ENABLED:-}" | tr '[:upper:]' '[:lower:]')" = "false" ]; then
|
||||
echo "" >&2
|
||||
echo " !! ACHTUNG: AUTH_ENABLED=false — die Anwendung laeuft OHNE Anmeldung." >&2
|
||||
echo " !! Jeder, der sie erreicht, darf alles: Verwaltung, Uploads, saemtliche" >&2
|
||||
echo " !! Codes anlegen, aendern und loeschen. Das ist nur fuer Einzelplatz-" >&2
|
||||
echo " !! oder LAN-Betrieb gedacht und gehoert nicht ins offene Netz." >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
|
||||
echo "→ Anwendung wird gestartet …"
|
||||
exec "$@"
|
||||
|
||||
@@ -112,12 +112,42 @@ gescannt zu werden.
|
||||
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||
trennen kann.
|
||||
|
||||
### Ein Logo in die Mitte setzen
|
||||
|
||||

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

|
||||
|
||||
@@ -128,6 +158,11 @@ Projekte nebeneinander.
|
||||
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||
liegen.
|
||||
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
|
||||
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
|
||||

|
||||
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
|
||||
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
|
||||
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||
|
||||
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||
@@ -135,6 +170,23 @@ Vorschau. Die Wahl merkt sich der Browser.
|
||||
|
||||

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

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

|
||||
@@ -247,6 +299,27 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
||||
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||
|
||||
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
|
||||
|
||||

|
||||
|
||||
| Auswahl | Wirkung |
|
||||
| --- | --- |
|
||||
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
|
||||
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
|
||||
|
||||
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
|
||||
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
|
||||
|
||||
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
|
||||
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
|
||||
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||
Aussage mehr.
|
||||
|
||||
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
|
||||
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
|
||||
Gruppe.
|
||||
|
||||
### Gruppen
|
||||
|
||||

|
||||
@@ -275,7 +348,34 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
||||
getrennt.
|
||||
|
||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
||||
Anwendung leiten. Was dafür einzurichten ist, steht nicht nur im README: Der
|
||||
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
|
||||
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
|
||||
Registrieren, Gruppenzuteilung, Nachprüfen.
|
||||
|
||||

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

|
||||
|
||||
| Schritt | Was er sagt |
|
||||
| --- | --- |
|
||||
| DNS | ob der Name auflöst, und auf welche Adressen |
|
||||
| Zertifikat | gültig bis wann, von wem ausgestellt |
|
||||
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
|
||||
| Ziel | ob dahinter **diese** Installation steht |
|
||||
|
||||
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
|
||||
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||
Meldung würde die eine wichtige verdecken.
|
||||
|
||||
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
|
||||
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
|
||||
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
|
||||
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Adressen.
|
||||
| Client type | **Confidential** |
|
||||
| Client ID | notieren — kommt gleich in die `.env` |
|
||||
| Client Secret | notieren — ebenfalls |
|
||||
| Redirect URIs | `https://qr.example.com/api/auth/oidc/callback` |
|
||||
| Redirect URIs | **deine** `BASE_URL` + `/api/auth/oidc/callback` — siehe unten |
|
||||
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
||||
|
||||
@@ -50,9 +50,77 @@ Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
|
||||
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
||||
Anmeldung mit einer entsprechenden Meldung ab.
|
||||
|
||||
> **Redirect URI genau so eintragen.** Ein fehlendes `https://`, ein Schrägstrich
|
||||
> zu viel oder ein anderer Hostname führt zu `redirect_uri mismatch` —
|
||||
> Authentik bricht dann ab, bevor die Anwendung überhaupt etwas mitbekommt.
|
||||
### Die Redirect URI
|
||||
|
||||
Das ist der häufigste Stolperstein. Der Wert ist **kein Beispiel zum
|
||||
Abschreiben**, sondern ergibt sich aus deiner `BASE_URL`:
|
||||
|
||||
```
|
||||
{BASE_URL}/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
Was dein Container als `BASE_URL` kennt, zeigt:
|
||||
|
||||
```bash
|
||||
docker compose exec app env | grep BASE_URL
|
||||
```
|
||||
|
||||
Ein paar Beispiele:
|
||||
|
||||
| `BASE_URL` | Redirect URI |
|
||||
| --- | --- |
|
||||
| `https://qr.example.com` | `https://qr.example.com/api/auth/oidc/callback` |
|
||||
| `http://10.10.10.150:3005` | `http://10.10.10.150:3005/api/auth/oidc/callback` |
|
||||
| `http://192.168.1.50:3000` | `http://192.168.1.50:3000/api/auth/oidc/callback` |
|
||||
|
||||
Zu beachten:
|
||||
|
||||
- **Kein Schrägstrich am Ende.**
|
||||
- **Schema und Port gehören dazu** — `http` ist nicht `https`, und `:3005`
|
||||
darf nicht fehlen.
|
||||
- Neben dem Feld steht in neueren Authentik-Fassungen eine Auswahl **Strict**
|
||||
oder **Regex**. Für eine feste Adresse muss dort **Strict** stehen. Bei
|
||||
`Regex` wäre jeder Punkt einer IP-Adresse ein Platzhalter und müsste
|
||||
maskiert werden.
|
||||
- Bleibt das Feld leer, lässt Authentik **gar nichts** durch.
|
||||
|
||||
> **Wenn es trotzdem klemmt:** Authentik zeigt den Fehler „Redirect URI Error“
|
||||
> noch auf seiner eigenen Seite an — die Anwendung bekommt davon nichts mit.
|
||||
> Welche Adresse tatsächlich ankam, steht zum Vergleich
|
||||
> 1. in der Adresszeile des Browsers, im Parameter `redirect_uri`, und
|
||||
> 2. in Authentik unter **Events → Logs** im Eintrag `redirect_uri_error`.
|
||||
>
|
||||
> Weicht die ankommende Adresse von deiner `BASE_URL` ab, ist nicht Authentik
|
||||
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
|
||||
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
|
||||
|
||||
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
|
||||
|
||||
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
|
||||
Anwendung tatsächlich aufruft.
|
||||
|
||||
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
|
||||
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
|
||||
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
|
||||
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
|
||||
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
|
||||
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
|
||||
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
|
||||
hat.
|
||||
|
||||
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
|
||||
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
|
||||
|
||||
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
|
||||
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
|
||||
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
|
||||
Übereinstimmung gebracht:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Aufruf im Browser | `https://qr.example.com` |
|
||||
| `BASE_URL` in der `.env` | `https://qr.example.com` |
|
||||
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
|
||||
|
||||
---
|
||||
|
||||
@@ -216,10 +284,11 @@ Danach verschwindet das Passwortfeld von der Anmeldeseite.
|
||||
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
||||
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
||||
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
||||
| Authentik meldet `redirect_uri mismatch` | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein. Auf Schema, Hostname und Schrägstriche achten. |
|
||||
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
|
||||
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
|
||||
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
|
||||
| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. |
|
||||
| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
|
||||
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
|
||||
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
|
||||
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der
|
||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||
- [Single Sign-on](#single-sign-on)
|
||||
- [Mehrere Domains](#mehrere-domains)
|
||||
- [Erscheinungsbild](#erscheinungsbild)
|
||||
- [Verwaltung](#verwaltung)
|
||||
- [Betrieb](#betrieb)
|
||||
- [Schnittstelle](#schnittstelle)
|
||||
@@ -101,12 +102,95 @@ Browser landet.
|
||||
| Ziel später änderbar | nein | ja, jederzeit |
|
||||
| Scans gezählt | nein | ja, mit Datum des letzten |
|
||||
| Braucht diesen Server | nie | dauerhaft |
|
||||
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
|
||||
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
|
||||
|
||||
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare
|
||||
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
|
||||
global — derselbe Slug darf auf zwei Domains liegen und dort auf
|
||||
Verschiedenes zeigen.
|
||||
### Ein statischer Code mit Namen
|
||||
|
||||
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
|
||||
|
||||
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
|
||||
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
|
||||
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
|
||||
wiederzufinden und erneut herunterzuladen.
|
||||
|
||||
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
|
||||
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
|
||||
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
|
||||
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
|
||||
Liste steht dort „statisch“ und ein Strich.
|
||||
|
||||
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
||||
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
||||
|
||||
**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
|
||||
identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
|
||||
wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
|
||||
keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
|
||||
|
||||
Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
|
||||
Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
|
||||
genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
|
||||
bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
|
||||
sobald zwei Kampagnen getrennt gezählt werden sollen.
|
||||
|
||||
### Ein Logo in der Mitte
|
||||
|
||||
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
|
||||
Zur Wahl stehen zwei Quellen:
|
||||
|
||||
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
|
||||
genügt, kein Upload je Code;
|
||||
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
|
||||
|
||||
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
|
||||
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
|
||||
Fläche in der Hintergrundfarbe, damit es sich absetzt.
|
||||
|
||||
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
|
||||
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
|
||||
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
|
||||
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
|
||||
nach Ausrichtungsmustern.
|
||||
|
||||
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
|
||||
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
|
||||
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
|
||||
|
||||
### Der Kurz-Link
|
||||
|
||||
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
|
||||
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
|
||||
gelegentlich abgetippt.
|
||||
|
||||
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
|
||||
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
|
||||
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
|
||||
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
|
||||
|
||||
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
|
||||
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
|
||||
dürfen sich nicht nur in der Schreibweise unterscheiden.
|
||||
|
||||
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
|
||||
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
|
||||
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
|
||||
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
|
||||
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
|
||||
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
|
||||
ließen sich fremde gedruckte Codes umlenken.
|
||||
|
||||
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
|
||||
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
|
||||
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
|
||||
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
|
||||
weiterhin.
|
||||
|
||||
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
|
||||
zwei Domains liegen und dort auf Verschiedenes zeigen.
|
||||
|
||||
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
|
||||
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
|
||||
@@ -231,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
||||
|
||||
## Meine Codes
|
||||
|
||||
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
||||
gespeichert werden.
|
||||
Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
|
||||
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
|
||||
„statisch“ und zählt keine Scans.
|
||||
|
||||
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||
@@ -240,6 +325,10 @@ gespeichert werden.
|
||||
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||
es auch eine Gruppenspalte.
|
||||
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
|
||||
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
|
||||
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
|
||||
Scans, die Spalte wäre eine Reihe aus Strichen.
|
||||
- **Kurz-URL kopieren** mit einem Klick.
|
||||
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||
@@ -342,13 +431,128 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||
Anwendung leiten.
|
||||
|
||||
### Kurzhilfe zur Einrichtung
|
||||
|
||||
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
|
||||
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
|
||||
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
|
||||
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
|
||||
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
|
||||
|
||||
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
|
||||
Einrichten und danach nie wieder.
|
||||
|
||||
### Prüfen, ob eine Domain hierher führt
|
||||
|
||||
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
|
||||
und meldet vier Dinge:
|
||||
|
||||
| Schritt | Was geprüft wird |
|
||||
| --- | --- |
|
||||
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||
| Ziel | Steht dahinter **diese** Installation? |
|
||||
|
||||
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
|
||||
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
|
||||
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
|
||||
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
|
||||
Datenbank und verlässt den Server nie.
|
||||
|
||||
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
|
||||
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
|
||||
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
|
||||
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
|
||||
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||
Meldung würde die eine wichtige verdecken.
|
||||
|
||||
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
|
||||
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
|
||||
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
|
||||
dass die Domain von außen erreichbar ist.
|
||||
|
||||
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
|
||||
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
|
||||
beliebige Adressen abklopfen ließen. Näheres in
|
||||
[sicherheit.md](sicherheit.md).
|
||||
|
||||
---
|
||||
|
||||
## Erscheinungsbild
|
||||
|
||||
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
|
||||
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
|
||||
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
|
||||
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
|
||||
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
|
||||
|
||||
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
|
||||
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
|
||||
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
|
||||
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
|
||||
|
||||
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
|
||||
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
|
||||
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
|
||||
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
|
||||
benutzbar.
|
||||
|
||||
### Hell und dunkel
|
||||
|
||||
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
|
||||
Kopfzeile steht ein Umschalter mit drei Zuständen:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
|
||||
| **Hell** | immer hell |
|
||||
| **Dunkel** | immer dunkel |
|
||||
|
||||
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
|
||||
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
|
||||
Tab zieht automatisch nach.
|
||||
|
||||
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
|
||||
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
|
||||
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
|
||||
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
|
||||
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
|
||||
sogar **ohne JavaScript**.
|
||||
|
||||
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
|
||||
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
|
||||
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
|
||||
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
|
||||
|
||||
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
|
||||
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
|
||||
aussehen.
|
||||
|
||||
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
|
||||
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
|
||||
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
|
||||
als eigener Punkt auf der [Roadmap](roadmap.md).
|
||||
|
||||
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
|
||||
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
|
||||
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
|
||||
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
|
||||
|
||||
---
|
||||
|
||||
## Verwaltung
|
||||
|
||||
| Bereich | Was dort geht |
|
||||
| --- | --- |
|
||||
| **QR-Typen** | einzelne Typen ausblenden |
|
||||
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
||||
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
|
||||
| **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
|
||||
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
||||
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
|
||||
@@ -360,6 +564,20 @@ lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
|
||||
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
|
||||
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
||||
|
||||
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
|
||||
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
|
||||
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
|
||||
folgt die Gruppe der Grundeinstellung.
|
||||
|
||||
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
|
||||
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
|
||||
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
|
||||
abgeschaltet“ keine verlässliche Aussage mehr.
|
||||
|
||||
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
|
||||
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
|
||||
beim Anlegen anhand der gewählten Gruppe.
|
||||
|
||||
---
|
||||
|
||||
## Betrieb
|
||||
@@ -382,6 +600,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
||||
|
||||
---
|
||||
|
||||
## Version und Änderungen
|
||||
|
||||
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
|
||||
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
|
||||
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
|
||||
Steht dort noch die alte Nummer, ist er es nicht.
|
||||
|
||||
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
|
||||
Daraus entstehen sowohl die Seite in der Anwendung als auch die
|
||||
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
|
||||
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
|
||||
einen Git-Tag.
|
||||
|
||||
---
|
||||
|
||||
## Schnittstelle
|
||||
|
||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||
@@ -398,7 +631,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||
| `POST /api/feedback` | Rückmeldung abgeben |
|
||||
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
|
||||
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
|
||||
| `/api/admin/types` · `users` · `groups` · `domains` | Verwaltung |
|
||||
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
|
||||
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
|
||||
| `GET /s/{slug}` | öffentliche Landingpage |
|
||||
|
||||
|
||||
@@ -7,65 +7,21 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
|
||||
|
||||
## Gebaut
|
||||
|
||||
### Version 1 — Grundgerüst (13.08.2026)
|
||||
Der vollständige Verlauf mit Versionsnummern steht im
|
||||
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
|
||||
**Version und Änderungen**, erreichbar über die Versionsnummer in der
|
||||
Fußzeile.
|
||||
|
||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
||||
Proxy, Migrationen beim Start
|
||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
||||
Formular, ohne Eingriff in den übrigen Code
|
||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
||||
Scan-Zähler
|
||||
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
||||
- Download als PNG und SVG, serverseitig gerendert
|
||||
- „Meine Codes“ mit Ziel ändern und Löschen
|
||||
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
||||
- Update-Skript für Linux/macOS und Windows
|
||||
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
|
||||
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
|
||||
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
|
||||
die Kurzhilfe zu den Domains.
|
||||
|
||||
### Typen einzeln freigeben (14.08.2026)
|
||||
|
||||
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
||||
eines abgeschalteten Typs funktionieren weiter.
|
||||
|
||||
### Phase A — acht weitere Typen (14.08.2026)
|
||||
|
||||
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
||||
Zahlungslink), WLAN.
|
||||
|
||||
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
||||
|
||||
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
||||
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
||||
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
||||
|
||||
### Phase B2 — mehrere Domains (14.08.2026)
|
||||
|
||||
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
||||
Host-Header gegen die registrierten Domains geprüft.
|
||||
|
||||
### Listenansicht (14.08.2026)
|
||||
|
||||
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
||||
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
||||
|
||||
### Phase C — die letzten sechs Typen (14.08.2026)
|
||||
|
||||
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
||||
|
||||
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
||||
- Feedback mit anonymer Erhebung und Auswertung
|
||||
|
||||
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
||||
|
||||
### Single Sign-on (15.08.2026)
|
||||
|
||||
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
||||
ID-Token.
|
||||
|
||||
### Dokumentation (15.08.2026)
|
||||
|
||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
||||
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
|
||||
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,27 +30,7 @@ Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
||||
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||
Person, die den Code kennt.
|
||||
|
||||
### 1. QR-Typen je Gruppe freigeben · mittel
|
||||
|
||||
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
||||
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
||||
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
||||
|
||||
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
||||
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
||||
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
||||
entscheidet die gewählte Gruppe.
|
||||
|
||||
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
||||
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
||||
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
||||
wahrscheinlich die richtige.
|
||||
|
||||
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
||||
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
||||
eine Gruppenauswahl bekommt.
|
||||
|
||||
### 2. Statistik über die Zeit · mittel
|
||||
### 1. Statistik über die Zeit · mittel
|
||||
|
||||
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||
@@ -108,7 +44,7 @@ sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
||||
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||
|
||||
### 3. Papierkorb statt Löschen · klein
|
||||
### 2. Papierkorb statt Löschen · klein
|
||||
|
||||
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||
samt seiner Dateien und Rückmeldungen.
|
||||
@@ -118,20 +54,7 @@ Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
||||
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||
|
||||
### 4. Logo im QR-Code · mittel
|
||||
|
||||
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
||||
|
||||
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
||||
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
||||
|
||||
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
||||
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
||||
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
||||
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
||||
die verdeckte Fläche.
|
||||
|
||||
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
||||
### 3. Ablaufdatum und Zeitsteuerung · mittel
|
||||
|
||||
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||
@@ -148,6 +71,20 @@ Geplant, in zwei Stufen:
|
||||
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||
Fenstern geschieht.
|
||||
|
||||
### 4. Gestaltung eines gespeicherten Codes ändern · klein
|
||||
|
||||
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
|
||||
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
|
||||
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
|
||||
|
||||
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
|
||||
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
|
||||
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
|
||||
Bearbeiten-Dialog.
|
||||
|
||||
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
|
||||
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
|
||||
|
||||
---
|
||||
|
||||
## Später denkbar
|
||||
@@ -190,10 +127,13 @@ sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
|
||||
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
|
||||
die Auslieferungsroute — aber auch die Sicherungsstrategie.
|
||||
|
||||
**Eigene Gestaltung der Landingpages** · groß
|
||||
Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma
|
||||
aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es
|
||||
braucht Grenzen, damit nichts Unlesbares entsteht.
|
||||
**Erscheinungsbild je Gruppe** · groß
|
||||
Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
|
||||
Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
|
||||
und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit
|
||||
eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch
|
||||
überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit
|
||||
nichts Unlesbares entsteht.
|
||||
|
||||
**Mehrsprachige Oberfläche** · groß
|
||||
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* Voraussetzungen:
|
||||
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
||||
* Codes im Bild landen), erreichbar unter BASE
|
||||
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
|
||||
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
|
||||
* selbst unter der eingetragenen Adresse nicht erreicht.
|
||||
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
||||
* Regelbetrieb
|
||||
* - Playwright: npm i -D playwright && npx playwright install chromium
|
||||
@@ -18,6 +21,7 @@ import { mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import sharp from "sharp";
|
||||
|
||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
||||
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
|
||||
locale: "de-DE",
|
||||
});
|
||||
|
||||
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
|
||||
/**
|
||||
* Nimmt eine Seite auf.
|
||||
*
|
||||
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
|
||||
* bei denen die ganze Seite mehr verdeckt als zeigt.
|
||||
*/
|
||||
async function schuss(
|
||||
context,
|
||||
pfad,
|
||||
datei,
|
||||
{ ganzeSeite = true, vorher, bereich } = {},
|
||||
) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||
if (vorher) await vorher(page);
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
|
||||
const ziel = join(ZIEL, datei);
|
||||
if (bereich) {
|
||||
await page.locator(bereich).first().screenshot({ path: ziel });
|
||||
} else {
|
||||
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
|
||||
}
|
||||
await page.close();
|
||||
console.log(" ✓", datei);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
|
||||
*
|
||||
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
|
||||
* und die Farbe passt zu den übrigen Aufnahmen.
|
||||
*/
|
||||
async function beispiellogo() {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
|
||||
<rect width="256" height="256" rx="56" fill="#312E81"/>
|
||||
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
|
||||
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
|
||||
</svg>`;
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
console.log("Bilder werden erzeugt:");
|
||||
|
||||
await schuss(desktop, "/", "01-startseite.png");
|
||||
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
|
||||
await page.waitForTimeout(400);
|
||||
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
||||
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
|
||||
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
|
||||
await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,8 +293,129 @@ await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
|
||||
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
|
||||
await schuss(desktop, "/konto", "15-konto.png");
|
||||
|
||||
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
|
||||
|
||||
const logo = await beispiellogo();
|
||||
|
||||
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
|
||||
// sind — das eine erklärt das andere.
|
||||
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
|
||||
vorher: async (page) => {
|
||||
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
|
||||
await page.setInputFiles('input[type="file"]', {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: logo,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
},
|
||||
});
|
||||
|
||||
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
|
||||
const statisch = await api("/api/codes", {
|
||||
method: "POST",
|
||||
json: {
|
||||
type: "website",
|
||||
data: { url: "https://example.com/aushang" },
|
||||
design: DESIGN,
|
||||
mode: "static",
|
||||
label: "Aushang Eingang",
|
||||
},
|
||||
});
|
||||
if (statisch.status === 201) angelegt.push(statisch.body.code);
|
||||
|
||||
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
|
||||
bereich: "section:has-text('Art des Codes')",
|
||||
vorher: async (page) => {
|
||||
await page.locator("#website-url").fill("https://example.com/aushang");
|
||||
await page.locator('button[data-mode="static"]').click();
|
||||
await page.waitForTimeout(900);
|
||||
await page.getByRole("button", { name: /ablegen/i }).click();
|
||||
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
|
||||
timeout: 15000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
|
||||
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
|
||||
vorher: async (page) => {
|
||||
await page.getByLabel("Nach Art filtern").selectOption("static");
|
||||
await page.waitForTimeout(600);
|
||||
},
|
||||
});
|
||||
|
||||
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
|
||||
const gruppe = await api("/api/admin/groups", {
|
||||
method: "POST",
|
||||
json: { name: "Marketing" },
|
||||
});
|
||||
const gruppeId = gruppe.body?.group?.id ?? null;
|
||||
|
||||
if (gruppeId) {
|
||||
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
|
||||
// um den es geht.
|
||||
await api("/api/admin/types", {
|
||||
method: "PATCH",
|
||||
json: { type: "payment", enabled: false, groupId: gruppeId },
|
||||
});
|
||||
}
|
||||
|
||||
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
|
||||
vorher: async (page) => {
|
||||
if (gruppeId) {
|
||||
await page.getByLabel("Freigabe für").selectOption(gruppeId);
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
|
||||
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
|
||||
bereich: "dialog[open]",
|
||||
vorher: async (page) => {
|
||||
await page.getByRole("button", { name: "Kurzhilfe" }).click();
|
||||
await page.locator("dialog[open]").waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
},
|
||||
});
|
||||
|
||||
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
|
||||
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
|
||||
// Fall, den die Anleitung zeigen soll.
|
||||
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
|
||||
vorher: async (page) => {
|
||||
await page.getByRole("button", { name: "Prüfen" }).first().click();
|
||||
await page
|
||||
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
|
||||
.first()
|
||||
.waitFor({ timeout: 30000 });
|
||||
await page.waitForTimeout(400);
|
||||
},
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
|
||||
|
||||
const bestand = await api("/api/codes");
|
||||
for (const eintrag of bestand.body?.codes ?? []) {
|
||||
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
|
||||
angelegt.push(eintrag);
|
||||
}
|
||||
}
|
||||
|
||||
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
|
||||
// Instanz so hinterlassen, wie es sie vorgefunden hat.
|
||||
if (gruppeId) {
|
||||
await api("/api/admin/types", {
|
||||
method: "PATCH",
|
||||
json: { type: "payment", enabled: true, groupId: gruppeId },
|
||||
});
|
||||
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Aufräumen */
|
||||
|
||||
for (const eintrag of angelegt) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
||||
|
||||
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
||||
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
|
||||
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026, ergänzt am 24.08.2026
|
||||
um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei
|
||||
offenen Punkte am Ende).
|
||||
|
||||
Geprüft gegen:
|
||||
|
||||
@@ -163,6 +165,171 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
|
||||
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
||||
wird beiläufig aufgeräumt.
|
||||
|
||||
### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt
|
||||
|
||||
**Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein
|
||||
lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des
|
||||
Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das
|
||||
prüfte der Code auch, nur war die Bestätigung falsch berechnet:
|
||||
|
||||
```ts
|
||||
emailVerified: payload.email_verified !== false
|
||||
```
|
||||
|
||||
Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender
|
||||
Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der
|
||||
`email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —,
|
||||
hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen
|
||||
kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines
|
||||
Administrators.
|
||||
|
||||
**Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur
|
||||
bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die
|
||||
Verknüpfung unterbleibt.
|
||||
|
||||
**Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das
|
||||
ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und
|
||||
Nonce bestehen.
|
||||
|
||||
### 12. Eine fehlende Angabe schaltete die Anmeldung ab
|
||||
|
||||
**Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß
|
||||
„aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine
|
||||
Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die
|
||||
Compose-Vorgabe stand zusätzlich auf `false`.
|
||||
|
||||
**Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false`
|
||||
setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml`
|
||||
und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der
|
||||
Container beim Start eine mehrzeilige Warnung ins Protokoll.
|
||||
|
||||
Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht
|
||||
und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt.
|
||||
|
||||
### 13. Wettlauf beim ersten Administrator über OIDC
|
||||
|
||||
**Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei
|
||||
gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und
|
||||
beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung
|
||||
sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion.
|
||||
|
||||
**Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer
|
||||
Transaktion mit `isolationLevel: "Serializable"`.
|
||||
|
||||
### 14. Die Herkunftsprüfung verglich das Schema nicht
|
||||
|
||||
**Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt
|
||||
damit als eigen, obwohl die Anwendung unter `https://example.com` läuft.
|
||||
|
||||
**Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der
|
||||
einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt.
|
||||
|
||||
**Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw.
|
||||
`X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht
|
||||
zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst
|
||||
gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen
|
||||
CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das
|
||||
verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr.
|
||||
|
||||
### 15. Die Kontosperre konnte den Besitzer aussperren
|
||||
|
||||
**Befund:** Acht Fehlversuche sperrten ein Konto für 15 Minuten — auch gegen
|
||||
das **richtige** Passwort. Wer die Adresse eines Administrators kannte,
|
||||
konnte die Sperre alle 15 Minuten erneuern und die Verwaltung praktisch
|
||||
dauerhaft schließen. Ein Nachweis, dass der Anfragende der Kontoinhaber ist,
|
||||
war dafür nicht nötig.
|
||||
|
||||
**Behoben:** Aus der Sperre wurde eine **Wartezeit**. Die ersten vier
|
||||
Fehlversuche bleiben folgenlos, danach verdoppelt sie sich bis zu einer
|
||||
Minute:
|
||||
|
||||
| Fehlversuche | Wartezeit |
|
||||
| --- | --- |
|
||||
| bis 4 | keine |
|
||||
| 5 | 1 s |
|
||||
| 7 | 4 s |
|
||||
| 10 | 32 s |
|
||||
| ab 11 | 60 s |
|
||||
|
||||
Für den Besitzer heißt das im schlimmsten Fall eine Minute statt einer
|
||||
Viertelstunde Aussperrung. Für jemanden, der Passwörter durchprobiert, einen
|
||||
Versuch pro Minute — das ist die eigentliche Aufgabe der Bremse, und die
|
||||
erfüllt sie weiterhin.
|
||||
|
||||
Die Wartezeit wird **vor** der Passwortprüfung ausgewertet. Ein Versuch, der
|
||||
ohnehin abgewiesen wird, kostet damit keine 128 MiB.
|
||||
|
||||
### 16. Die Bremsen ließen sich über eine Kopfzeile umgehen
|
||||
|
||||
**Befund:** Der Absender wurde aus `X-Forwarded-For` gelesen. Ohne
|
||||
vorgeschalteten Proxy — und die mitgelieferte Compose-Konfiguration
|
||||
veröffentlicht den Port direkt — wählt ihn jeder Aufrufer selbst und umgeht
|
||||
damit Login- wie Feedback-Bremse.
|
||||
|
||||
**Behoben:** Drei Stufen statt einer, in `src/lib/auth/throttle.ts`:
|
||||
|
||||
1. **Je Absender** — nur noch, wenn `TRUST_PROXY=true` gesetzt ist. Ohne
|
||||
diese Angabe wird die Kopfzeile gar nicht gelesen. Eine Bremse, die sich
|
||||
mit einem selbst gewählten Wert umgehen lässt, ist keine; dann lieber
|
||||
ehrlich keine.
|
||||
2. **Je Konto** — die Wartezeit aus Punkt 15. Sie hängt an der Kennung des
|
||||
Kontos, nicht an einer Kopfzeile, und ist deshalb nicht zu umgehen.
|
||||
3. **Für die ganze Installation** — eine Obergrenze von 60 Passwortprüfungen
|
||||
je Minute, und 120 Rückmeldungen je Minute. Das ist die Stufe, die den
|
||||
Speicher schützt: Ohne sie könnte jemand mit genügend gleichzeitigen
|
||||
Anfragen den Server allein über die Hash-Berechnung lahmlegen.
|
||||
|
||||
Was bleibt: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei
|
||||
mehreren Instanzen bräuchte es einen gemeinsamen Zähler — siehe „Offen“.
|
||||
|
||||
### 17. Skripte liefen ohne Content-Security-Policy
|
||||
|
||||
**Befund:** Die Policy bestand aus `frame-ancestors 'none'`. Gegen
|
||||
eingeschleusten Skriptcode wirkte sie nicht.
|
||||
|
||||
**Behoben:** Eine vollständige Policy mit **Nonce je Anfrage**. Skripte
|
||||
laufen nur, wenn sie den Nonce dieser einen Anfrage tragen oder von einem
|
||||
Skript geladen wurden, das selbst einen hatte (`strict-dynamic`). Ein
|
||||
Skript, das über eine Lücke in die Seite gerät, hat keinen — und läuft
|
||||
nicht.
|
||||
|
||||
Dazu `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`,
|
||||
`form-action 'self'`. `frame-src` nennt ausschließlich die beiden
|
||||
Video-Anbieter, die der Typ „Video“ einbetten kann.
|
||||
|
||||
**Zwei bewusste Lockerungen**, damit sie nicht als Versehen gelesen werden:
|
||||
|
||||
- `style-src` erlaubt weiterhin `'unsafe-inline'`. Ein Nonce wirkt nur auf
|
||||
`<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen; die
|
||||
Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen. Über
|
||||
ein Stilattribut lässt sich kein Code ausführen, der Gewinn wäre gering.
|
||||
- `media-src` erlaubt jede `https:`-Quelle. Der Typ „Video“ nimmt
|
||||
ausdrücklich auch einen Direktlink auf eine Videodatei entgegen, und wo
|
||||
die liegt, weiß nur der, der ihn einträgt.
|
||||
|
||||
`upgrade-insecure-requests` steht nur, wenn `BASE_URL` auf `https` zeigt —
|
||||
auf einer bewusst über HTTP betriebenen Installation würde die Regel die
|
||||
eigenen Ressourcen hochstufen und alles zerlegen.
|
||||
|
||||
### 18. Sicherheitsereignisse standen nirgends
|
||||
|
||||
**Befund:** Fehlversuche, OIDC-Verknüpfungen und die Vergabe von
|
||||
Administratorrechten hinterließen keine Spur.
|
||||
|
||||
**Behoben:** `src/lib/auth/protokoll.ts` schreibt je Ereignis eine Zeile als
|
||||
JSON auf die Standardausgabe — damit landet sie in `docker compose logs` und
|
||||
lässt sich von dort weiterreichen, ohne dass die Anwendung ein Werkzeug
|
||||
dafür kennen muss.
|
||||
|
||||
Protokolliert werden: fehlgeschlagene Anmeldungen samt Zahl der Versuche und
|
||||
Wartezeit, abgelehnte OIDC-Verknüpfungen mit Grund, erfolgte Verknüpfungen
|
||||
und jede Vergabe von Administratorrechten.
|
||||
|
||||
**Was ausdrücklich nicht hineingehört:** Passwörter, Sitzungstoken und
|
||||
vollständige E-Mail-Adressen. Konten stehen mit ihrer Id da. Ein Protokoll
|
||||
wird kopiert und verschickt und ist selten so gut geschützt wie die
|
||||
Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## Was schon vorher stimmte
|
||||
@@ -191,10 +358,57 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
|
||||
|
||||
---
|
||||
|
||||
## Der Domain-Check und seine Grenzen
|
||||
|
||||
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
|
||||
Server aus auf. Drei Dinge grenzen das ein:
|
||||
|
||||
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
|
||||
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
|
||||
aus beliebige Adressen abklopfen ließen.
|
||||
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
|
||||
die auch Domains anlegt.
|
||||
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
|
||||
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
|
||||
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
|
||||
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
|
||||
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
|
||||
|
||||
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
|
||||
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
|
||||
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
||||
Übertragen wird über diese Verbindung nichts.
|
||||
|
||||
---
|
||||
|
||||
## Offen
|
||||
|
||||
Bewusst nicht umgesetzt, mit Begründung.
|
||||
|
||||
**Die Zähler gelten je Prozess.** Bremsen und Wartezeiten liegen im
|
||||
Arbeitsspeicher und sind nach einem Neustart weg. Bei **einer** Instanz — dem
|
||||
Regelfall dieser Anwendung — ist das verschmerzbar: Die Wartezeit je Konto
|
||||
steht in der Datenbank und übersteht den Neustart, nur die Zähler je Absender
|
||||
und die Obergrenze der Instanz beginnen von vorn.
|
||||
|
||||
Wer mehrere Instanzen hinter einem Lastverteiler betreibt, braucht einen
|
||||
gemeinsamen, atomaren Zähler — üblicherweise Redis. Das ist ein weiterer
|
||||
Dienst und lohnt sich erst, wenn es die zweite Instanz wirklich gibt.
|
||||
|
||||
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
|
||||
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx) in
|
||||
`deepmerge-ts@7.1.5`, mitgeschleppt über `@prisma/config`. Es geht um
|
||||
Stapelüberlauf beim Zusammenführen rekursiver Objekte.
|
||||
|
||||
Aus HTTP-Eingaben entsteht hier keine Prisma-Konfiguration, die praktische
|
||||
Ausnutzbarkeit ist also gering. Die Prisma-CLI liegt aber im Laufzeit-Abbild
|
||||
und läuft bei jedem Containerstart für die Migrationen — deshalb steht der
|
||||
Punkt hier und nicht unter „egal“. Eine Fassung ohne die Advisory gibt es
|
||||
noch nicht: 7.9.1 ist die neueste und hängt weiterhin an `deepmerge-ts@7.1.5`.
|
||||
`npm audit fix --force` würde auf Prisma 6 zurückstufen und ist deshalb keine
|
||||
Lösung. Zu tun: beobachten und aktualisieren, sobald es eine passende Fassung
|
||||
gibt.
|
||||
|
||||
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||||
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||||
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
@@ -17,6 +17,7 @@
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -566,7 +567,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2065,6 +2065,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -4039,7 +4105,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7795,7 +7860,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -7845,7 +7909,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.1.0",
|
||||
"version": "1.7.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"changelog": "node scripts/changelog.mjs",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
@@ -23,6 +24,7 @@
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation.
|
||||
|
||||
-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an
|
||||
-- einem Code, deshalb ist die Voreinstellung richtig.
|
||||
ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code';
|
||||
CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose");
|
||||
|
||||
CREATE TABLE "Branding" (
|
||||
"id" TEXT NOT NULL DEFAULT 'branding',
|
||||
"appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator',
|
||||
"footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes',
|
||||
"accentColor" TEXT NOT NULL DEFAULT '#4F46E5',
|
||||
"logoUploadId" TEXT,
|
||||
"faviconUploadId" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Branding_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId");
|
||||
CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId");
|
||||
|
||||
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey"
|
||||
FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey"
|
||||
FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss.
|
||||
INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
|
||||
|
||||
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
|
||||
-- gedruckten Codes entwerten.
|
||||
CREATE TABLE "QrCodeAlias" (
|
||||
"id" TEXT NOT NULL,
|
||||
"domainId" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"qrCodeId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
|
||||
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
|
||||
|
||||
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
|
||||
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
|
||||
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
|
||||
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
|
||||
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
|
||||
--
|
||||
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
|
||||
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
|
||||
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
|
||||
-- bricht die Migration in dem Fall lieber ab.
|
||||
DO $$
|
||||
DECLARE kollisionen INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO kollisionen FROM (
|
||||
SELECT "domainId", LOWER("slug")
|
||||
FROM "QrCode"
|
||||
GROUP BY "domainId", LOWER("slug")
|
||||
HAVING COUNT(*) > 1
|
||||
) AS doppelt;
|
||||
|
||||
IF kollisionen > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
|
||||
kollisionen;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
|
||||
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
|
||||
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
|
||||
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
|
||||
-- koennen.
|
||||
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
|
||||
ON "QrCode"("domainId", LOWER("slug"));
|
||||
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
|
||||
ON "QrCodeAlias"("domainId", LOWER("slug"));
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||
|
||||
CREATE TABLE "InstanceSetting" (
|
||||
"id" TEXT NOT NULL DEFAULT 'instance',
|
||||
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
|
||||
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
|
||||
-- und erneut herunterladen kann.
|
||||
|
||||
-- Alles, was es bisher gibt, ist dynamisch.
|
||||
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
|
||||
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
|
||||
|
||||
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
|
||||
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
|
||||
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
|
||||
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
|
||||
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
|
||||
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
|
||||
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
|
||||
--
|
||||
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
|
||||
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
|
||||
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
|
||||
--
|
||||
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
|
||||
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
|
||||
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
|
||||
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
|
||||
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
|
||||
|
||||
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
|
||||
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
|
||||
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- QR-Typen lassen sich je Gruppe abschalten.
|
||||
--
|
||||
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
|
||||
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
|
||||
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
|
||||
-- aufheben — global aus gewinnt immer.
|
||||
--
|
||||
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||
-- nebeneinander zu.
|
||||
CREATE TABLE "QrTypeGroupSetting" (
|
||||
"type" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
|
||||
);
|
||||
|
||||
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
|
||||
|
||||
-- Mit der Gruppe verschwindet auch ihre Abweichung.
|
||||
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
|
||||
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -87,6 +87,7 @@ model Group {
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
domainGrants DomainGrant[]
|
||||
typeSettings QrTypeGroupSetting[]
|
||||
}
|
||||
|
||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||
@@ -122,6 +123,7 @@ model Domain {
|
||||
|
||||
grants DomainGrant[]
|
||||
codes QrCode[]
|
||||
aliases QrCodeAlias[]
|
||||
}
|
||||
|
||||
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
||||
@@ -154,6 +156,31 @@ model QrTypeSetting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
|
||||
///
|
||||
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
|
||||
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
|
||||
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
||||
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
|
||||
/// nicht gesichert.
|
||||
///
|
||||
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
|
||||
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
|
||||
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
|
||||
/// aufheben — global aus gewinnt immer.
|
||||
model QrTypeGroupSetting {
|
||||
/// Kennung aus der Typ-Registry, z. B. "website".
|
||||
type String
|
||||
groupId String
|
||||
enabled Boolean
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([type, groupId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||
///
|
||||
@@ -161,10 +188,18 @@ model QrTypeSetting {
|
||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
model QrCode {
|
||||
id String @id @default(cuid())
|
||||
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
||||
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
|
||||
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
|
||||
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
|
||||
mode String @default("dynamic")
|
||||
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
|
||||
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||
slug String
|
||||
///
|
||||
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
|
||||
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
|
||||
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
|
||||
slug String?
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
@@ -173,6 +208,14 @@ model QrCode {
|
||||
size Int @default(512)
|
||||
ecLevel String @default("M")
|
||||
margin Int @default(4)
|
||||
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
|
||||
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
|
||||
logoSource String?
|
||||
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
|
||||
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
|
||||
logoUploadId String?
|
||||
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
|
||||
logoScale Int @default(20)
|
||||
scans Int @default(0)
|
||||
lastScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -194,11 +237,14 @@ model QrCode {
|
||||
uploads Upload[]
|
||||
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
||||
feedback Feedback[]
|
||||
/// Frühere Kurz-Links, die weiterhin hierher führen.
|
||||
aliases QrCodeAlias[]
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
@@index([domainId])
|
||||
@@index([mode])
|
||||
}
|
||||
|
||||
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||
@@ -219,14 +265,89 @@ model Upload {
|
||||
data Bytes
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
|
||||
/// erste Art wird als herrenlos weggeräumt.
|
||||
purpose String @default("code")
|
||||
|
||||
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
||||
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
||||
/// `src/lib/uploads.ts` nach einer Weile weg.
|
||||
qrCodeId String?
|
||||
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
brandingLogo Branding? @relation("BrandingLogo")
|
||||
brandingFavicon Branding? @relation("BrandingFavicon")
|
||||
|
||||
@@index([qrCodeId])
|
||||
@@index([createdAt])
|
||||
@@index([purpose])
|
||||
}
|
||||
|
||||
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||
///
|
||||
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
|
||||
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
|
||||
/// ihren Typ behalten.
|
||||
model InstanceSetting {
|
||||
id String @id @default("instance")
|
||||
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
|
||||
///
|
||||
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
|
||||
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
||||
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
||||
editorsMaySetSlug Boolean @default(false)
|
||||
/// Geheimnis fuer den Domain-Check.
|
||||
///
|
||||
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
|
||||
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
|
||||
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
|
||||
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
|
||||
/// Server nie und entsteht beim ersten Check.
|
||||
checkSecret String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
|
||||
///
|
||||
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
|
||||
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
|
||||
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
|
||||
/// Tippfehler, den noch niemand gedruckt hat.
|
||||
model QrCodeAlias {
|
||||
id String @id @default(cuid())
|
||||
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
|
||||
domainId String
|
||||
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||
slug String
|
||||
qrCodeId String
|
||||
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([qrCodeId])
|
||||
}
|
||||
|
||||
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
|
||||
///
|
||||
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
|
||||
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
|
||||
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
|
||||
model Branding {
|
||||
id String @id @default("branding")
|
||||
appName String @default("QR-Code-Generator")
|
||||
/// Zeile in der Fußzeile, hinter dem Namen.
|
||||
footerText String @default("statische und dynamische QR-Codes")
|
||||
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
|
||||
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
|
||||
/// src/lib/branding-colors.ts.
|
||||
accentColor String @default("#4F46E5")
|
||||
|
||||
logoUploadId String? @unique
|
||||
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
|
||||
faviconUploadId String? @unique
|
||||
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
|
||||
*
|
||||
* node scripts/changelog.mjs erzeugen
|
||||
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
|
||||
*
|
||||
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
|
||||
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
|
||||
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const ziel = join(wurzel, "CHANGELOG.md");
|
||||
|
||||
/**
|
||||
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
|
||||
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
|
||||
* besteht ausschließlich aus Zeichenketten und Listen.
|
||||
*/
|
||||
function ladeVerlauf() {
|
||||
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
|
||||
const anfang = quelle.indexOf("export const CHANGELOG");
|
||||
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
|
||||
|
||||
const start = quelle.indexOf("[", anfang);
|
||||
const ende = quelle.indexOf("\n];", start);
|
||||
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
|
||||
|
||||
const literal = quelle.slice(start, ende + 2);
|
||||
return Function(`"use strict"; return (${literal});`)();
|
||||
}
|
||||
|
||||
function formatiereDatum(iso) {
|
||||
const [jahr, monat, tag] = iso.split("-");
|
||||
return `${tag}.${monat}.${jahr}`;
|
||||
}
|
||||
|
||||
function baue(verlauf) {
|
||||
const zeilen = [
|
||||
"# Änderungen",
|
||||
"",
|
||||
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
|
||||
"Anwendung unter **Version und Änderungen**, erreichbar über die",
|
||||
"Versionsnummer in der Fußzeile.",
|
||||
"",
|
||||
"> Diese Datei wird erzeugt: `npm run changelog`.",
|
||||
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const eintrag of verlauf) {
|
||||
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
|
||||
zeilen.push(eintrag.summary, "");
|
||||
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
|
||||
zeilen.push("");
|
||||
}
|
||||
|
||||
return zeilen.join("\n");
|
||||
}
|
||||
|
||||
const inhalt = baue(ladeVerlauf());
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
let vorhanden = "";
|
||||
try {
|
||||
vorhanden = readFileSync(ziel, "utf8");
|
||||
} catch {
|
||||
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
|
||||
process.exit(1);
|
||||
}
|
||||
if (vorhanden !== inhalt) {
|
||||
console.error(
|
||||
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("CHANGELOG.md ist aktuell.");
|
||||
} else {
|
||||
writeFileSync(ziel, inhalt, "utf8");
|
||||
console.log("CHANGELOG.md geschrieben.");
|
||||
}
|
||||
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
|
||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||
</Notice>
|
||||
) : oidc?.nurOidc ? (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
|
||||
{setup
|
||||
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
|
||||
: ""}
|
||||
</p>
|
||||
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
|
||||
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
|
||||
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
|
||||
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
|
||||
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
|
||||
setup ? (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||
|
||||
@@ -4,6 +4,10 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { QrGenerator } from "@/components/qr-generator";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { getQrTypeState } from "@/lib/qr/availability";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
@@ -47,9 +51,14 @@ function Hinweis({
|
||||
}
|
||||
|
||||
export default async function CreatePage({ params }: Props) {
|
||||
await requirePageActor();
|
||||
const actor = await requirePageActor();
|
||||
const { type } = await params;
|
||||
const state = await getQrTypeState(type);
|
||||
|
||||
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
|
||||
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const state = await getQrTypeState(type, zielgruppe);
|
||||
|
||||
if (!state) {
|
||||
notFound();
|
||||
@@ -60,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
|
||||
<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."
|
||||
text={
|
||||
state.enabledGlobally
|
||||
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
|
||||
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -75,5 +88,24 @@ export default async function CreatePage({ params }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} />;
|
||||
// Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
|
||||
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
|
||||
// noch einmal.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
const maySetSlug = can(actor, "codes:slug", {
|
||||
groupId: zielgruppe,
|
||||
editorsMaySetSlug,
|
||||
});
|
||||
|
||||
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||
// der eigene Upload.
|
||||
const { logoUploadId } = await getBranding();
|
||||
|
||||
return (
|
||||
<QrGenerator
|
||||
typeId={state.qrType.id}
|
||||
maySetSlug={maySetSlug}
|
||||
hasBrandingLogo={logoUploadId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ export default async function AccountPage() {
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
||||
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>{" "}
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>{" "}
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
|
||||
@@ -2,8 +2,11 @@ import Link from "next/link";
|
||||
|
||||
import { LogoutButton } from "@/components/auth-forms";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { APP_VERSION } from "@/lib/changelog";
|
||||
|
||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||
// werden.
|
||||
@@ -21,24 +24,43 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
const actor = await getActor();
|
||||
const signedIn = actor.kind !== "anonymous";
|
||||
const showAdmin = can(actor, "instance:manage");
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 bg-surface">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
||||
>
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
{branding.logoUploadId ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||
Datenbank ausgeliefert, nicht aus dem Dateisystem */
|
||||
<img
|
||||
src={`/api/uploads/${branding.logoUploadId}`}
|
||||
alt={branding.appName}
|
||||
className="h-9 w-auto max-w-[12rem] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
|
||||
Navigation nicht mehr in eine Zeile. */}
|
||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
||||
Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
|
||||
dieses schon den Namen. */}
|
||||
{branding.logoUploadId ? null : (
|
||||
<span className="hidden sm:inline">{branding.appName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||
<ThemeToggle />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||
/>
|
||||
{signedIn ? (
|
||||
<>
|
||||
<Link href="/" className={NAV_LINK}>
|
||||
@@ -66,7 +88,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
title={`Konto von ${actor.user.email}`}
|
||||
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
||||
{actorLabel(actor).slice(0, 2)}
|
||||
</span>
|
||||
<span className="hidden truncate sm:inline">
|
||||
@@ -106,9 +128,21 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
<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
|
||||
<footer className="border-t border-slate-200 bg-surface">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||
<span>
|
||||
{branding.appName}
|
||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||
</span>
|
||||
{/* Welche Fassung hier läuft — nach einem Update die schnellste
|
||||
Antwort auf die Frage, ob das Update auch angekommen ist. */}
|
||||
<Link
|
||||
href="/version"
|
||||
title="Version und Änderungen"
|
||||
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
|
||||
{kommentare.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-900 tabular-nums">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CodesList } from "@/components/codes-list";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Codes" };
|
||||
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyCodesPage() {
|
||||
await requirePageActor();
|
||||
const actor = await requirePageActor();
|
||||
|
||||
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
|
||||
// nebeneinander zeigen können.
|
||||
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
|
||||
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
|
||||
// dann mit der Gruppe des betroffenen Codes.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
@@ -19,13 +27,13 @@ export default async function MyCodesPage() {
|
||||
Meine Codes
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
||||
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
||||
gespeichert.
|
||||
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
|
||||
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
|
||||
lässt sich die Liste auf eine der beiden Arten einschränken.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodesList />
|
||||
<CodesList maySetSlug={maySetSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@ 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";
|
||||
import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
|
||||
|
||||
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
||||
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
await requirePageActor();
|
||||
const types = await getVisibleQrTypes();
|
||||
const actor = await requirePageActor();
|
||||
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
|
||||
const types = await getVisibleQrTypesForActor(actor);
|
||||
const usableCount = types.filter((entry) => entry.usable).length;
|
||||
|
||||
return (
|
||||
@@ -50,7 +51,7 @@ export default async function HomePage() {
|
||||
<span
|
||||
className={
|
||||
usable
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
|
||||
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
||||
}
|
||||
>
|
||||
@@ -96,7 +97,7 @@ export default async function HomePage() {
|
||||
{usable ? (
|
||||
<Link
|
||||
href={`/erstellen/${qrType.id}`}
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
{tile}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Badge, Card } from "@/components/ui";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import {
|
||||
APP_VERSION,
|
||||
CHANGELOG,
|
||||
formatChangelogDate,
|
||||
} from "@/lib/changelog";
|
||||
|
||||
export const metadata: Metadata = { title: "Version und Änderungen" };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VersionPage() {
|
||||
await requirePageActor();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Typ-Auswahl
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Version und Änderungen
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Hier läuft Version{" "}
|
||||
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
|
||||
Darunter steht, was in welcher Fassung dazugekommen ist.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
|
||||
Nummer, läuft der Container noch mit dem alten Abbild.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-4">
|
||||
{CHANGELOG.map((eintrag) => {
|
||||
const laeuft = eintrag.version === APP_VERSION;
|
||||
return (
|
||||
<li key={eintrag.version}>
|
||||
<Card>
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
{eintrag.version}
|
||||
</h2>
|
||||
<span className="text-sm text-slate-500 tabular-nums">
|
||||
{formatChangelogDate(eintrag.date)}
|
||||
</span>
|
||||
{laeuft ? (
|
||||
<Badge tone="accent">läuft hier</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
|
||||
|
||||
<ul className="space-y-1.5 text-sm text-slate-700">
|
||||
{eintrag.changes.map((zeile) => (
|
||||
<li key={zeile} className="flex gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
|
||||
/>
|
||||
<span>{zeile}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { SettingsAdmin } from "@/components/settings-admin";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Einstellungen
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { BrandingAdmin } from "@/components/branding-admin";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
export const metadata: Metadata = { title: "Erscheinungsbild" };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BrandingPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
const branding = await getBranding();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Erscheinungsbild
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
|
||||
gelten für die ganze Installation.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Die öffentlichen Landingpages übernehmen die Farbe und das Favicon,
|
||||
aber weder Logo noch Navigation — wer einen Code scannt, soll die
|
||||
Seite des Inhalts sehen und nicht die einer Verwaltungssoftware.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BrandingAdmin
|
||||
initial={{
|
||||
appName: branding.appName,
|
||||
footerText: branding.footerText,
|
||||
accentColor: branding.accentColor,
|
||||
logoUploadId: branding.logoUploadId,
|
||||
faviconUploadId: branding.faviconUploadId,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Icon, type IconName } from "@/components/icons";
|
||||
import { Notice } from "@/components/ui";
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
@@ -26,9 +28,9 @@ function Tile({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -45,12 +47,22 @@ function Tile({
|
||||
export default async function AdminPage() {
|
||||
await requirePagePermission("instance:manage");
|
||||
|
||||
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.group.count(),
|
||||
prisma.qrCode.count(),
|
||||
prisma.domain.count(),
|
||||
]);
|
||||
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
|
||||
await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.group.count(),
|
||||
prisma.qrCode.count(),
|
||||
prisma.domain.count(),
|
||||
getBranding(),
|
||||
getSettings(),
|
||||
]);
|
||||
|
||||
const eigenes = [
|
||||
branding.logoUploadId && "Logo",
|
||||
branding.faviconUploadId && "Favicon",
|
||||
].filter(Boolean);
|
||||
const brandingHint =
|
||||
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
@@ -68,11 +80,11 @@ export default async function AdminPage() {
|
||||
<Notice tone="info">
|
||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
AUTH_ENABLED=true
|
||||
</code>
|
||||
in der <code className="font-mono text-xs">.env</code> setzen und
|
||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||
docker compose up -d
|
||||
</code>
|
||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||
@@ -110,6 +122,24 @@ export default async function AdminPage() {
|
||||
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
||||
hint="15 Typen insgesamt"
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/einstellungen"
|
||||
icon="lock"
|
||||
title="Einstellungen"
|
||||
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
|
||||
hint={
|
||||
settings.editorsMaySetSlug
|
||||
? "Kurz-Links: auch Bearbeiter"
|
||||
: "Kurz-Links: nur Administratoren"
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/erscheinungsbild"
|
||||
icon="image"
|
||||
title="Erscheinungsbild"
|
||||
description="Name, Logo, Favicon und Farbe der Oberfläche."
|
||||
hint={brandingHint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||
|
||||
export const metadata: Metadata = { title: "QR-Typen" };
|
||||
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
|
||||
const availability = await getQrTypeAvailability();
|
||||
|
||||
const initial: TypeSetting[] = availability.map(
|
||||
({ qrType, implemented, enabled }) => ({
|
||||
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
enabledGlobally,
|
||||
disabledInGroup,
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = await prisma.group.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
|
||||
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
|
||||
anlegen.
|
||||
</p>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
|
||||
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
|
||||
abgeschaltet.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
|
||||
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
|
||||
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TypeSettings initial={initial} />
|
||||
<TypeSettings initial={initial} groups={groups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { isHexColor } from "@/lib/branding-colors";
|
||||
import {
|
||||
getBranding,
|
||||
MAX_APP_NAME,
|
||||
MAX_FOOTER_TEXT,
|
||||
saveBranding,
|
||||
} from "@/lib/branding";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */
|
||||
const bildSchema = z.string().trim().min(1).max(64).nullable().optional();
|
||||
|
||||
const updateSchema = z.object({
|
||||
appName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte gib einen Namen an." })
|
||||
.max(MAX_APP_NAME, {
|
||||
error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`,
|
||||
})
|
||||
.optional(),
|
||||
footerText: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(MAX_FOOTER_TEXT, {
|
||||
error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`,
|
||||
})
|
||||
.optional(),
|
||||
accentColor: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isHexColor, {
|
||||
error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
|
||||
})
|
||||
.optional(),
|
||||
logoUploadId: bildSchema,
|
||||
faviconUploadId: bildSchema,
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
return Response.json({ branding: await getBranding() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass eine angegebene Datei existiert und wirklich zum
|
||||
* Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden
|
||||
* QR-Codes einhängen und damit dessen Anzeige zerstören.
|
||||
*/
|
||||
async function assertBrandingUpload(
|
||||
id: string | null | undefined,
|
||||
feld: string,
|
||||
): Promise<void> {
|
||||
if (!id) return;
|
||||
const upload = await prisma.upload.findUnique({
|
||||
where: { id },
|
||||
select: { purpose: true },
|
||||
});
|
||||
if (!upload || upload.purpose !== "branding") {
|
||||
throw new QrRequestError(
|
||||
`${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`,
|
||||
404,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo");
|
||||
await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon");
|
||||
|
||||
return Response.json({ branding: await saveBranding(parsed.data) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pruefeDomain } from "@/lib/domain-check";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
|
||||
* dahinter wirklich diese Installation steht.
|
||||
*
|
||||
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
|
||||
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
|
||||
*
|
||||
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
|
||||
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
|
||||
* Server aus beliebige Adressen abklopfen lassen.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
const { id } = await context.params;
|
||||
|
||||
const domain = await prisma.domain.findUnique({
|
||||
where: { id },
|
||||
select: { hostname: true },
|
||||
});
|
||||
if (!domain) {
|
||||
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
|
||||
}
|
||||
|
||||
return Response.json({ report: await pruefeDomain(domain.hostname) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { getSettings, saveSettings } from "@/lib/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const updateSchema = z.object({
|
||||
editorsMaySetSlug: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
return Response.json({ settings: await getSettings() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
return Response.json({ settings: await saveSettings(parsed.data) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { z } from "zod";
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getQrTypeAvailability,
|
||||
setQrTypeEnabled,
|
||||
setQrTypeEnabledForGroup,
|
||||
} from "@/lib/qr/availability";
|
||||
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
|
||||
const updateSchema = z.object({
|
||||
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
||||
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
|
||||
/**
|
||||
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
|
||||
* für die ganze Installation.
|
||||
*/
|
||||
groupId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
/** Zustand aller QR-Typen für die Verwaltung. */
|
||||
export async function GET(): Promise<Response> {
|
||||
/**
|
||||
* Zustand aller QR-Typen für die Verwaltung.
|
||||
*
|
||||
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
|
||||
* Grundeinstellung.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
const types = await getQrTypeAvailability();
|
||||
|
||||
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
|
||||
if (groupId !== undefined) {
|
||||
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||
}
|
||||
|
||||
const types = await getQrTypeAvailability(groupId);
|
||||
return Response.json({
|
||||
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
usable,
|
||||
})),
|
||||
groupId: groupId ?? null,
|
||||
types: types.map(
|
||||
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
enabledGlobally,
|
||||
disabledInGroup,
|
||||
usable,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
|
||||
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
|
||||
const { type, enabled, groupId } = parsed.data;
|
||||
|
||||
if (groupId === undefined) {
|
||||
await setQrTypeEnabled(type, enabled);
|
||||
} else {
|
||||
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
|
||||
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
|
||||
await setQrTypeEnabledForGroup(type, groupId, enabled);
|
||||
}
|
||||
|
||||
return Response.json({ type, enabled, groupId: groupId ?? null });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type OidcIdentity,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBaseUrl } from "@/lib/env";
|
||||
|
||||
@@ -100,6 +101,12 @@ async function findeOderLegeAn(
|
||||
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||
// übernehmen.
|
||||
if (!identity.emailVerified) {
|
||||
// Der Versuch gehört ins Protokoll: Wenn er gehäuft auftritt, stimmt
|
||||
// entweder die Anbieterkonfiguration nicht — oder jemand probiert es.
|
||||
sicherheitsereignis("oidc-verknuepfung-abgelehnt", {
|
||||
konto: nachEmail.id,
|
||||
grund: "email-nicht-bestaetigt",
|
||||
});
|
||||
return {
|
||||
fehler:
|
||||
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
||||
@@ -116,6 +123,12 @@ async function findeOderLegeAn(
|
||||
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||
},
|
||||
});
|
||||
// Eine Verknüpfung verschiebt eine Vertrauensgrenze: Ab jetzt kommt man
|
||||
// über den Anbieter in dieses Konto. Das soll nachvollziehbar sein.
|
||||
sicherheitsereignis("oidc-konto-verknuepft", {
|
||||
konto: verknuepft.id,
|
||||
administrator: verknuepft.isInstanceAdmin,
|
||||
});
|
||||
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||
}
|
||||
|
||||
@@ -128,21 +141,37 @@ async function findeOderLegeAn(
|
||||
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||
const istErster = (await prisma.user.count()) === 0;
|
||||
|
||||
const angelegt = await prisma.user.create({
|
||||
data: {
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
passwordHash: null,
|
||||
oidcSubject: identity.subject,
|
||||
isInstanceAdmin: istAdmin ?? istErster,
|
||||
lastLoginAt: new Date(),
|
||||
memberships: standardgruppe
|
||||
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
||||
: undefined,
|
||||
//
|
||||
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
|
||||
// jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung
|
||||
// dazwischenkommen, und beide würden sich für die erste halten. Am Ende
|
||||
// stünden zwei Administratoren da, von denen einer ungebeten ist.
|
||||
const angelegt = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const istErster = (await tx.user.count()) === 0;
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
passwordHash: null,
|
||||
oidcSubject: identity.subject,
|
||||
isInstanceAdmin: istAdmin ?? istErster,
|
||||
lastLoginAt: new Date(),
|
||||
memberships: standardgruppe
|
||||
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
if (angelegt.isInstanceAdmin) {
|
||||
sicherheitsereignis("administrator-vergeben", {
|
||||
konto: angelegt.id,
|
||||
weg: istAdmin === undefined ? "erster-benutzer" : "gruppe-des-anbieters",
|
||||
});
|
||||
}
|
||||
|
||||
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||
import {
|
||||
assertHostMatchesBaseUrl,
|
||||
discover,
|
||||
getOidcConfig,
|
||||
isOidcEnabled,
|
||||
@@ -26,7 +28,7 @@ function base64url(buffer: Buffer): string {
|
||||
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
||||
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
if (!isAuthEnabled()) {
|
||||
throw new QrRequestError(
|
||||
@@ -41,6 +43,10 @@ export async function GET(): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
|
||||
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
|
||||
assertHostMatchesBaseUrl(request.headers);
|
||||
|
||||
const config = getOidcConfig();
|
||||
const meta = await discover(config.issuer);
|
||||
|
||||
|
||||
@@ -17,18 +17,31 @@ import {
|
||||
} from "@/lib/auth/session";
|
||||
import {
|
||||
clearAttempts,
|
||||
kontoWartezeit,
|
||||
requestOrigin,
|
||||
teureRechnungErlaubt,
|
||||
tooManyAttempts,
|
||||
verbleibendeKontowartezeit,
|
||||
} from "@/lib/auth/throttle";
|
||||
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||
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;
|
||||
/**
|
||||
* Wartezeit statt Sperre.
|
||||
*
|
||||
* Früher stand hier eine Sperre von 15 Minuten nach acht Fehlversuchen. Die
|
||||
* hatte einen Haken: Sie wies auch das **richtige** Passwort ab. Wer die
|
||||
* Adresse eines Administrators kannte, konnte ihn damit alle 15 Minuten neu
|
||||
* aussperren und die Verwaltung praktisch dauerhaft schließen.
|
||||
*
|
||||
* Jetzt wächst nur die Wartezeit zwischen zwei Versuchen, bis zu einer
|
||||
* Minute — siehe `kontoWartezeit`. Der Besitzer kommt spätestens nach einer
|
||||
* Minute hinein, ein Fremder bekommt einen Versuch pro Minute.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
||||
@@ -248,14 +261,31 @@ async function handleSetup(
|
||||
|
||||
async function handleLogin(
|
||||
input: z.infer<typeof loginSchema>,
|
||||
absender: string,
|
||||
/** Absender laut vertrauenswürdigem Proxy — `null`, wenn keiner davorsteht. */
|
||||
absender: string | null,
|
||||
): Promise<Response> {
|
||||
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
||||
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||
if (wartezeit !== null) {
|
||||
// Wer viele Konten durchprobiert, löst nie eine Wartezeit für ein
|
||||
// einzelnes Konto aus — dagegen hilft nur eine Bremse je Absender. Sie
|
||||
// greift nur hinter einem Proxy, dem ausdrücklich vertraut wird; sonst
|
||||
// wählte der Aufrufer seine Kennung selbst.
|
||||
if (absender !== null) {
|
||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||
if (wartezeit !== null) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Der Deckel der ganzen Installation. Er steht vor jeder Passwortprüfung,
|
||||
// auch vor der gegen den Attrappen-Hash: Beide kosten gleich viel Speicher,
|
||||
// und wer sie auslösen darf, könnte den Server sonst allein damit
|
||||
// lahmlegen. Das ist die einzige Bremse, die sich nicht über Kopfzeilen
|
||||
// umgehen lässt.
|
||||
if (!teureRechnungErlaubt()) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||
"Zurzeit gehen sehr viele Anmeldeversuche ein. Bitte versuche es in einer Minute erneut.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -275,12 +305,15 @@ async function handleLogin(
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutes = Math.ceil(
|
||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||
);
|
||||
// Wartezeit des Kontos — **vor** der Passwortprüfung, damit ein Versuch,
|
||||
// der ohnehin abgewiesen wird, keine 128 MiB kostet.
|
||||
const kontoWarten = verbleibendeKontowartezeit(
|
||||
user.failedLogins,
|
||||
user.lockedUntil,
|
||||
);
|
||||
if (kontoWarten > 0) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
|
||||
`Zu viele Fehlversuche. Bitte versuche es in ${kontoWarten} Sekunde${kontoWarten === 1 ? "" : "n"} erneut.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -295,15 +328,17 @@ async function handleLogin(
|
||||
|
||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||
const failedLogins = user.failedLogins + 1;
|
||||
// `lockedUntil` heißt jetzt „frühestens wieder ab“: der Zeitpunkt des
|
||||
// Fehlversuchs. Wie lange das bedeutet, rechnet `kontoWartezeit` aus der
|
||||
// Zahl der Fehlversuche — so bleibt die Spalte, aber nicht die Sperre.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLogins,
|
||||
lockedUntil:
|
||||
failedLogins >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
||||
: null,
|
||||
},
|
||||
data: { failedLogins, lockedUntil: new Date() },
|
||||
});
|
||||
sicherheitsereignis("anmeldung-fehlgeschlagen", {
|
||||
konto: user.id,
|
||||
fehlversuche: failedLogins,
|
||||
wartezeit: kontoWartezeit(failedLogins),
|
||||
});
|
||||
throw invalid;
|
||||
}
|
||||
@@ -333,7 +368,7 @@ async function handleLogin(
|
||||
},
|
||||
});
|
||||
|
||||
clearAttempts(`login:${absender}`);
|
||||
if (absender !== null) clearAttempts(`login:${absender}`);
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email });
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shortUrl } from "@/lib/env";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { errorResponse } from "@/lib/http";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||
|
||||
@@ -47,13 +49,22 @@ export async function GET(
|
||||
size: code.size,
|
||||
ecLevel: code.ecLevel,
|
||||
margin: code.margin,
|
||||
logoSource: code.logoSource,
|
||||
logoUploadId: code.logoUploadId,
|
||||
logoScale: code.logoScale,
|
||||
});
|
||||
const logo = await ladeLogo(design);
|
||||
|
||||
const content = shortUrl(code.slug, code.domain.hostname);
|
||||
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||
const content =
|
||||
code.mode === "static" || !code.slug
|
||||
? requireImplementedQrType(code.type).encode(code.data)
|
||||
: shortUrl(code.slug, code.domain.hostname);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
: await renderSvg(content, design);
|
||||
? new Uint8Array(await renderPng(content, design, logo))
|
||||
: await renderSvg(content, design, logo);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
|
||||
@@ -10,7 +10,10 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
} as const;
|
||||
|
||||
const updateSchema = z.object({
|
||||
@@ -32,6 +36,17 @@ const updateSchema = z.object({
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.nullable()
|
||||
.optional(),
|
||||
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
|
||||
slug: z.string().trim().max(120).optional(),
|
||||
/**
|
||||
* Welche früheren Adressen weiterhin gelten sollen.
|
||||
*
|
||||
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
|
||||
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
|
||||
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
|
||||
* freigegeben — und ist damit sofort tot.
|
||||
*/
|
||||
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
|
||||
});
|
||||
|
||||
async function loadCode(id: string) {
|
||||
@@ -81,6 +96,15 @@ export async function PATCH(
|
||||
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||
let neueDateien: string[] | null = null;
|
||||
|
||||
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
|
||||
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
|
||||
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
|
||||
if (parsed.data.data !== undefined && existing.mode === "static") {
|
||||
return jsonError(
|
||||
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.data !== undefined) {
|
||||
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
||||
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
||||
@@ -109,20 +133,122 @@ export async function PATCH(
|
||||
Object.assign(update, parsed.data.design);
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
|
||||
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
|
||||
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
|
||||
const logoVorher =
|
||||
existing.logoSource === "upload" ? existing.logoUploadId : null;
|
||||
const logoNachher =
|
||||
parsed.data.design === undefined
|
||||
? logoVorher
|
||||
: parsed.data.design.logoSource === "upload"
|
||||
? parsed.data.design.logoUploadId
|
||||
: null;
|
||||
|
||||
/* ------------------------------------------------------- Kurz-Link */
|
||||
|
||||
const neuerSlug = parsed.data.slug?.trim();
|
||||
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
|
||||
// selbst. Ein Umbenennen waere gegenstandslos.
|
||||
if (existing.mode === "static" && neuerSlug) {
|
||||
return jsonError(
|
||||
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||
);
|
||||
}
|
||||
const umbenannt =
|
||||
neuerSlug !== undefined &&
|
||||
existing.slug !== null &&
|
||||
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
||||
|
||||
/**
|
||||
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
|
||||
* es ist; steht es da, beschreibt es den gewünschten Zustand.
|
||||
*/
|
||||
const behalten =
|
||||
parsed.data.aliases === undefined
|
||||
? null
|
||||
: new Set(parsed.data.aliases.map(normalizeSlug));
|
||||
|
||||
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
|
||||
where: { qrCodeId: existing.id },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const freigeben = behalten
|
||||
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
|
||||
: [];
|
||||
|
||||
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
|
||||
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
|
||||
// mit — unverändert soll das kein zusätzliches Recht verlangen.
|
||||
if (umbenannt || freigeben.length > 0) {
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
requirePermission(actor, "codes:slug", {
|
||||
groupId: existing.groupId,
|
||||
editorsMaySetSlug,
|
||||
});
|
||||
}
|
||||
|
||||
if (umbenannt) {
|
||||
const problem = describeSlugProblem(neuerSlug);
|
||||
if (problem) return jsonError(problem);
|
||||
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
|
||||
update.slug = normalizeSlug(neuerSlug);
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(update).length === 0 &&
|
||||
!umbenannt &&
|
||||
freigeben.length === 0
|
||||
) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
const updated = await prisma.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
|
||||
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
|
||||
// ist, oder eine freigegebene Adresse bliebe hängen.
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (freigeben.length > 0) {
|
||||
await tx.qrCodeAlias.deleteMany({
|
||||
where: { id: { in: freigeben.map((a) => a.id) } },
|
||||
});
|
||||
}
|
||||
|
||||
if (umbenannt) {
|
||||
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
|
||||
// Codes. Freigeben lässt er sich danach einzeln.
|
||||
await tx.qrCodeAlias.create({
|
||||
data: {
|
||||
domainId: existing.domainId,
|
||||
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
|
||||
slug: existing.slug as string,
|
||||
qrCodeId: existing.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
});
|
||||
|
||||
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
||||
// später als verwaist weggeräumt.
|
||||
if (neueDateien !== null) {
|
||||
await attachUploads(updated.id, neueDateien);
|
||||
await attachUploads(
|
||||
updated.id,
|
||||
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
|
||||
);
|
||||
} else if (logoNachher && logoNachher !== logoVorher) {
|
||||
await attachUpload(updated.id, logoNachher);
|
||||
}
|
||||
|
||||
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
|
||||
// später weggeräumt.
|
||||
if (logoVorher && logoVorher !== logoNachher) {
|
||||
await detachUpload(updated.id, logoVorher);
|
||||
}
|
||||
|
||||
return Response.json({ code: toQrCodeDto(updated) });
|
||||
|
||||
@@ -7,12 +7,15 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
||||
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveTargetDomain } from "@/lib/domains";
|
||||
import { findStaticDuplicate } from "@/lib/duplicates";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { requireAvailableQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
|
||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -26,12 +29,47 @@ const createSchema = z.object({
|
||||
.trim()
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.optional(),
|
||||
/**
|
||||
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
||||
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
||||
*/
|
||||
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
||||
.default("dynamic"),
|
||||
/**
|
||||
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
||||
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
||||
*/
|
||||
slug: z.string().trim().max(120).optional(),
|
||||
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||
groupId: z.string().trim().min(1).optional(),
|
||||
/** Unter welcher Domain die Kurz-URL entsteht. */
|
||||
domainId: z.string().trim().min(1).optional(),
|
||||
/**
|
||||
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
|
||||
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
|
||||
* statischer Code in der Gruppe liegt.
|
||||
*/
|
||||
allowDuplicate: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Alle Dateien, die zu einem Code gehören.
|
||||
*
|
||||
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
||||
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
||||
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
||||
*/
|
||||
function dateienDesCodes(
|
||||
qrType: { uploadIds: (data: never) => string[] },
|
||||
data: unknown,
|
||||
design: { logoSource: string | null; logoUploadId: string | null },
|
||||
): string[] {
|
||||
const ids = qrType.uploadIds(data as never);
|
||||
return design.logoSource === "upload" && design.logoUploadId
|
||||
? [...ids, design.logoUploadId]
|
||||
: ids;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -57,6 +95,7 @@ export async function GET(): Promise<Response> {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,22 +125,95 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
// ist — und der Slug ist nur je Domain eindeutig.
|
||||
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
||||
|
||||
const qrType = await requireAvailableQrType(type);
|
||||
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
|
||||
const qrType = await requireAvailableQrType(type, groupId);
|
||||
|
||||
const validated = qrType.validate(parsed.data.data);
|
||||
if (!validated.ok) {
|
||||
return jsonError(validated.message);
|
||||
}
|
||||
|
||||
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||
assertDynamicPossible(qrType, validated.data);
|
||||
const istStatisch = parsed.data.mode === "static";
|
||||
|
||||
if (istStatisch) {
|
||||
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
|
||||
// können, sonst gäbe es nichts zu kodieren.
|
||||
if (!qrType.supportsStatic) {
|
||||
return jsonError(
|
||||
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
|
||||
);
|
||||
}
|
||||
if (parsed.data.slug) {
|
||||
return jsonError(
|
||||
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||
assertDynamicPossible(qrType, validated.data);
|
||||
}
|
||||
|
||||
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
||||
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
||||
const gewuenscht = parsed.data.slug?.trim();
|
||||
if (gewuenscht) {
|
||||
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||
|
||||
const problem = describeSlugProblem(gewuenscht);
|
||||
if (problem) return jsonError(problem);
|
||||
await assertSlugAvailable(domainId, gewuenscht);
|
||||
}
|
||||
|
||||
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
|
||||
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
|
||||
// eines stillen zweiten Eintrags.
|
||||
if (istStatisch && !parsed.data.allowDuplicate) {
|
||||
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
|
||||
if (dublette) {
|
||||
return Response.json(
|
||||
{
|
||||
error: dublette.label
|
||||
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
|
||||
: "Diesen Inhalt hast du schon abgelegt.",
|
||||
duplicate: dublette,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (istStatisch) {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
mode: "static",
|
||||
slug: null,
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
groupId,
|
||||
domainId,
|
||||
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||
...design,
|
||||
},
|
||||
include: {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
}
|
||||
|
||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const created = await prisma.qrCode.create({
|
||||
data: {
|
||||
slug: generateSlug(),
|
||||
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
@@ -114,14 +226,23 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||
// vorher lagen sie ohne Bezug herum.
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
} catch (error) {
|
||||
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
|
||||
// käme immer wieder auf dieselbe Adresse.
|
||||
if (gewuenscht && isUniqueViolation(error)) {
|
||||
return jsonError(
|
||||
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Beweis-Route für den Domain-Check.
|
||||
*
|
||||
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
|
||||
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
|
||||
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
|
||||
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
|
||||
* nichts über das Geheimnis.
|
||||
*
|
||||
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
|
||||
|
||||
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
|
||||
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
|
||||
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
|
||||
return Response.json(
|
||||
{ error: "Es fehlt eine gültige Zufallszahl." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const proof = bildeBeweis(await getCheckSecret(), nonce);
|
||||
return Response.json(
|
||||
{ app: "qrcode-generator", proof },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Domain-Check konnte keinen Beweis bilden:", error);
|
||||
return Response.json({ error: "Nicht möglich." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requestOrigin } from "@/lib/auth/throttle";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
|
||||
@@ -31,6 +32,31 @@ const FENSTER_MS = 60_000;
|
||||
const MAX_PRO_FENSTER = 10;
|
||||
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
||||
|
||||
/**
|
||||
* Obergrenze für die ganze Installation.
|
||||
*
|
||||
* Die Bremse je Absender greift nur hinter einem Proxy, dem vertraut wird —
|
||||
* sonst wählt der Aufrufer seine Kennung selbst. Dieser Deckel gilt immer
|
||||
* und begrenzt, wie schnell die Tabelle überhaupt wachsen kann.
|
||||
*
|
||||
* Großzügig bemessen: Selbst ein gut besuchter Aushang bekommt keine
|
||||
* hundertzwanzig Rückmeldungen in der Minute.
|
||||
*/
|
||||
const MAX_PRO_MINUTE_INSGESAMT = 120;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
let gesamtFenster = 0;
|
||||
let gesamtAnzahl = 0;
|
||||
|
||||
function insgesamtZuSchnell(): boolean {
|
||||
const jetzt = Date.now();
|
||||
if (jetzt - gesamtFenster > MINUTE_MS) {
|
||||
gesamtFenster = jetzt;
|
||||
gesamtAnzahl = 0;
|
||||
}
|
||||
gesamtAnzahl += 1;
|
||||
return gesamtAnzahl > MAX_PRO_MINUTE_INSGESAMT;
|
||||
}
|
||||
|
||||
function zuSchnell(schluessel: string): boolean {
|
||||
const jetzt = Date.now();
|
||||
const eintrag = zaehler.get(schluessel);
|
||||
@@ -58,12 +84,19 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const herkunft =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unbekannt";
|
||||
// Erst der Deckel für alle — er lässt sich nicht über Kopfzeilen umgehen.
|
||||
if (insgesamtZuSchnell()) {
|
||||
return jsonError(
|
||||
"Zurzeit gehen sehr viele Rückmeldungen ein. Bitte versuche es gleich noch einmal.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
if (zuSchnell(herkunft)) {
|
||||
// Dann die Bremse je Absender. Sie sagt nur etwas aus, wenn ein Proxy
|
||||
// davorsteht, dem ausdrücklich vertraut wird; sonst wählte der Aufrufer
|
||||
// seine Kennung selbst und die Bremse wäre eine Behauptung.
|
||||
const herkunft = requestOrigin(request.headers);
|
||||
if (herkunft !== null && zuSchnell(herkunft)) {
|
||||
return jsonError(
|
||||
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||
429,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||
import {
|
||||
downloadFilename,
|
||||
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
||||
|
||||
const logo = await ladeLogo(design);
|
||||
const body =
|
||||
format === "png"
|
||||
? new Uint8Array(await renderPng(content, design))
|
||||
: await renderSvg(content, design);
|
||||
? new Uint8Array(await renderPng(content, design, logo))
|
||||
: await renderSvg(content, design, logo);
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
|
||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { ladeLogo } from "@/lib/qr/logo";
|
||||
import { renderSvg } from "@/lib/qr/render";
|
||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||
|
||||
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
const { content } = await resolveQrRequest(parsed.data);
|
||||
const svg = await renderSvg(content, parsed.data.design);
|
||||
const logo = await ladeLogo(parsed.data.design);
|
||||
const svg = await renderSvg(content, parsed.data.design, logo);
|
||||
|
||||
return Response.json({
|
||||
content,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isUploadKind,
|
||||
maxUploadBytes,
|
||||
storeUpload,
|
||||
uploadPurposeOf,
|
||||
} from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
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();
|
||||
@@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError("Unbekannte Dateiart.");
|
||||
}
|
||||
|
||||
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
|
||||
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
|
||||
if (uploadPurposeOf(kind) === "branding") {
|
||||
requirePermission(actor, "instance:manage");
|
||||
} else {
|
||||
const groupId = await resolveTargetGroup(actor, undefined);
|
||||
requirePermission(actor, "codes:create", { groupId });
|
||||
}
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError("Es wurde keine Datei mitgeschickt.");
|
||||
|
||||
@@ -9,10 +9,81 @@
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Dark Mode
|
||||
|
||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
|
||||
|
||||
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
|
||||
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
|
||||
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
|
||||
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
|
||||
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
|
||||
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
|
||||
entscheidet sich hier.
|
||||
|
||||
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
|
||||
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
|
||||
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
|
||||
|
||||
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
|
||||
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
|
||||
demselben leicht blauen Ton wie die helle Reihe.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
|
||||
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
|
||||
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
|
||||
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
|
||||
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
|
||||
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
|
||||
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
|
||||
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
|
||||
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
|
||||
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
|
||||
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
|
||||
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
|
||||
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
|
||||
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
|
||||
|
||||
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
|
||||
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
|
||||
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
|
||||
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
|
||||
|
||||
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
|
||||
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
|
||||
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
|
||||
--color-accent-text: light-dark(
|
||||
var(--color-indigo-600),
|
||||
var(--color-indigo-400)
|
||||
);
|
||||
--color-accent-soft: light-dark(
|
||||
var(--color-indigo-50),
|
||||
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
|
||||
);
|
||||
--color-accent-soft-fg: light-dark(
|
||||
var(--color-indigo-700),
|
||||
var(--color-indigo-300)
|
||||
);
|
||||
|
||||
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
|
||||
der Grund einer Videovorschau. */
|
||||
--color-scrim: oklch(20.8% 0.042 265.755);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-slate-50);
|
||||
color: var(--color-slate-900);
|
||||
@@ -22,7 +93,7 @@ body {
|
||||
|
||||
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-indigo-600);
|
||||
outline: 2px solid var(--color-accent-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||
import { accentCss } from "@/lib/branding-colors";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "QR-Code-Generator",
|
||||
template: "%s · QR-Code-Generator",
|
||||
},
|
||||
description:
|
||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||
};
|
||||
// Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
|
||||
// vorgerendert werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await getBranding();
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: branding.appName,
|
||||
template: `%s · ${branding.appName}`,
|
||||
},
|
||||
description:
|
||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
|
||||
...(branding.faviconUploadId
|
||||
? {
|
||||
icons: {
|
||||
icon: {
|
||||
url: `/api/uploads/${branding.faviconUploadId}`,
|
||||
type: branding.faviconMimeType ?? undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wurzel-Layout — bewusst nur `html` und `body`.
|
||||
* Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
|
||||
*
|
||||
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
|
||||
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
|
||||
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
|
||||
* „Meine Codes“ nichts zu suchen.
|
||||
*
|
||||
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
|
||||
*/
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const branding = await getBranding();
|
||||
|
||||
// Der Nonce dieser Anfrage, gesetzt in `src/proxy.ts`. Ohne ihn lehnt der
|
||||
// Browser die beiden eingebetteten Blöcke unten ab — sie sind die einzigen
|
||||
// der Anwendung.
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="flex min-h-full flex-col">{children}</body>
|
||||
<body className="flex min-h-full flex-col">
|
||||
{/*
|
||||
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
|
||||
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||
`light-dark()` im CSS von allein.
|
||||
*/}
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
|
||||
/>
|
||||
{/*
|
||||
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
|
||||
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
|
||||
die Voreinstellung zurück.
|
||||
*/}
|
||||
<style
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||
/>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
|
||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { isValidSlug } from "@/lib/slug";
|
||||
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ slug: string }> };
|
||||
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
|
||||
// Sammelroute: Next liefert die Abschnitte dann als Liste.
|
||||
type Context = { params: Promise<{ slug: string[] }> };
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return new Response(
|
||||
@@ -44,7 +47,8 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
const { slug } = await context.params;
|
||||
const abschnitte = (await context.params).slug;
|
||||
const slug = (abschnitte ?? []).join("/");
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||
@@ -65,9 +69,8 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const code = await prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
||||
});
|
||||
const gefunden = await findCodeBySlug(domain.id, slug);
|
||||
const code = gefunden?.code;
|
||||
|
||||
if (!code) {
|
||||
return notFound(
|
||||
@@ -85,7 +88,7 @@ export async function GET(
|
||||
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
||||
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
||||
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
||||
target = `/s/${slug}`;
|
||||
target = `/s/${code.slug}`;
|
||||
} else {
|
||||
try {
|
||||
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
||||
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
|
||||
|
||||
import { qrLandings } from "@/components/landing";
|
||||
import { LandingNotice } from "@/components/landing/shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||
import { getQrType } from "@/lib/qr/registry";
|
||||
import { isValidSlug } from "@/lib/slug";
|
||||
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ slug: string }> };
|
||||
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
|
||||
type PageProps = { params: Promise<{ slug: string[] }> };
|
||||
|
||||
/**
|
||||
* Öffentliche Landingpage eines dynamischen Codes.
|
||||
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
|
||||
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
|
||||
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
|
||||
*/
|
||||
async function loadCode(slug: string) {
|
||||
async function loadCode(abschnitte: string[] | undefined) {
|
||||
const slug = (abschnitte ?? []).join("/");
|
||||
if (!isValidSlug(slug)) return null;
|
||||
|
||||
const matched = await resolveRequestDomain(await headers());
|
||||
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
|
||||
if (!domain.isActive) return null;
|
||||
|
||||
return prisma.qrCode.findUnique({
|
||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
||||
});
|
||||
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
|
||||
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
|
||||
if (!code.slug) {
|
||||
return (
|
||||
<LandingNotice title="Diese Seite gibt es nicht">
|
||||
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
|
||||
der ihn erstellt hat.
|
||||
</LandingNotice>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Landing
|
||||
data={code.data}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardTitle,
|
||||
Field,
|
||||
Notice,
|
||||
TextInput,
|
||||
} from "@/components/ui";
|
||||
import { buildRamp, isHexColor } from "@/lib/branding-colors";
|
||||
import { UPLOAD_KINDS, formatBytes } from "@/lib/uploads.shared";
|
||||
|
||||
export type BrandingDto = {
|
||||
appName: string;
|
||||
footerText: string;
|
||||
accentColor: string;
|
||||
logoUploadId: string | null;
|
||||
faviconUploadId: string | null;
|
||||
};
|
||||
|
||||
/** Vorschläge, damit man nicht mit einem Farbwähler anfangen muss. */
|
||||
const FARBEN = [
|
||||
{ label: "Indigo", value: "#4F46E5" },
|
||||
{ label: "Blau", value: "#1D4ED8" },
|
||||
{ label: "Türkis", value: "#0D9488" },
|
||||
{ label: "Grün", value: "#15803D" },
|
||||
{ label: "Bernstein", value: "#B45309" },
|
||||
{ label: "Rot", value: "#DC2626" },
|
||||
{ label: "Pink", value: "#DB2777" },
|
||||
{ label: "Violett", value: "#7C3AED" },
|
||||
{ label: "Schiefer", value: "#475569" },
|
||||
] as const;
|
||||
|
||||
type BildFeld = "logoUploadId" | "faviconUploadId";
|
||||
|
||||
export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
|
||||
const [wert, setWert] = useState<BrandingDto>(initial);
|
||||
const [gespeichert, setGespeichert] = useState<BrandingDto>(initial);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
const logoInput = useRef<HTMLInputElement>(null);
|
||||
const faviconInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const geaendert = JSON.stringify(wert) !== JSON.stringify(gespeichert);
|
||||
const farbeGueltig = isHexColor(wert.accentColor);
|
||||
|
||||
async function hochladen(datei: File, kind: "logo" | "favicon", feld: BildFeld) {
|
||||
setFehler(null);
|
||||
setLaeuft(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", datei);
|
||||
const antwort = await fetch("/api/uploads", { method: "POST", body: form });
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Die Datei konnte nicht abgelegt werden.");
|
||||
}
|
||||
setWert((alt) => ({ ...alt, [feld]: rumpf.upload.id }));
|
||||
setHinweis(null);
|
||||
} catch (error) {
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
setLaeuft(true);
|
||||
try {
|
||||
const antwort = await fetch("/api/admin/branding", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(wert),
|
||||
});
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||
}
|
||||
setGespeichert(rumpf.branding);
|
||||
setWert(rumpf.branding);
|
||||
setHinweis(
|
||||
"Gespeichert. Die Kopfzeile zeigt es beim nächsten Seitenwechsel.",
|
||||
);
|
||||
} catch (error) {
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ramp = buildRamp(farbeGueltig ? wert.accentColor : "#4F46E5");
|
||||
const farbe = (stop: number) =>
|
||||
ramp.find((eintrag) => eintrag.stop === stop)?.value ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Steht in der Kopfzeile, im Titel des Browser-Tabs und in der Fußzeile.">
|
||||
Name
|
||||
</CardTitle>
|
||||
<div className="space-y-4">
|
||||
<Field label="Name der Anwendung" htmlFor="branding-name">
|
||||
<TextInput
|
||||
id="branding-name"
|
||||
value={wert.appName}
|
||||
maxLength={60}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, appName: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Zusatz in der Fußzeile"
|
||||
htmlFor="branding-footer"
|
||||
hint="Steht hinter dem Namen. Leer lassen, wenn nur der Name erscheinen soll."
|
||||
>
|
||||
<TextInput
|
||||
id="branding-footer"
|
||||
value={wert.footerText}
|
||||
maxLength={120}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, footerText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Übernommen werden Farbton und Sättigung. Die Helligkeitsstufen bleiben, wie sie sind — sonst würde weiße Schrift auf hellem Grund unlesbar.">
|
||||
Akzentfarbe
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FARBEN.map((eintrag) => (
|
||||
<button
|
||||
key={eintrag.value}
|
||||
type="button"
|
||||
disabled={laeuft}
|
||||
onClick={() => setWert({ ...wert, accentColor: eintrag.value })}
|
||||
aria-label={eintrag.label}
|
||||
aria-pressed={
|
||||
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||
}
|
||||
title={eintrag.label}
|
||||
className={`size-9 rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||
? "border-slate-900"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: eintrag.value }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end gap-4">
|
||||
<Field label="Eigener Wert" htmlFor="branding-accent">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Farbe wählen"
|
||||
value={farbeGueltig ? wert.accentColor : "#4F46E5"}
|
||||
disabled={laeuft}
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, accentColor: event.target.value.toUpperCase() })
|
||||
}
|
||||
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||
/>
|
||||
<TextInput
|
||||
id="branding-accent"
|
||||
value={wert.accentColor}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
disabled={laeuft}
|
||||
invalid={!farbeGueltig}
|
||||
className="w-32 font-mono"
|
||||
onChange={(event) =>
|
||||
setWert({ ...wert, accentColor: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
So sieht die Abstufung aus
|
||||
</span>
|
||||
<div className="flex overflow-hidden rounded-lg border border-slate-200">
|
||||
{ramp.map((eintrag) => (
|
||||
<span
|
||||
key={eintrag.stop}
|
||||
title={String(eintrag.stop)}
|
||||
className="size-7"
|
||||
style={{ backgroundColor: eintrag.value }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!farbeGueltig ? (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
Bitte einen Hex-Wert wie #4F46E5 angeben.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Zeigt, was die Farbe an den auffälligsten Stellen bewirkt. */}
|
||||
<div className="mt-5 rounded-xl border border-slate-200 p-4">
|
||||
<span className="mb-3 block text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Vorschau
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className="flex size-9 items-center justify-center rounded-lg text-white"
|
||||
style={{ backgroundColor: farbe(600) }}
|
||||
>
|
||||
<Icon name="qr" className="size-5" />
|
||||
</span>
|
||||
<span
|
||||
className="rounded-lg px-4 py-2.5 text-sm font-medium text-white"
|
||||
style={{ backgroundColor: farbe(600) }}
|
||||
>
|
||||
Dynamischen Code speichern
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: farbe(100), color: farbe(700) }}
|
||||
>
|
||||
Abzeichen
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: farbe(600) }}>
|
||||
Ein Link
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Ohne eigenes Logo erscheint das Zeichen der Anwendung samt Name.">
|
||||
Logo
|
||||
</CardTitle>
|
||||
<BildFeldZeile
|
||||
vorhanden={wert.logoUploadId}
|
||||
beschreibung={`PNG, JPEG, WebP oder AVIF, bis ${formatBytes(2 * 1024 * 1024)}. Wird auf 36 px Höhe gezeigt.`}
|
||||
accept={UPLOAD_KINDS.logo.accept}
|
||||
inputRef={logoInput}
|
||||
laeuft={laeuft}
|
||||
hintergrund="bg-surface"
|
||||
onWaehlen={(datei) => hochladen(datei, "logo", "logoUploadId")}
|
||||
onEntfernen={() => setWert({ ...wert, logoUploadId: null })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Das kleine Bild im Browser-Tab und in den Lesezeichen.">
|
||||
Favicon
|
||||
</CardTitle>
|
||||
<BildFeldZeile
|
||||
vorhanden={wert.faviconUploadId}
|
||||
beschreibung={`PNG, ICO oder WebP, bis ${formatBytes(512 * 1024)}. Am besten quadratisch, mindestens 32 × 32 px.`}
|
||||
accept={UPLOAD_KINDS.favicon.accept}
|
||||
inputRef={faviconInput}
|
||||
laeuft={laeuft}
|
||||
hintergrund="qr-canvas"
|
||||
onWaehlen={(datei) => hochladen(datei, "favicon", "faviconUploadId")}
|
||||
onEntfernen={() => setWert({ ...wert, faviconUploadId: null })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void speichern()} disabled={laeuft || !geaendert || !farbeGueltig}>
|
||||
{laeuft ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
{geaendert ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setWert(gespeichert);
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
}}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">Keine Änderungen offen.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BildFeldZeile({
|
||||
vorhanden,
|
||||
beschreibung,
|
||||
accept,
|
||||
inputRef,
|
||||
laeuft,
|
||||
hintergrund,
|
||||
onWaehlen,
|
||||
onEntfernen,
|
||||
}: {
|
||||
vorhanden: string | null;
|
||||
beschreibung: string;
|
||||
accept: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
laeuft: boolean;
|
||||
hintergrund: string;
|
||||
onWaehlen: (datei: File) => void;
|
||||
onEntfernen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span
|
||||
className={`flex h-16 w-24 shrink-0 items-center justify-center rounded-lg border border-slate-200 p-2 ${hintergrund}`}
|
||||
>
|
||||
{vorhanden ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||
Datenbank ausgeliefert */
|
||||
<img
|
||||
src={`/api/uploads/${vorhanden}`}
|
||||
alt="Vorschau"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">keins</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-600">{beschreibung}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={laeuft}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{vorhanden ? "Ersetzen" : "Datei wählen"}
|
||||
</Button>
|
||||
{vorhanden ? (
|
||||
<Button variant="ghost" size="sm" disabled={laeuft} onClick={onEntfernen}>
|
||||
Entfernen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const datei = event.target.files?.[0];
|
||||
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
|
||||
event.target.value = "";
|
||||
if (datei) onWaehlen(datei);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
import type { QrCodeDto } from "@/lib/dto";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import { describeSlugProblem } from "@/lib/slug";
|
||||
import type { QrFormData } from "@/lib/qr/type-module";
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
@@ -42,6 +43,8 @@ function formatShortDate(value: string | null): string {
|
||||
|
||||
type View = "list" | "cards";
|
||||
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
||||
/** Betriebsart, auf die die Liste eingeschränkt ist. */
|
||||
type ModeFilter = "alle" | "dynamic" | "static";
|
||||
|
||||
const SORT_LABELS: Record<SortKey, string> = {
|
||||
neueste: "Neueste zuerst",
|
||||
@@ -50,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
||||
scans: "Meiste Scans",
|
||||
};
|
||||
|
||||
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||
alle: "Alle Arten",
|
||||
dynamic: "Nur dynamische",
|
||||
static: "Nur statische",
|
||||
};
|
||||
|
||||
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||
|
||||
@@ -65,11 +74,19 @@ function displayName(code: QrCodeDto): string {
|
||||
return code.label?.trim() || code.summary;
|
||||
}
|
||||
|
||||
export function CodesList() {
|
||||
export function CodesList({
|
||||
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
|
||||
maySetSlug = true,
|
||||
}: {
|
||||
maySetSlug?: boolean;
|
||||
}) {
|
||||
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<QrCodeDto | null>(null);
|
||||
const [editSlug, setEditSlug] = useState("");
|
||||
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
|
||||
const [editAliases, setEditAliases] = useState<string[]>([]);
|
||||
const [editData, setEditData] = useState<QrFormData>({});
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
@@ -85,6 +102,7 @@ export function CodesList() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("neueste");
|
||||
const [groupFilter, setGroupFilter] = useState("alle");
|
||||
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
|
||||
|
||||
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
||||
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
||||
@@ -98,6 +116,14 @@ export function CodesList() {
|
||||
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||
};
|
||||
|
||||
const changeModeFilter = (next: ModeFilter) => {
|
||||
setModeFilter(next);
|
||||
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
|
||||
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
|
||||
// eine Reihenfolge an, die es dort gar nicht gibt.
|
||||
if (next === "static" && sort === "scans") setSort("neueste");
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||
@@ -124,9 +150,21 @@ export function CodesList() {
|
||||
setEditing(code);
|
||||
setEditData(code.data);
|
||||
setEditLabel(code.label ?? "");
|
||||
setEditSlug(code.slug ?? "");
|
||||
setEditAliases(code.aliases);
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const slugProblem =
|
||||
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||
const slugGeaendert =
|
||||
editing !== null &&
|
||||
editing.slug !== null &&
|
||||
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||
const freigegeben = editing
|
||||
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||
: [];
|
||||
|
||||
const EditForm = editing ? qrForms[editing.type] : undefined;
|
||||
const editValidation = useMemo(
|
||||
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
||||
@@ -139,12 +177,29 @@ export function CodesList() {
|
||||
setEditError(editValidation.message);
|
||||
return;
|
||||
}
|
||||
if (slugProblem) {
|
||||
setEditError(slugProblem);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
json: { data: editValidation.data, label: editLabel.trim() || null },
|
||||
json: {
|
||||
label: editLabel.trim() || null,
|
||||
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
|
||||
// gehören nicht in die Anfrage.
|
||||
...(editing.mode === "static"
|
||||
? {}
|
||||
: {
|
||||
data: editValidation.data,
|
||||
// Nur mitschicken, wenn es bearbeitet werden durfte.
|
||||
...(maySetSlug
|
||||
? { slug: editSlug.trim(), aliases: editAliases }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
setEditing(null);
|
||||
await load();
|
||||
@@ -179,6 +234,7 @@ export function CodesList() {
|
||||
};
|
||||
|
||||
const copy = async (code: QrCodeDto) => {
|
||||
if (!code.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.shortUrl);
|
||||
setCopiedId(code.id);
|
||||
@@ -202,11 +258,25 @@ export function CodesList() {
|
||||
);
|
||||
}, [codes]);
|
||||
|
||||
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
|
||||
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
|
||||
// Arten in der Liste stehen.
|
||||
const kinds = useMemo(() => {
|
||||
let dynamisch = false;
|
||||
let statisch = false;
|
||||
for (const code of codes ?? []) {
|
||||
if (code.mode === "static") statisch = true;
|
||||
else dynamisch = true;
|
||||
}
|
||||
return { dynamisch, statisch, beide: dynamisch && statisch };
|
||||
}, [codes]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const filtered = (codes ?? []).filter((code) => {
|
||||
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
||||
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
|
||||
if (!needle) return true;
|
||||
return [
|
||||
displayName(code),
|
||||
@@ -234,7 +304,7 @@ export function CodesList() {
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}, [codes, query, sort, groupFilter]);
|
||||
}, [codes, query, sort, groupFilter, modeFilter]);
|
||||
|
||||
/* ---------------------------------------------------------------- View */
|
||||
|
||||
@@ -248,6 +318,10 @@ export function CodesList() {
|
||||
const svgHref = (code: QrCodeDto) =>
|
||||
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
||||
|
||||
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
|
||||
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
|
||||
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
|
||||
|
||||
const toolbar = codes.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1 basis-56">
|
||||
@@ -281,23 +355,42 @@ export function CodesList() {
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
{kinds.beide ? (
|
||||
<Select
|
||||
aria-label="Nach Art filtern"
|
||||
className="!w-auto"
|
||||
value={modeFilter}
|
||||
onChange={(event) =>
|
||||
changeModeFilter(event.target.value as ModeFilter)
|
||||
}
|
||||
>
|
||||
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{MODE_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
aria-label="Sortierung"
|
||||
className="!w-auto"
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||
>
|
||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||
.filter((key) => key !== "scans" || !nurStatische)
|
||||
.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Ansicht"
|
||||
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5"
|
||||
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
@@ -316,7 +409,7 @@ export function CodesList() {
|
||||
className={cx(
|
||||
"rounded-md p-2 transition-colors",
|
||||
view === value
|
||||
? "bg-indigo-50 text-indigo-700"
|
||||
? "bg-accent-soft text-accent-soft-fg"
|
||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
||||
)}
|
||||
>
|
||||
@@ -367,14 +460,15 @@ export function CodesList() {
|
||||
{codes.length > 0 && visible.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Kein Code passt zur Suche.{" "}
|
||||
Kein Code passt zu Suche und Filtern.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setGroupFilter("alle");
|
||||
setModeFilter("alle");
|
||||
}}
|
||||
className="font-medium text-indigo-600 underline"
|
||||
className="font-medium text-accent-text underline"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
@@ -384,7 +478,7 @@ export function CodesList() {
|
||||
|
||||
{/* ------------------------------------------------------ Listenansicht */}
|
||||
{view === "list" && visible.length > 0 ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
|
||||
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
|
||||
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
|
||||
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
|
||||
@@ -407,9 +501,11 @@ export function CodesList() {
|
||||
Gruppe
|
||||
</th>
|
||||
) : null}
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
{nurStatische ? null : (
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||
@@ -449,9 +545,18 @@ export function CodesList() {
|
||||
<div className="truncate text-xs text-slate-500">
|
||||
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
||||
gleich für alle Zeilen, der Slug unterscheidet. */}
|
||||
<span className="font-mono md:hidden" title={code.shortUrl}>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
{code.shortUrl ? (
|
||||
<span
|
||||
className="font-mono md:hidden"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="md:hidden">
|
||||
{qrTypes[code.type]?.title ?? code.type} · statisch
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden md:inline">
|
||||
{qrTypes[code.type]?.title ?? code.type}
|
||||
</span>
|
||||
@@ -461,28 +566,34 @@ export function CodesList() {
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-indigo-600 hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
|
||||
// steckt in ihm selbst.
|
||||
<Badge tone="neutral">statisch</Badge>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 sm:table-cell">
|
||||
@@ -497,7 +608,13 @@ export function CodesList() {
|
||||
{code.target.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block truncate text-red-600" title={code.summary}>
|
||||
<span
|
||||
className={cx(
|
||||
"block truncate",
|
||||
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||
)}
|
||||
title={code.summary}
|
||||
>
|
||||
{code.summary}
|
||||
</span>
|
||||
)}
|
||||
@@ -511,9 +628,20 @@ export function CodesList() {
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.scans}
|
||||
</td>
|
||||
{nurStatische ? null : (
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.mode === "static" ? (
|
||||
<span
|
||||
className="font-normal text-slate-400"
|
||||
title="Statische Codes werden nicht gezählt"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td
|
||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||
@@ -539,8 +667,8 @@ export function CodesList() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(code)}
|
||||
title="Ziel bearbeiten"
|
||||
aria-label={`Ziel von ${displayName(code)} bearbeiten`}
|
||||
title="Bearbeiten"
|
||||
aria-label={`${displayName(code)} bearbeiten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="pencil" className="size-4" />
|
||||
@@ -610,30 +738,41 @@ export function CodesList() {
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-indigo-600 hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
) : (
|
||||
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
|
||||
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Art:</dt>
|
||||
<dd className="text-slate-800">
|
||||
statisch — Inhalt steckt im Code, nicht änderbar
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Ziel:</dt>
|
||||
@@ -670,7 +809,7 @@ export function CodesList() {
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="star" className="size-3.5" />
|
||||
Rückmeldungen
|
||||
@@ -683,18 +822,18 @@ export function CodesList() {
|
||||
onClick={() => openEdit(code)}
|
||||
>
|
||||
<Icon name="pencil" className="size-3.5" />
|
||||
Ziel bearbeiten
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
PNG
|
||||
</a>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
SVG
|
||||
@@ -721,7 +860,7 @@ export function CodesList() {
|
||||
{/* ------------------------------------------------------ Bearbeiten */}
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
title="Ziel bearbeiten"
|
||||
title="Code bearbeiten"
|
||||
onClose={() => setEditing(null)}
|
||||
footer={
|
||||
<>
|
||||
@@ -742,15 +881,12 @@ export function CodesList() {
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
|
||||
auf{" "}
|
||||
<code className="font-mono text-xs break-all">
|
||||
{editing.shortUrl}
|
||||
</code>
|
||||
.
|
||||
{editing.mode === "static"
|
||||
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||
</p>
|
||||
|
||||
{EditForm ? (
|
||||
{editing.mode === "static" ? null : EditForm ? (
|
||||
<EditForm
|
||||
value={editData}
|
||||
onChange={(value) => {
|
||||
@@ -766,6 +902,95 @@ export function CodesList() {
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{maySetSlug && editing.mode !== "static" ? (
|
||||
<>
|
||||
<Field
|
||||
label="Kurz-Link"
|
||||
htmlFor="edit-slug"
|
||||
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||
>
|
||||
<TextInput
|
||||
id="edit-slug"
|
||||
value={editSlug}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
invalid={Boolean(slugProblem)}
|
||||
disabled={saving}
|
||||
onChange={(event) => {
|
||||
setEditSlug(event.target.value);
|
||||
setEditError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{slugProblem ? (
|
||||
<Notice tone="error">{slugProblem}</Notice>
|
||||
) : slugGeaendert ? (
|
||||
<Notice tone="info">
|
||||
Der bisherige Kurz-Link{" "}
|
||||
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
|
||||
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
|
||||
lässt er sich später freigeben.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{editing.aliases.length > 0 ? (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
Frühere Kurz-Links
|
||||
</span>
|
||||
<p className="mb-2 text-sm text-slate-500">
|
||||
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
|
||||
nichts gedruckt ist — die Adresse ist danach sofort tot.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{editing.aliases.map((alias) => {
|
||||
const behalten = editAliases.includes(alias);
|
||||
return (
|
||||
<li
|
||||
key={alias}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
|
||||
>
|
||||
<code
|
||||
className={
|
||||
behalten
|
||||
? "min-w-0 font-mono text-xs break-all text-slate-700"
|
||||
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
|
||||
}
|
||||
>
|
||||
/r/{alias}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant={behalten ? "ghost" : "secondary"}
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setEditAliases((alt) =>
|
||||
behalten
|
||||
? alt.filter((eintrag) => eintrag !== alias)
|
||||
: [...alt, alias],
|
||||
)
|
||||
}
|
||||
>
|
||||
{behalten ? "Freigeben" : "Doch behalten"}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{freigegeben.length > 0 ? (
|
||||
<Notice tone="warning">
|
||||
{freigegeben.length === 1
|
||||
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
|
||||
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
|
||||
</Notice>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Field label="Name (optional)" htmlFor="edit-label">
|
||||
<TextInput
|
||||
id="edit-label"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { FileField } from "@/components/qr-forms/file-field";
|
||||
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
||||
import { evaluateContrast } from "@/lib/qr/contrast";
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
EC_LEVELS,
|
||||
EC_LEVEL_LABELS,
|
||||
LOGO_SCALE_WARN,
|
||||
MAX_LOGO_SCALE,
|
||||
MAX_SIZE,
|
||||
MIN_LOGO_SCALE,
|
||||
MIN_SIZE,
|
||||
type EcLevel,
|
||||
type LogoSource,
|
||||
type QrDesign,
|
||||
} from "@/lib/qr/design";
|
||||
|
||||
@@ -40,7 +45,7 @@ function ColorField({
|
||||
aria-label={`${label} auswählen`}
|
||||
value={valid ? value : "#000000"}
|
||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
|
||||
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||
/>
|
||||
<TextInput
|
||||
id={id}
|
||||
@@ -62,9 +67,12 @@ function ColorField({
|
||||
export function DesignPanel({
|
||||
design,
|
||||
onChange,
|
||||
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
|
||||
hasBrandingLogo = false,
|
||||
}: {
|
||||
design: QrDesign;
|
||||
onChange: (design: QrDesign) => void;
|
||||
hasBrandingLogo?: boolean;
|
||||
}) {
|
||||
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
||||
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
||||
@@ -95,7 +103,7 @@ export function DesignPanel({
|
||||
onClick={() =>
|
||||
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
|
||||
}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded border border-slate-300"
|
||||
@@ -111,7 +119,7 @@ export function DesignPanel({
|
||||
onClick={() =>
|
||||
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
|
||||
}
|
||||
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Farben tauschen
|
||||
</button>
|
||||
@@ -158,14 +166,18 @@ export function DesignPanel({
|
||||
if (Number.isNaN(raw)) return;
|
||||
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
|
||||
}}
|
||||
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
||||
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Fehlerkorrektur"
|
||||
hint={EC_LEVEL_LABELS[design.ecLevel]}
|
||||
hint={
|
||||
design.logoSource === null
|
||||
? EC_LEVEL_LABELS[design.ecLevel]
|
||||
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -177,13 +189,16 @@ export function DesignPanel({
|
||||
key={level}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={design.ecLevel === level}
|
||||
aria-checked={
|
||||
design.logoSource === null ? design.ecLevel === level : level === "H"
|
||||
}
|
||||
disabled={design.logoSource !== null}
|
||||
onClick={() => patch({ ecLevel: level })}
|
||||
className={cx(
|
||||
"rounded-lg border py-2 text-sm font-medium transition-colors",
|
||||
design.ecLevel === level
|
||||
? "border-indigo-600 bg-indigo-50 text-indigo-700"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
|
||||
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
(design.logoSource === null ? design.ecLevel : "H") === level
|
||||
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
@@ -208,6 +223,147 @@ export function DesignPanel({
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<LogoAuswahl
|
||||
design={design}
|
||||
patch={patch}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- Logo */
|
||||
|
||||
/**
|
||||
* Das Bild in der Mitte des Codes.
|
||||
*
|
||||
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
|
||||
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
|
||||
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
|
||||
*/
|
||||
function LogoAuswahl({
|
||||
design,
|
||||
patch,
|
||||
hasBrandingLogo,
|
||||
}: {
|
||||
design: QrDesign;
|
||||
patch: (changes: Partial<QrDesign>) => void;
|
||||
hasBrandingLogo: boolean;
|
||||
}) {
|
||||
const an = design.logoSource !== null;
|
||||
|
||||
const waehle = (quelle: LogoSource | null) => {
|
||||
if (quelle === null) {
|
||||
patch({ logoSource: null, logoUploadId: null });
|
||||
} else if (quelle === "branding") {
|
||||
patch({ logoSource: "branding", logoUploadId: null });
|
||||
} else {
|
||||
patch({ logoSource: "upload" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="border-t border-slate-200 pt-4">
|
||||
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Logo in der Mitte
|
||||
</legend>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={an}
|
||||
onChange={(event) =>
|
||||
waehle(
|
||||
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
|
||||
)
|
||||
}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Ein Logo in die Mitte des Codes setzen
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{an ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
{hasBrandingLogo ? (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Herkunft des Logos"
|
||||
className="space-y-2"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["branding", "Logo dieser Installation"],
|
||||
["upload", "Eigenes Logo hochladen"],
|
||||
] as [LogoSource, string][]
|
||||
).map(([quelle, text]) => (
|
||||
<label
|
||||
key={quelle}
|
||||
className="flex cursor-pointer items-center gap-2.5"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="logo-quelle"
|
||||
checked={design.logoSource === quelle}
|
||||
onChange={() => waehle(quelle)}
|
||||
className="size-4 shrink-0 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{design.logoSource === "upload" ? (
|
||||
<div className="space-y-2">
|
||||
<FileField
|
||||
kind="qrlogo"
|
||||
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
|
||||
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
|
||||
/>
|
||||
{design.logoUploadId ? null : (
|
||||
<p className="text-sm text-amber-700">
|
||||
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={`Größe: ${design.logoScale} % der Kantenlänge`}
|
||||
htmlFor="qr-logo-scale"
|
||||
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
|
||||
>
|
||||
<input
|
||||
id="qr-logo-scale"
|
||||
type="range"
|
||||
min={MIN_LOGO_SCALE}
|
||||
max={MAX_LOGO_SCALE}
|
||||
step={1}
|
||||
value={design.logoScale}
|
||||
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{design.logoScale > LOGO_SCALE_WARN ? (
|
||||
<Notice tone="warning">
|
||||
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
|
||||
zwar, aber manche Scanner-Apps suchen in der Mitte nach
|
||||
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
|
||||
bevor er in den Druck geht.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
|
||||
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
|
||||
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { DomainBericht } from "@/components/domain-bericht";
|
||||
import { DomainHilfe } from "@/components/domain-hilfe";
|
||||
import { HelpButton } from "@/components/help-button";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import {
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
TextInput,
|
||||
} from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
import type { Pruefbericht } from "@/lib/domain-check";
|
||||
|
||||
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
||||
|
||||
@@ -44,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
|
||||
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
||||
|
||||
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
|
||||
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiJson<{
|
||||
@@ -122,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const check = async (domain: AdminDomain) => {
|
||||
setChecking(domain.id);
|
||||
setError(null);
|
||||
try {
|
||||
const { report } = await apiJson<{ report: Pruefbericht }>(
|
||||
`/api/admin/domains/${domain.id}/check`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Prüfung ließ sich nicht ausführen.",
|
||||
);
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (domains === null) {
|
||||
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
||||
}
|
||||
@@ -134,10 +162,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
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.
|
||||
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
|
||||
einzurichten ist, steht in der Kurzhilfe.
|
||||
</Notice>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
|
||||
<DomainHilfe baseUrlHostname={baseUrlHostname} />
|
||||
</HelpButton>
|
||||
<Button type="button" onClick={() => setCreating(true)}>
|
||||
Domain registrieren
|
||||
</Button>
|
||||
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
<Icon name="pencil" className="size-3.5" />
|
||||
Gruppen zuteilen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={checking !== null}
|
||||
onClick={() => void check(domain)}
|
||||
>
|
||||
<Icon name="scan" className="size-3.5" />
|
||||
{checking === domain.id ? "Prüft …" : "Prüfen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{reports[domain.id] ? (
|
||||
<DomainBericht bericht={reports[domain.id]} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { cx } from "@/components/ui";
|
||||
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
|
||||
|
||||
/**
|
||||
* Das Ergebnis eines Domain-Checks.
|
||||
*
|
||||
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
|
||||
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
|
||||
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
|
||||
*/
|
||||
|
||||
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
|
||||
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
|
||||
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
|
||||
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
|
||||
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
|
||||
};
|
||||
|
||||
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
|
||||
ok: "border-emerald-300 bg-emerald-50/60",
|
||||
warnung: "border-amber-300 bg-amber-50/60",
|
||||
fehler: "border-red-300 bg-red-50/60",
|
||||
};
|
||||
|
||||
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
|
||||
ok: "Die Domain führt hierher.",
|
||||
warnung: "Es läuft, aber etwas will beachtet werden.",
|
||||
fehler: "So kommt niemand hier an.",
|
||||
};
|
||||
|
||||
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
|
||||
return (
|
||||
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{FAZIT[bericht.gesamt]}
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{bericht.schritte.map((schritt) => {
|
||||
const zeichen = SYMBOLE[schritt.status];
|
||||
return (
|
||||
<li key={schritt.name} className="flex items-start gap-2 text-sm">
|
||||
<Icon
|
||||
name={zeichen.icon}
|
||||
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
|
||||
/>
|
||||
<span className="sr-only">{zeichen.wort}: </span>
|
||||
<span className="w-24 shrink-0 font-medium text-slate-700">
|
||||
{schritt.titel}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-slate-600">
|
||||
{schritt.detail}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Aufgerufen wurde{" "}
|
||||
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
|
||||
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
|
||||
Prüfung scheitern, obwohl von außen alles stimmt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { HelpCode, HelpStep } from "@/components/help-button";
|
||||
|
||||
/**
|
||||
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
|
||||
*
|
||||
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
|
||||
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
|
||||
* einen Eintrag anlegt, steht in dessen Handbuch besser.
|
||||
*/
|
||||
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||
const beispiel = baseUrlHostname || "qr.example.com";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p>
|
||||
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
|
||||
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
|
||||
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
|
||||
Zertifikat.
|
||||
</p>
|
||||
|
||||
<ol className="space-y-4">
|
||||
<HelpStep n={1} title="DNS-Eintrag anlegen">
|
||||
<p>
|
||||
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
|
||||
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
|
||||
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
|
||||
auch.
|
||||
</p>
|
||||
<p>
|
||||
Steht der Name nur im internen DNS oder ist er nur über VPN
|
||||
erreichbar, funktionieren gedruckte Codes{" "}
|
||||
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
|
||||
Verpackungen muss er öffentlich auflösbar sein.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
|
||||
<p>
|
||||
Für die Domain einen eigenen Eintrag anlegen, der auf diese
|
||||
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
|
||||
<code className="font-mono text-xs">3000</code>, oder im selben
|
||||
Docker-Netz der Servicename{" "}
|
||||
<code className="font-mono text-xs">app</code>.
|
||||
</p>
|
||||
<p>
|
||||
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
|
||||
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
|
||||
braucht sie im Serverblock:
|
||||
</p>
|
||||
<HelpCode>{`proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
|
||||
<p>
|
||||
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
|
||||
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
|
||||
Zweitdomain werden nicht gefunden.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={3} title="Zertifikat anfordern">
|
||||
<p>
|
||||
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
|
||||
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
|
||||
Zertifikat selbst, sobald der DNS-Eintrag steht.
|
||||
</p>
|
||||
<p>
|
||||
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
|
||||
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
|
||||
aus.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={4} title="Domain hier registrieren">
|
||||
<p>
|
||||
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
|
||||
genau so, wie er im Browser steht, ohne{" "}
|
||||
<code className="font-mono text-xs">https://</code> und ohne Pfad:
|
||||
</p>
|
||||
<HelpCode>{beispiel}</HelpCode>
|
||||
<p>
|
||||
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
|
||||
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
|
||||
lässt sich also keine fremde Domain ansprechen.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={5} title="Einer Gruppe zuteilen">
|
||||
<p>
|
||||
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
|
||||
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
|
||||
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
|
||||
Domain ist die, die beim Anlegen vorausgewählt ist.
|
||||
</p>
|
||||
</HelpStep>
|
||||
|
||||
<HelpStep n={6} title="Nachprüfen">
|
||||
<p>
|
||||
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
|
||||
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
|
||||
hinter der Domain wirklich diese Installation steht.
|
||||
</p>
|
||||
<p>
|
||||
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
|
||||
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
|
||||
stimmt. Von einem fremden Netz aus hilft dann:
|
||||
</p>
|
||||
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||
<p>
|
||||
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
|
||||
auswählen und den fertigen Code einmal mit dem Handy scannen — das
|
||||
ist die einzige Prüfung, die wirklich zählt.
|
||||
</p>
|
||||
</HelpStep>
|
||||
</ol>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
|
||||
</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
Die Umgebungsvariable{" "}
|
||||
<code className="font-mono text-xs">BASE_URL</code> ist die
|
||||
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
|
||||
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
|
||||
<code className="font-mono text-xs">http</code> oder{" "}
|
||||
<code className="font-mono text-xs">https</code> gebildet werden.
|
||||
Weitere Domains werden hier registriert, nicht dort eingetragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
|
||||
* aufklappt.
|
||||
*
|
||||
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
|
||||
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
|
||||
* würde die Seite für alle anderen Aufrufe zumüllen.
|
||||
*/
|
||||
export function HelpButton({
|
||||
title,
|
||||
label = "Hilfe",
|
||||
children,
|
||||
}: {
|
||||
/** Überschrift des Dialogs. */
|
||||
title: string;
|
||||
/** Beschriftung des Knopfes. */
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Icon name="help" className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
size="lg"
|
||||
title={title}
|
||||
onClose={() => setOpen(false)}
|
||||
footer={
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Verstanden
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
|
||||
{children}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Bausteine für den Inhalt */
|
||||
|
||||
/** Ein nummerierter Schritt mit Überschrift. */
|
||||
export function HelpStep({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
n: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex gap-3">
|
||||
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
|
||||
export function HelpCode({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -214,12 +214,37 @@ const PATHS = {
|
||||
<path d="M4 12h16" />
|
||||
</>
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
|
||||
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
|
||||
</>
|
||||
),
|
||||
moon: (
|
||||
<>
|
||||
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
|
||||
</>
|
||||
),
|
||||
monitor: (
|
||||
<>
|
||||
<rect x="2.5" y="4" width="19" height="13" rx="2" />
|
||||
<path d="M9 21h6M12 17v4" />
|
||||
</>
|
||||
),
|
||||
lock: (
|
||||
<>
|
||||
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
|
||||
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
||||
</>
|
||||
),
|
||||
help: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
|
||||
<path d="M11.75 16.75h.01" />
|
||||
</>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
|
||||
<fieldset>
|
||||
<legend className="text-center text-base font-medium text-slate-900">
|
||||
{question}
|
||||
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
value={kommentar}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => setKommentar(event.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
|
||||
{images.map((image, index) => (
|
||||
<figure
|
||||
key={image.fileId}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
||||
|
||||
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
|
||||
href={link.url}
|
||||
// Fremde Ziele: kein Zugriff auf dieses Fenster.
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 font-medium break-words">
|
||||
{link.label}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
|
||||
title={title || filename || "PDF-Dokument"}
|
||||
subtitle={description || null}
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
||||
<Icon name="document" className="size-6" />
|
||||
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
|
||||
</a>
|
||||
<a
|
||||
href={`${href}?download`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-4" />
|
||||
Herunterladen
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
|
||||
<a
|
||||
href={profile.url}
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
|
||||
<a
|
||||
href={url}
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
|
||||
<Icon name="video" className="size-5" />
|
||||
|
||||
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGestartet(true)}
|
||||
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
|
||||
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-scrim px-6 text-center shadow-sm transition-colors hover:brightness-125"
|
||||
>
|
||||
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
|
||||
<Icon name="video" className="size-8" />
|
||||
</span>
|
||||
<span className="font-medium text-white">Video abspielen</span>
|
||||
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
|
||||
<span className="max-w-xs text-xs leading-relaxed text-white/75">
|
||||
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
|
||||
werden Daten an den Anbieter übertragen.
|
||||
</span>
|
||||
|
||||
@@ -9,12 +9,15 @@ export function Modal({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
size = "md",
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** `lg` für längere Texte wie eine Kurzhilfe. */
|
||||
size?: "md" | "lg";
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
@@ -39,7 +42,9 @@ export function Modal({
|
||||
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
||||
if (event.target === ref.current) onClose();
|
||||
}}
|
||||
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40"
|
||||
className={`m-auto w-[calc(100%-2rem)] ${
|
||||
size === "lg" ? "max-w-2xl" : "max-w-lg"
|
||||
} rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`}
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
<img
|
||||
src={`/api/uploads/${image.fileId}`}
|
||||
alt=""
|
||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
|
||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="truncate text-sm text-slate-600">
|
||||
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`Bild ${index + 1} nach oben`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||
</button>
|
||||
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === images.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`Bild ${index + 1} nach unten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||
</button>
|
||||
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Bild ${index + 1} entfernen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`Link ${index + 1} nach oben`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||
</button>
|
||||
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || index === links.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`Link ${index + 1} nach unten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||
</button>
|
||||
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
disabled={disabled || links.length === 1}
|
||||
onClick={() => setLinks(links.filter((_, i) => i !== index))}
|
||||
aria-label={`Link ${index + 1} entfernen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
onChange({ ...value, fileId: "", filename: "", size: 0 })
|
||||
}
|
||||
aria-label="Datei entfernen"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
setProfiles(profiles.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Profil ${index + 1} entfernen`}
|
||||
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
onClick={() =>
|
||||
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
|
||||
}
|
||||
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
className="mt-2 rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
https:// voranstellen
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
|
||||
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
|
||||
import { describeSlugProblem } from "@/lib/slug";
|
||||
|
||||
type PreviewState = { dataUrl: string; content: string };
|
||||
|
||||
@@ -42,7 +43,32 @@ function sameData(a: unknown, b: unknown): boolean {
|
||||
return stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
|
||||
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */
|
||||
type Dublette = { id: string; label: string | null; createdAt: string };
|
||||
|
||||
/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */
|
||||
function leseDublette(details: unknown): Dublette | null {
|
||||
if (details === null || typeof details !== "object") return null;
|
||||
const feld = (details as { duplicate?: unknown }).duplicate;
|
||||
if (feld === null || typeof feld !== "object") return null;
|
||||
const { id, label, createdAt } = feld as Record<string, unknown>;
|
||||
if (typeof id !== "string" || typeof createdAt !== "string") return null;
|
||||
return { id, label: typeof label === "string" ? label : null, createdAt };
|
||||
}
|
||||
|
||||
const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
|
||||
|
||||
export function QrGenerator({
|
||||
typeId,
|
||||
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||
maySetSlug = true,
|
||||
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
|
||||
hasBrandingLogo = false,
|
||||
}: {
|
||||
typeId: QrTypeId;
|
||||
maySetSlug?: boolean;
|
||||
hasBrandingLogo?: boolean;
|
||||
}) {
|
||||
const qrType = qrTypes[typeId];
|
||||
const Form = qrForms[typeId];
|
||||
|
||||
@@ -51,10 +77,20 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
|
||||
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
|
||||
const [label, setLabel] = useState("");
|
||||
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
|
||||
const [wunschSlug, setWunschSlug] = useState("");
|
||||
|
||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
/**
|
||||
* Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der
|
||||
* Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne
|
||||
* einen Effekt, der sie hinterherräumt.
|
||||
*/
|
||||
const [duplicate, setDuplicate] = useState<
|
||||
{ treffer: Dublette; inhalt: string } | null
|
||||
>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
@@ -74,11 +110,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
const dataError = !validation.ok && touched ? validation.message : null;
|
||||
|
||||
const mode = isDynamic ? "dynamic" : "static";
|
||||
const slug = isDynamic ? saved?.slug : undefined;
|
||||
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
|
||||
|
||||
const requestBody = useMemo(
|
||||
() => ({ type: typeId, data, design, mode, slug }),
|
||||
[typeId, data, design, mode, slug],
|
||||
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
|
||||
// Herunterladen. Auf die Vorschau hat er keinen Einfluss.
|
||||
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
|
||||
[typeId, data, design, mode, slug, label],
|
||||
);
|
||||
|
||||
const ready = validation.ok && designValid;
|
||||
@@ -164,22 +202,40 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
|
||||
/* ----------------------------------------------- Dynamischen Code speichern */
|
||||
|
||||
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
|
||||
// ein Zeichen nicht erlaubt ist.
|
||||
const slugProblem =
|
||||
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
|
||||
|
||||
const hasUnsavedChanges =
|
||||
saved !== null &&
|
||||
(!sameData(saved.data, validation.ok ? validation.data : data) ||
|
||||
!sameData(saved.design, design) ||
|
||||
(saved.label ?? "") !== label.trim());
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!validation.ok || !designValid) return;
|
||||
// Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
|
||||
const dublette =
|
||||
duplicate !== null &&
|
||||
duplicate.inhalt === stableStringify(validation.ok ? validation.data : data)
|
||||
? duplicate.treffer
|
||||
: null;
|
||||
|
||||
const handleSave = useCallback(async (trotzDublette = false) => {
|
||||
if (!validation.ok || !designValid || slugProblem) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setDuplicate(null);
|
||||
try {
|
||||
const body = {
|
||||
type: typeId,
|
||||
data: validation.data,
|
||||
design,
|
||||
mode,
|
||||
label: label.trim() || undefined,
|
||||
// Ein statischer Code hat keinen Kurz-Link.
|
||||
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
|
||||
allowDuplicate: trotzDublette,
|
||||
};
|
||||
const result = saved
|
||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||
@@ -192,18 +248,40 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
});
|
||||
setSaved(result.code);
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gespeichert werden.",
|
||||
);
|
||||
// Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
|
||||
const gemeldet =
|
||||
error instanceof ApiError && error.status === 409
|
||||
? leseDublette(error.details)
|
||||
: null;
|
||||
if (gemeldet) {
|
||||
setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) });
|
||||
} else {
|
||||
setSaveError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gespeichert werden.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [validation, designValid, typeId, design, label, saved]);
|
||||
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
|
||||
|
||||
/**
|
||||
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
|
||||
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
|
||||
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
|
||||
* dazu, dass die Oberfläche den falschen Zustand zeigt.
|
||||
*/
|
||||
const wechsleModus = useCallback((dynamisch: boolean) => {
|
||||
setIsDynamic(dynamisch);
|
||||
setSaved(null);
|
||||
setSaveError(null);
|
||||
setDuplicate(null);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!saved) return;
|
||||
if (!saved?.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(saved.shortUrl);
|
||||
setCopied(true);
|
||||
@@ -226,7 +304,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</Link>
|
||||
|
||||
<div className="mb-7 flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||
<Icon name={qrType.icon} className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
@@ -286,12 +364,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
data-mode="static"
|
||||
aria-pressed={!isDynamic}
|
||||
disabled={!qrType.supportsStatic}
|
||||
onClick={() => setIsDynamic(false)}
|
||||
onClick={() => wechsleModus(false)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
? "border-indigo-600 bg-accent-soft/60"
|
||||
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
@@ -310,12 +388,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
data-mode="dynamic"
|
||||
aria-pressed={isDynamic}
|
||||
disabled={!qrType.supportsDynamic}
|
||||
onClick={() => setIsDynamic(true)}
|
||||
onClick={() => wechsleModus(true)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
? "border-indigo-600 bg-accent-soft/60"
|
||||
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
@@ -331,12 +409,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
htmlFor="code-label"
|
||||
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||
hint={
|
||||
isDynamic
|
||||
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
id="code-label"
|
||||
@@ -347,13 +428,92 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{saved ? (
|
||||
{!isDynamic || saved || !maySetSlug ? null : (
|
||||
<Field
|
||||
label="Kurz-Link (optional)"
|
||||
htmlFor="code-slug"
|
||||
error={slugProblem}
|
||||
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||
>
|
||||
<TextInput
|
||||
id="code-slug"
|
||||
value={wunschSlug}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
invalid={Boolean(slugProblem)}
|
||||
placeholder="z. B. sommeraktion oder kunde-a/flyer"
|
||||
onChange={(event) => setWunschSlug(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isDynamic ? (
|
||||
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
|
||||
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
|
||||
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
|
||||
von vorher; dann wird sie wieder angeboten. */
|
||||
saved && !hasUnsavedChanges ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Abgelegt.</span> Du findest
|
||||
den Code jetzt unter „Meine Codes“ und kannst ihn dort
|
||||
erneut herunterladen. Der Code selbst ändert sich dadurch
|
||||
nicht — sein Inhalt steckt in ihm.
|
||||
</Notice>
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||
>
|
||||
Zu „Meine Codes“
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{dublette ? (
|
||||
<Notice tone="warning">
|
||||
<span className="font-medium">
|
||||
Diesen Inhalt hast du schon abgelegt
|
||||
</span>{" "}
|
||||
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
|
||||
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
|
||||
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
|
||||
<span className="mt-1 block text-xs">
|
||||
Verglichen wird nur der Inhalt, nicht die Gestaltung —
|
||||
derselbe Code in anderen Farben gilt hier als Dublette.
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
|
||||
>
|
||||
Den vorhandenen ansehen
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</span>
|
||||
</Notice>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void handleSave(dublette !== null)}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving
|
||||
? "Legt ab …"
|
||||
: dublette
|
||||
? "Trotzdem ablegen"
|
||||
: "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : saved ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Code gespeichert.</span> Der
|
||||
QR-Code zeigt dauerhaft auf diese Kurz-URL:
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
|
||||
<code className="rounded bg-surface/70 px-2 py-1 font-mono text-xs break-all">
|
||||
{saved.shortUrl}
|
||||
</code>
|
||||
<Button
|
||||
@@ -375,7 +535,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
{hasUnsavedChanges ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Speichert …" : "Änderungen speichern"}
|
||||
@@ -383,7 +543,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
) : null}
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||
>
|
||||
Zu „Meine Codes“
|
||||
<Icon name="external" className="size-3.5" />
|
||||
@@ -407,8 +567,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
</Notice>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready || Boolean(slugProblem)}
|
||||
>
|
||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||
</Button>
|
||||
@@ -416,8 +576,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
)}
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -426,7 +585,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||
Gestaltung
|
||||
</CardTitle>
|
||||
<DesignPanel design={design} onChange={setDesign} />
|
||||
<DesignPanel
|
||||
design={design}
|
||||
onChange={setDesign}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function QrPreview({
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<span className="absolute top-3 right-3 rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
||||
<span className="absolute top-3 right-3 rounded-full bg-surface/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
||||
aktualisiert …
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Card, CardTitle, Checkbox, Notice } from "@/components/ui";
|
||||
|
||||
export type SettingsDto = {
|
||||
editorsMaySetSlug: boolean;
|
||||
};
|
||||
|
||||
export function SettingsAdmin({ initial }: { initial: SettingsDto }) {
|
||||
const [wert, setWert] = useState(initial);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
// Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem
|
||||
// zweiten Klick wirkt, ist eine Stolperfalle.
|
||||
const umschalten = async (aktiv: boolean) => {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
setHinweis(null);
|
||||
const vorher = wert;
|
||||
setWert({ ...wert, editorsMaySetSlug: aktiv });
|
||||
try {
|
||||
const antwort = await fetch("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ editorsMaySetSlug: aktiv }),
|
||||
});
|
||||
const rumpf = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||
}
|
||||
setWert(rumpf.settings);
|
||||
setHinweis(
|
||||
aktiv
|
||||
? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern."
|
||||
: "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.",
|
||||
);
|
||||
} catch (error) {
|
||||
setWert(vorher);
|
||||
setFehler(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
|
||||
Kurz-Links
|
||||
</CardTitle>
|
||||
|
||||
<Checkbox
|
||||
id="editors-may-set-slug"
|
||||
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
|
||||
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
|
||||
checked={wert.editorsMaySetSlug}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => void umschalten(event.target.checked)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-slate-600">
|
||||
<p>
|
||||
Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten
|
||||
Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten —
|
||||
die vorherige Adresse bleibt zwar gültig, lässt sich aber auch
|
||||
wieder freigeben.
|
||||
</p>
|
||||
<p>
|
||||
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
|
||||
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
|
||||
die Freigabe unbedenklich.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { Icon, type IconName } from "@/components/icons";
|
||||
|
||||
/**
|
||||
* Umschalter für das Farbthema.
|
||||
*
|
||||
* Drei Zustände statt zwei: Wer „System“ wählt, folgt weiterhin dem
|
||||
* Betriebssystem — abends dunkel, morgens hell, ohne Zutun. Ein reiner
|
||||
* Ein/Aus-Schalter kann das nicht abbilden, weil er nach dem ersten Klick
|
||||
* für immer eine feste Wahl bedeutet.
|
||||
*
|
||||
* Umgesetzt wird die Wahl allein über das Attribut `data-theme` am
|
||||
* <html>-Element; die Farben stecken in `globals.css`. Ohne Attribut gilt die
|
||||
* Systemeinstellung — deshalb wird bei „System“ nicht etwa „light“ gesetzt,
|
||||
* sondern das Attribut entfernt.
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = "qrgen.theme";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
|
||||
const OPTIONEN: { wert: Theme; icon: IconName; label: string }[] = [
|
||||
{ wert: "system", icon: "monitor", label: "System" },
|
||||
{ wert: "light", icon: "sun", label: "Hell" },
|
||||
{ wert: "dark", icon: "moon", label: "Dunkel" },
|
||||
];
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (theme === "system") {
|
||||
delete document.documentElement.dataset.theme;
|
||||
} else {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft vor dem ersten Zeichnen und verhindert das kurze Aufblitzen der
|
||||
* hellen Fassung bei jemandem, der ausdrücklich „Dunkel“ gewählt hat.
|
||||
*
|
||||
* Bewusst winzig und ohne Abhängigkeiten: Alles, was hier steht, blockiert
|
||||
* den Seitenaufbau. Wer der Systemeinstellung folgt, braucht das Skript gar
|
||||
* nicht — dafür sorgt schon das CSS.
|
||||
*/
|
||||
export const THEME_INIT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
THEME_STORAGE_KEY,
|
||||
)});if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`;
|
||||
|
||||
/**
|
||||
* Die Wahl liegt im `localStorage` — also außerhalb von React. Dafür ist
|
||||
* `useSyncExternalStore` gedacht: Es liest den Wert beim Einhängen, ohne dass
|
||||
* der Server-Aufbau davon abweicht, und benachrichtigt alle Stellen, wenn er
|
||||
* sich ändert. Nebenbei zieht ein zweiter Tab automatisch nach.
|
||||
*/
|
||||
const zuhoerer = new Set<() => void>();
|
||||
|
||||
function abonnieren(melden: () => void): () => void {
|
||||
zuhoerer.add(melden);
|
||||
window.addEventListener("storage", melden);
|
||||
return () => {
|
||||
zuhoerer.delete(melden);
|
||||
window.removeEventListener("storage", melden);
|
||||
};
|
||||
}
|
||||
|
||||
function lesen(): Theme {
|
||||
try {
|
||||
const wert = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return wert === "dark" || wert === "light" ? wert : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
/** Auf dem Server ist nichts gespeichert — dort gilt die Systemeinstellung. */
|
||||
function aufDemServer(): Theme {
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(abonnieren, lesen, aufDemServer);
|
||||
|
||||
const waehlen = (wert: Theme) => {
|
||||
applyTheme(wert);
|
||||
try {
|
||||
if (wert === "system") localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
else localStorage.setItem(THEME_STORAGE_KEY, wert);
|
||||
} catch {
|
||||
// Privater Modus ohne Speicher — die Wahl gilt dann bis zum Neuladen.
|
||||
}
|
||||
for (const melden of zuhoerer) melden();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Farbthema"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5"
|
||||
>
|
||||
{OPTIONEN.map((option) => {
|
||||
const aktiv = theme === option.wert;
|
||||
return (
|
||||
<button
|
||||
key={option.wert}
|
||||
type="button"
|
||||
data-theme-option={option.wert}
|
||||
aria-pressed={aktiv}
|
||||
title={`Farbthema: ${option.label}`}
|
||||
aria-label={`Farbthema: ${option.label}`}
|
||||
onClick={() => waehlen(option.wert)}
|
||||
className={
|
||||
aktiv
|
||||
? "rounded-md bg-accent-soft p-1.5 text-accent-soft-fg"
|
||||
: "rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
}
|
||||
>
|
||||
<Icon name={option.icon} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||