6 Commits
Author SHA1 Message Date
Florian Wilde 46e7eb0b59 Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten
Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
2026-08-17 10:41:57 +02:00
Florian Wilde c38b27ca85 Anmeldung: Host-Abweichung frueh erkennen, weniger auf der Anmeldeseite verraten
Im Betrieb aufgetreten: Die Anwendung lief hinter einem Reverse Proxy unter
einer Domain, in der .env stand aber noch die interne Adresse aus der
Einrichtung. Der Anmeldeknopf setzt den Cookie mit dem laufenden Vorgang auf
dem Host, ueber den der Browser da ist — die Rueckkehradresse fuer den
Anbieter kommt dagegen aus BASE_URL. Der Anbieter schickte den Browser also
auf den anderen Host, der Cookie blieb zurueck, und am Ende stand „Der
Anmeldevorgang ist abgelaufen". Eine Meldung, die die Ursache gut versteckt.

Jetzt prueft der Start beide Adressen und bricht sofort mit einer Erklaerung
ab. Auf dem Bildschirm steht nur die aufgerufene Adresse — die interne gehoert
ins Protokoll, nicht auf eine Seite, die jeder sieht.

Aus demselben Grund faellt der Hinweis „Die Anmeldung mit Passwort ist
abgeschaltet (OIDC_ONLY=true)" weg. Wie die Anmeldung eingerichtet ist, geht
Fremde nichts an; wer hierher gehoert, sieht den einen Knopf und weiss, was zu
tun ist. Beim allerersten Aufruf bleibt der Hinweis, dass der Erste
Administrator wird — der ist dann nuetzlich.

Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die
drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie
passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und
die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht,
warum BASE_URL und die Adresse im Browser dieselbe sein muessen.
2026-08-17 00:28:31 +02:00
Florian Wilde fcf41f56fe Authentik-Anleitung: die Redirect URI deutlicher erklaert
Der Beispielwert https://qr.example.com/... liess sich als etwas zum
Abschreiben lesen. Er ist aber keiner: Die Adresse ergibt sich aus der
eigenen BASE_URL, und wer den Beispielwert uebernimmt, landet bei Authentik
auf „Redirect URI Error", ohne dass die Anwendung ueberhaupt etwas mitbekommt.

Jetzt ein eigener Abschnitt mit der Formel, dem Befehl zum Nachsehen der
BASE_URL, drei Beispielen und den vier Fallen: Schraegstrich am Ende, Schema
und Port, die Auswahl Strict/Regex neben dem Feld, und ein leeres Feld.

Dazu, wo die tatsaechlich angekommene Adresse steht — in der Adresszeile und
in Authentik unter Events, Logs. Und der Hinweis, dass eine abweichende
Adresse nicht fuer eine falsche Authentik-Einstellung spricht, sondern fuer
eine falsche BASE_URL — die gehoert korrigiert, bevor der erste dynamische
Code entsteht.
2026-08-16 23:51:28 +02:00
Florian Wilde b828f38ca5 Dark Mode: folgt dem System, mit Umschalter
Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil
ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl
bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell.

Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die
Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu
verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste
daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert
sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den
Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert
sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl,
damit die helle Fassung nicht kurz aufblitzt.

Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im
Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide
teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt
`bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als
Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`.

Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die
Gestaltung fest.

Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift
flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der
niedrigste bei 5,9:1.
2026-08-16 09:09:10 +02:00
Florian Wilde 4046b72626 Erscheinungsbild im Admin-Bereich anpassbar
Name, Zusatz in der Fusszeile, Akzentfarbe, Logo und Favicon lassen sich
unter Verwaltung -> Erscheinungsbild einstellen. Sie gelten fuer die ganze
Installation und liegen in einer eigenen Tabelle mit genau einer Zeile.

Zur Farbe: Tailwind 4 uebersetzt bg-indigo-600 zu
background-color: var(--color-indigo-600). Die Anwendung schreibt diese
Variablen zur Laufzeit neu — damit folgen alle rund 60 eingefaerbten Stellen
der gewaehlten Farbe, ohne dass eine einzige Klasse angefasst werden muss.

Uebernommen werden nur Farbton und Saettigung, nicht die Helligkeit. Die
Helligkeitskurve stammt aus der Tailwind-Vorlage. Sonst wuerde ein helles
Gelb die Kopfzeile unlesbar machen, denn auf Stufe 600 steht weisse Schrift.
Die Umrechnung laeuft ueber OKLCH und trifft Tailwinds eigenes Indigo exakt.

Logo und Favicon sind Uploads mit eigener Groessengrenze (2 MB bzw. 512 KB)
und eigener Rechtepruefung: instance:manage statt codes:create. Uploads haben
jetzt ein Feld "purpose" — sonst haette die Aufraeumroutine fuer herrenlose
Dateien Logo und Favicon nach einem Tag geloescht. Ein ersetztes Bild wird
gelaescht, und eine Datei aus einem QR-Code laesst sich nicht als Logo
einhaengen.

Auf den Landingpages gelten Farbe und Favicon, aber weder Logo noch
Navigation — wer scannt, soll die Seite des Inhalts sehen.

Ein erster Entwurf hielt das Erscheinungsbild 15 Sekunden im Speicher, um die
Abfrage je Seitenaufruf zu sparen. Das ging schief: API-Route und Layout
laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
die andere Instanz nicht — nach dem Speichern blieb die Oberflaeche
unveraendert. Jetzt ohne Puffer.
2026-08-16 02:18:49 +02:00
Florian Wilde d0057f038c update.sh: erklaeren, warum ein Vorspulen scheitert
Das Skript brach mit „laesst sich nicht ohne Merge aktualisieren. Bitte von
Hand pruefen" ab — und liess den Betreiber damit allein. Genau dieser Fall
ist auf dem Server aufgetreten, nachdem hier die Historie neu geschrieben
wurde: Alte und neue Commits haben dann keinen gemeinsamen Vorfahren mehr.

Jetzt unterscheidet das Skript die beiden Ursachen und nennt den Ausweg. Bei
neu geschriebener Historie steht der vollstaendige Befehl da, samt dem
Hinweis, dass die .env davon unberuehrt bleibt. Bei eigenen Commits werden
sie aufgelistet.

Beide Zweige an nachgebauten Repositories geprueft.
2026-08-16 01:21:16 +02:00
63 changed files with 2581 additions and 177 deletions
+96 -7
View File
@@ -27,6 +27,12 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt. - **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter. - **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.
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`. von selbst; alles Nötige steht in einer `.env`.
@@ -120,9 +126,25 @@ BASE_URL=http://192.168.1.50:3000
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht > **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 > das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy > `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die > läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht > umbiegen, also am besten vor dem ersten produktiven Code festlegen.
> 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 Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
Servicename aus der `docker-compose.yml`, nicht dein Server. Servicename aus der `docker-compose.yml`, nicht dein Server.
@@ -230,7 +252,7 @@ BASE_URL=https://qr.deine-domain.de
| Scheme | `http` | | Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | | Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` | | Forward Port | `3000` |
| Websockets Support | an | | Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
| Block Common Exploits | an | | Block Common Exploits | an |
| SSL | Zertifikat anfordern, *Force SSL* aktivieren | | SSL | Zertifikat anfordern, *Force SSL* aktivieren |
@@ -245,9 +267,76 @@ Port. Danach:
docker compose up -d docker compose up -d
``` ```
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung > `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue > aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
> dynamische Codes ins interne Netz statt auf die Domain. > `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
> scheitert (siehe unten).
### Was der Proxy weiterreichen muss
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
sie eintragen.
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
| --- | --- | --- |
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
Für ein reines nginx (ohne NPM) heißt das:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
von sich aus.
### Größe der Uploads
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
Oberfläche.
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
**Advanced → Custom Nginx Configuration**:
```nginx
client_max_body_size 20m;
```
### Nachprüfen, ob es stimmt
```bash
# Kommt die Anwendung überhaupt an?
curl -I https://qr.deine-domain.de/
# Kennt der Container dieselbe Adresse wie der Browser?
docker compose exec app env | grep BASE_URL
```
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
- jeder neue dynamische Code zeigt auf die interne Adresse, und
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
### Erreichbar für wen?
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
öffentlich auflösbar und der Proxy von außen erreichbar sein.
### Mehrere Domains ### Mehrere Domains
+75 -6
View File
@@ -42,7 +42,7 @@ Adressen.
| Client type | **Confidential** | | Client type | **Confidential** |
| Client ID | notieren — kommt gleich in die `.env` | | Client ID | notieren — kommt gleich in die `.env` |
| Client Secret | notieren — ebenfalls | | 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 | | Signing Key | ein vorhandenes Zertifikat auswählen |
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) | | 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 Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
Anmeldung mit einer entsprechenden Meldung ab. Anmeldung mit einer entsprechenden Meldung ab.
> **Redirect URI genau so eintragen.** Ein fehlendes `https://`, ein Schrägstrich ### Die Redirect URI
> zu viel oder ein anderer Hostname führt zu `redirect_uri mismatch` —
> Authentik bricht dann ab, bevor die Anwendung überhaupt etwas mitbekommt. 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. | | 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. | | „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. | | „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 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 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. | | „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. | | 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. |
+105 -5
View File
@@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen) - [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Single Sign-on](#single-sign-on) - [Single Sign-on](#single-sign-on)
- [Mehrere Domains](#mehrere-domains) - [Mehrere Domains](#mehrere-domains)
- [Erscheinungsbild](#erscheinungsbild)
- [Verwaltung](#verwaltung) - [Verwaltung](#verwaltung)
- [Betrieb](#betrieb) - [Betrieb](#betrieb)
- [Schnittstelle](#schnittstelle) - [Schnittstelle](#schnittstelle)
@@ -103,10 +104,41 @@ Browser landet.
| Braucht diesen Server | nie | dauerhaft | | Braucht diesen Server | nie | dauerhaft |
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler | | In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare ### Der Kurz-Link
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
global — derselbe Slug darf auf zwei Domains liegen und dort auf Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
Verschiedenes zeigen. 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 Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben — permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
@@ -344,10 +376,78 @@ Anwendung leiten.
--- ---
## Erscheinungsbild
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
| | |
| --- | --- |
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
benutzbar.
### Hell und dunkel
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
Kopfzeile steht ein Umschalter mit drei Zuständen:
| | |
| --- | --- |
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
| **Hell** | immer hell |
| **Dunkel** | immer dunkel |
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
Tab zieht automatisch nach.
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
sogar **ohne JavaScript**.
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
aussehen.
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
als eigener Punkt auf der [Roadmap](roadmap.md).
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
---
## Verwaltung ## Verwaltung
| Bereich | Was dort geht | | Bereich | Was dort geht |
| --- | --- | | --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
@@ -398,7 +498,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
| `POST /api/feedback` | Rückmeldung abgeben | | `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | | `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 /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage | | `GET /s/{slug}` | öffentliche Landingpage |
+28 -4
View File
@@ -62,6 +62,27 @@ Damit sind **alle 15 vorgesehenen Typen** gebaut.
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
ID-Token. ID-Token.
### Erscheinungsbild (16.08.2026)
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
Umbau der rund 60 eingefärbten Stellen.
### Dark Mode (16.08.2026)
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
### Sprechende Kurz-Links (17.08.2026)
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
vorbehalten, per Einstellung für Bearbeiter freigebbar.
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
### Dokumentation (15.08.2026) ### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
@@ -190,10 +211,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 Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
die Auslieferungsroute — aber auch die Sicherungsstrategie. die Auslieferungsroute — aber auch die Sicherungsstrategie.
**Eigene Gestaltung der Landingpages** · groß **Erscheinungsbild je Gruppe** · groß
Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
braucht Grenzen, damit nichts Unlesbares entsteht. 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ß **Mehrsprachige Oberfläche** · groß
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
@@ -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);
+70
View File
@@ -122,6 +122,7 @@ model Domain {
grants DomainGrant[] grants DomainGrant[]
codes QrCode[] codes QrCode[]
aliases QrCodeAlias[]
} }
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen /// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -194,6 +195,8 @@ model QrCode {
uploads Upload[] uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind. /// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[] feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug]) @@unique([domainId, slug])
@@index([createdAt]) @@index([createdAt])
@@ -219,14 +222,81 @@ model Upload {
data Bytes data Bytes
createdAt DateTime @default(now()) 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, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt /// der Code aber noch nicht gespeichert wurde; solche Reste räumt
/// `src/lib/uploads.ts` nach einer Weile weg. /// `src/lib/uploads.ts` nach einer Weile weg.
qrCodeId String? qrCodeId String?
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
brandingLogo Branding? @relation("BrandingLogo")
brandingFavicon Branding? @relation("BrandingFavicon")
@@index([qrCodeId]) @@index([qrCodeId])
@@index([createdAt]) @@index([createdAt])
@@index([purpose])
}
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
model Branding {
id String @id @default("branding")
appName String @default("QR-Code-Generator")
/// Zeile in der Fußzeile, hinter dem Namen.
footerText String @default("statische und dynamische QR-Codes")
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
/// src/lib/branding-colors.ts.
accentColor String @default("#4F46E5")
logoUploadId String? @unique
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
faviconUploadId String? @unique
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
updatedAt DateTime @updatedAt
} }
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes /// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
+8 -4
View File
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
Gruppen, Benutzer und die QR-Typen verwalten. Gruppen, Benutzer und die QR-Typen verwalten.
</Notice> </Notice>
) : oidc?.nurOidc ? ( ) : oidc?.nurOidc ? (
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
setup ? (
<p className="text-center text-sm text-slate-500"> <p className="text-center text-sm text-slate-500">
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>). Wer sich zuerst anmeldet, wird Administrator dieser Installation.
{setup
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
: ""}
</p> </p>
) : null
) : ( ) : (
<p className="text-center text-sm text-slate-500"> <p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung → Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+12 -2
View File
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator"; import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability"; import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
@@ -47,7 +50,7 @@ function Hinweis({
} }
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; const { type } = await params;
const state = await getQrTypeState(type); const state = await getQrTypeState(type);
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
); );
} }
return <QrGenerator typeId={state.qrType.id} />; // Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
} }
+2 -2
View File
@@ -37,11 +37,11 @@ export default async function AccountPage() {
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "} <span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
Anwendung erreicht, darf alles. Zum Einschalten{" "} 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 AUTH_ENABLED=true
</code>{" "} </code>{" "}
in der <code className="font-mono text-xs">.env</code> setzen und{" "} 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 docker compose up -d
</code>{" "} </code>{" "}
ausführen. Beim nächsten Aufruf legst du den ersten Administrator ausführen. Beim nächsten Aufruf legst du den ersten Administrator
+28 -6
View File
@@ -2,8 +2,10 @@ import Link from "next/link";
import { LogoutButton } from "@/components/auth-forms"; import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy"; import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden. // werden.
@@ -21,24 +23,43 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
const actor = await getActor(); const actor = await getActor();
const signedIn = actor.kind !== "anonymous"; const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage"); const showAdmin = can(actor, "instance:manage");
const branding = await getBranding();
return ( return (
<div className="flex flex-1 flex-col"> <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"> <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 <Link
href="/" href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900" className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
> >
{branding.logoUploadId ? (
/* eslint-disable-next-line @next/next/no-img-element -- aus der
Datenbank ausgeliefert, nicht aus dem Dateisystem */
<img
src={`/api/uploads/${branding.logoUploadId}`}
alt={branding.appName}
className="h-9 w-auto max-w-[12rem] object-contain"
/>
) : (
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white"> <span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" /> <Icon name="qr" className="size-5" />
</span> </span>
)}
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */} Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
<span className="hidden sm:inline">QR-Code-Generator</span> dieses schon den Namen. */}
{branding.logoUploadId ? null : (
<span className="hidden sm:inline">{branding.appName}</span>
)}
</Link> </Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1"> <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 ? ( {signedIn ? (
<> <>
<Link href="/" className={NAV_LINK}> <Link href="/" className={NAV_LINK}>
@@ -66,7 +87,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
title={`Konto von ${actor.user.email}`} 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" 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)} {actorLabel(actor).slice(0, 2)}
</span> </span>
<span className="hidden truncate sm:inline"> <span className="hidden truncate sm:inline">
@@ -106,9 +127,10 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white"> <footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6"> <div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · statische und dynamische QR-Codes {branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</div> </div>
</footer> </footer>
</div> </div>
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
{kommentare.map((eintrag) => ( {kommentare.map((eintrag) => (
<li <li
key={eintrag.id} 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"> <div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
<span className="font-medium text-slate-900 tabular-nums"> <span className="font-medium text-slate-900 tabular-nums">
+10 -2
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list"; import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" }; export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function MyCodesPage() { export default async function MyCodesPage() {
await requirePageActor(); const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte // Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können. // 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 ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
</p> </p>
</div> </div>
<CodesList /> <CodesList maySetSlug={maySetSlug} />
</div> </div>
); );
} }
+2 -2
View File
@@ -50,7 +50,7 @@ export default async function HomePage() {
<span <span
className={ className={
usable 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" : "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
} }
> >
@@ -96,7 +96,7 @@ export default async function HomePage() {
{usable ? ( {usable ? (
<Link <Link
href={`/erstellen/${qrType.id}`} 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} {tile}
</Link> </Link>
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { SettingsAdmin } from "@/components/settings-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = { title: "Einstellungen" };
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
await requirePagePermission("instance:manage");
const settings = await getSettings();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Einstellungen
</h1>
<p className="mt-2 text-slate-600">
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
</p>
</div>
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
</div>
);
}
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BrandingAdmin } from "@/components/branding-admin";
import { Icon } from "@/components/icons";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
export const metadata: Metadata = { title: "Erscheinungsbild" };
export const dynamic = "force-dynamic";
export default async function BrandingPage() {
await requirePagePermission("instance:manage");
const branding = await getBranding();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Erscheinungsbild
</h1>
<p className="mt-2 text-slate-600">
Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
gelten für die ganze Installation.
</p>
<p className="mt-2 text-sm text-slate-500">
Die öffentlichen Landingpages übernehmen die Farbe und das Favicon,
aber weder Logo noch Navigation — wer einen Code scannt, soll die
Seite des Inhalts sehen und nicht die einer Verwaltungssoftware.
</p>
</div>
<BrandingAdmin
initial={{
appName: branding.appName,
footerText: branding.footerText,
accentColor: branding.accentColor,
logoUploadId: branding.logoUploadId,
faviconUploadId: branding.faviconUploadId,
}}
/>
</div>
);
}
+35 -5
View File
@@ -5,6 +5,8 @@ import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui"; import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" }; export const metadata: Metadata = { title: "Verwaltung" };
@@ -26,9 +28,9 @@ function Tile({
return ( return (
<Link <Link
href={href} 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" /> <Icon name={icon} className="size-6" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -45,13 +47,23 @@ function Tile({
export default async function AdminPage() { export default async function AdminPage() {
await requirePagePermission("instance:manage"); await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(), prisma.user.count(),
prisma.group.count(), prisma.group.count(),
prisma.qrCode.count(), prisma.qrCode.count(),
prisma.domain.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 ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -68,11 +80,11 @@ export default async function AdminPage() {
<Notice tone="info"> <Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "} <span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten 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 AUTH_ENABLED=true
</code> </code>
in der <code className="font-mono text-xs">.env</code> setzen und 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 docker compose up -d
</code> </code>
ausführen. Beim nächsten Aufruf legst du den ersten Administrator 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." description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt" 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>
</div> </div>
); );
+96
View File
@@ -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);
}
}
+40
View File
@@ -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);
}
}
+7 -1
View File
@@ -1,8 +1,10 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { import {
assertHostMatchesBaseUrl,
discover, discover,
getOidcConfig, getOidcConfig,
isOidcEnabled, 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, * 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. * 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 { try {
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
throw new QrRequestError( 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 config = getOidcConfig();
const meta = await discover(config.issuer); const meta = await discover(config.issuer);
+87 -2
View File
@@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability"; import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads"; import { attachUploads } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const; } as const;
const updateSchema = z.object({ const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable() .nullable()
.optional(), .optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
}); });
async function loadCode(id: string) { async function loadCode(id: string) {
@@ -109,15 +124,85 @@ export async function PATCH(
Object.assign(update, parsed.data.design); Object.assign(update, parsed.data.design);
} }
if (Object.keys(update).length === 0) { /* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
const umbenannt =
neuerSlug !== undefined &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt."); return jsonError("Es wurden keine Änderungen übermittelt.");
} }
const updated = await prisma.qrCode.update({ // 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,
slug: existing.slug,
qrCodeId: existing.id,
},
});
}
return tx.qrCode.update({
where: { id }, where: { id },
data: update, data: update,
include: WITH_RELATIONS, include: WITH_RELATIONS,
}); });
});
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt. // später als verwaist weggeräumt.
+32 -2
View File
@@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; 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"; import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,6 +28,11 @@ const createSchema = z.object({
.trim() .trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(), .optional(),
/**
* 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. */ /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(), groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */ /** Unter welcher Domain die Kurz-URL entsteht. */
@@ -57,6 +64,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
@@ -96,12 +104,25 @@ export async function POST(request: NextRequest): Promise<Response> {
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data); 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);
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const created = await prisma.qrCode.create({ const created = await prisma.qrCode.create({
data: { data: {
slug: generateSlug(), slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id, type: qrType.id,
label: label && label.length > 0 ? label : null, label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data), data: toJsonInput(validated.data),
@@ -114,6 +135,7 @@ export async function POST(request: NextRequest): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
@@ -122,6 +144,14 @@ export async function POST(request: NextRequest): Promise<Response> {
return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) { } catch (error) {
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
// käme immer wieder auf dieselbe Adresse.
if (gewuenscht && isUniqueViolation(error)) {
return jsonError(
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
409,
);
}
if (!isUniqueViolation(error)) throw error; if (!isUniqueViolation(error)) throw error;
} }
} }
+10 -4
View File
@@ -9,6 +9,7 @@ import {
isUploadKind, isUploadKind,
maxUploadBytes, maxUploadBytes,
storeUpload, storeUpload,
uploadPurposeOf,
} from "@/lib/uploads"; } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise<Response> {
const actor = await getActor(); 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; let form: FormData;
try { try {
form = await request.formData(); form = await request.formData();
@@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError("Unbekannte Dateiart."); 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"); const file = form.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
return jsonError("Es wurde keine Datei mitgeschickt."); return jsonError("Es wurde keine Datei mitgeschickt.");
+72 -1
View File
@@ -9,10 +9,81 @@
monospace; monospace;
} }
/* ---------------------------------------------------------------- Dark Mode
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
entscheidet sich hier.
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
demselben leicht blauen Ton wie die helle Reihe.
*/
@theme {
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
--color-accent-text: light-dark(
var(--color-indigo-600),
var(--color-indigo-400)
);
--color-accent-soft: light-dark(
var(--color-indigo-50),
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
);
--color-accent-soft-fg: light-dark(
var(--color-indigo-700),
var(--color-indigo-300)
);
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
der Grund einer Videovorschau. */
--color-scrim: oklch(20.8% 0.042 265.755);
}
:root { :root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light; color-scheme: light;
} }
:root[data-theme="dark"] {
color-scheme: dark;
}
body { body {
background-color: var(--color-slate-50); background-color: var(--color-slate-50);
color: var(--color-slate-900); color: var(--color-slate-900);
@@ -22,7 +93,7 @@ body {
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */ /* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
:focus-visible { :focus-visible {
outline: 2px solid var(--color-indigo-600); outline: 2px solid var(--color-accent-text);
outline-offset: 2px; outline-offset: 2px;
} }
+51 -6
View File
@@ -1,28 +1,73 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
import { accentCss } from "@/lib/branding-colors";
import { getBranding } from "@/lib/branding";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { // Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
// vorgerendert werden.
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: { title: {
default: "QR-Code-Generator", default: branding.appName,
template: "%s · QR-Code-Generator", template: `%s · ${branding.appName}`,
}, },
description: description:
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
...(branding.faviconUploadId
? {
icons: {
icon: {
url: `/api/uploads/${branding.faviconUploadId}`,
type: branding.faviconMimeType ?? undefined,
},
},
}
: {}),
}; };
}
/** /**
* Wurzel-Layout — bewusst nur `html` und `body`. * Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
* *
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
* „Meine Codes“ nichts zu suchen. * „Meine Codes“ nichts zu suchen.
*
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
*/ */
export default function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const branding = await getBranding();
return ( return (
<html lang="de" className="h-full"> <html lang="de" className="h-full">
<body className="flex min-h-full flex-col">{children}</body> <body className="flex min-h-full flex-col">
{/*
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
`light-dark()` im CSS von allein.
*/}
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
{/*
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
die Voreinstellung zurück.
*/}
<style
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
/>
{children}
</body>
</html> </html>
); );
} }
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> }; // Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response { function notFound(message: string): Response {
return new Response( return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest, request: NextRequest,
context: Context, context: Context,
): Promise<Response> { ): Promise<Response> {
const { slug } = await context.params; const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) { if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig."); return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
); );
} }
const code = await prisma.qrCode.findUnique({ const gefunden = await findCodeBySlug(domain.id, slug);
where: { domainId_slug: { domainId: domain.id, slug } }, const code = gefunden?.code;
});
if (!code) { if (!code) {
return notFound( return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ, // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN). // wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`; target = `/s/${code.slug}`;
} else { } else {
try { try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, // 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 { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell"; import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> }; // Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/** /**
* Öffentliche Landingpage eines dynamischen Codes. * Ö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]`. * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. * 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; if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers()); const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null; if (!domain.isActive) return null;
return prisma.qrCode.findUnique({ return (await findCodeBySlug(domain.id, slug))?.code ?? null;
where: { domainId_slug: { domainId: domain.id, slug } },
});
} }
export async function generateMetadata({ export async function generateMetadata({
+377
View File
@@ -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>
);
}
+136 -21
View File
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module"; import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", { const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary; 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 [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null); const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({}); const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
@@ -124,9 +133,19 @@ export function CodesList() {
setEditing(code); setEditing(code);
setEditData(code.data); setEditData(code.data);
setEditLabel(code.label ?? ""); setEditLabel(code.label ?? "");
setEditSlug(code.slug);
setEditAliases(code.aliases);
setEditError(null); setEditError(null);
}; };
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
const freigegeben = editing
? editing.aliases.filter((alias) => !editAliases.includes(alias))
: [];
const EditForm = editing ? qrForms[editing.type] : undefined; const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo( const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null), () => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +158,23 @@ export function CodesList() {
setEditError(editValidation.message); setEditError(editValidation.message);
return; return;
} }
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true); setSaving(true);
setEditError(null); setEditError(null);
try { try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, { await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH", method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null }, json: {
data: editValidation.data,
label: editLabel.trim() || null,
// Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
...(maySetSlug
? { slug: editSlug.trim(), aliases: editAliases }
: {}),
},
}); });
setEditing(null); setEditing(null);
await load(); await load();
@@ -297,7 +327,7 @@ export function CodesList() {
<div <div
role="group" role="group"
aria-label="Ansicht" 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 +346,7 @@ export function CodesList() {
className={cx( className={cx(
"rounded-md p-2 transition-colors", "rounded-md p-2 transition-colors",
view === value 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", : "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)} )}
> >
@@ -374,7 +404,7 @@ export function CodesList() {
setQuery(""); setQuery("");
setGroupFilter("alle"); setGroupFilter("alle");
}} }}
className="font-medium text-indigo-600 underline" className="font-medium text-accent-text underline"
> >
Filter zurücksetzen Filter zurücksetzen
</button> </button>
@@ -384,7 +414,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Listenansicht */} {/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? ( {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 {/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt, aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */} steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
@@ -466,7 +496,7 @@ export function CodesList() {
href={code.shortUrl} href={code.shortUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="truncate font-mono text-xs text-indigo-600 hover:underline" className="truncate font-mono text-xs text-accent-text hover:underline"
title={code.shortUrl} title={code.shortUrl}
> >
{code.shortUrl.replace(/^https?:\/\//, "")} {code.shortUrl.replace(/^https?:\/\//, "")}
@@ -539,8 +569,8 @@ export function CodesList() {
<button <button
type="button" type="button"
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
title="Ziel bearbeiten" title="Bearbeiten"
aria-label={`Ziel von ${displayName(code)} 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" 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" /> <Icon name="pencil" className="size-4" />
@@ -617,7 +647,7 @@ export function CodesList() {
href={code.shortUrl} href={code.shortUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-mono text-xs break-all text-indigo-600 hover:underline" className="font-mono text-xs break-all text-accent-text hover:underline"
> >
{code.shortUrl} {code.shortUrl}
</a> </a>
@@ -670,7 +700,7 @@ export function CodesList() {
{code.type === "feedback" ? ( {code.type === "feedback" ? (
<Link <Link
href={`/meine-codes/${code.id}/feedback`} 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" /> <Icon name="star" className="size-3.5" />
Rückmeldungen Rückmeldungen
@@ -683,18 +713,18 @@ export function CodesList() {
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
> >
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Ziel bearbeiten Bearbeiten
</Button> </Button>
<a <a
href={`/api/codes/${code.id}/qr?format=png&download=1`} href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="download" className="size-3.5" /> <Icon name="download" className="size-3.5" />
PNG PNG
</a> </a>
<a <a
href={`/api/codes/${code.id}/qr?format=svg&download=1`} href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="download" className="size-3.5" /> <Icon name="download" className="size-3.5" />
SVG SVG
@@ -721,7 +751,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */} {/* ------------------------------------------------------ Bearbeiten */}
<Modal <Modal
open={editing !== null} open={editing !== null}
title="Ziel bearbeiten" title="Code bearbeiten"
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
footer={ footer={
<> <>
@@ -742,12 +772,8 @@ export function CodesList() {
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
auf{" "} gültig, solange der Kurz-Link derselbe ist.
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
</p> </p>
{EditForm ? ( {EditForm ? (
@@ -766,6 +792,95 @@ export function CodesList() {
</Notice> </Notice>
)} )}
{maySetSlug ? (
<>
<Field
label="Kurz-Link"
htmlFor="edit-slug"
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="edit-slug"
value={editSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
disabled={saving}
onChange={(event) => {
setEditSlug(event.target.value);
setEditError(null);
}}
/>
</Field>
{slugProblem ? (
<Notice tone="error">{slugProblem}</Notice>
) : slugGeaendert ? (
<Notice tone="info">
Der bisherige Kurz-Link{" "}
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
lässt er sich später freigeben.
</Notice>
) : null}
{editing.aliases.length > 0 ? (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
Frühere Kurz-Links
</span>
<p className="mb-2 text-sm text-slate-500">
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
nichts gedruckt ist — die Adresse ist danach sofort tot.
</p>
<ul className="space-y-1.5">
{editing.aliases.map((alias) => {
const behalten = editAliases.includes(alias);
return (
<li
key={alias}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
>
<code
className={
behalten
? "min-w-0 font-mono text-xs break-all text-slate-700"
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
}
>
/r/{alias}
</code>
<Button
type="button"
variant={behalten ? "ghost" : "secondary"}
size="sm"
disabled={saving}
onClick={() =>
setEditAliases((alt) =>
behalten
? alt.filter((eintrag) => eintrag !== alias)
: [...alt, alias],
)
}
>
{behalten ? "Freigeben" : "Doch behalten"}
</Button>
</li>
);
})}
</ul>
{freigegeben.length > 0 ? (
<Notice tone="warning">
{freigegeben.length === 1
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
</Notice>
) : null}
</div>
) : null}
</>
) : null}
<Field label="Name (optional)" htmlFor="edit-label"> <Field label="Name (optional)" htmlFor="edit-label">
<TextInput <TextInput
id="edit-label" id="edit-label"
+6 -6
View File
@@ -40,7 +40,7 @@ function ColorField({
aria-label={`${label} auswählen`} aria-label={`${label} auswählen`}
value={valid ? value : "#000000"} value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())} 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 <TextInput
id={id} id={id}
@@ -95,7 +95,7 @@ export function DesignPanel({
onClick={() => onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor }) 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 <span
className="size-4 rounded border border-slate-300" className="size-4 rounded border border-slate-300"
@@ -111,7 +111,7 @@ export function DesignPanel({
onClick={() => onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor }) 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 Farben tauschen
</button> </button>
@@ -158,7 +158,7 @@ export function DesignPanel({
if (Number.isNaN(raw)) return; if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) }); 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> </div>
</Field> </Field>
@@ -182,8 +182,8 @@ export function DesignPanel({
className={cx( className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors", "rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level design.ecLevel === level
? "border-indigo-600 bg-indigo-50 text-indigo-700" ? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50", : "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)} )}
> >
{level} {level}
+18
View File
@@ -214,6 +214,24 @@ const PATHS = {
<path d="M4 12h16" /> <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: ( lock: (
<> <>
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" /> <rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
+2 -2
View File
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
return ( return (
<LandingShell title={title}> <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> <fieldset>
<legend className="text-center text-base font-medium text-slate-900"> <legend className="text-center text-base font-medium text-slate-900">
{question} {question}
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
value={kommentar} value={kommentar}
disabled={laeuft} disabled={laeuft}
onChange={(event) => setKommentar(event.target.value)} 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" placeholder="Optional"
/> />
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
{images.map((image, index) => ( {images.map((image, index) => (
<figure <figure
key={image.fileId} 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 {/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung ohne bekannte Maße; der Optimierer von Next Auslieferung ohne bekannte Maße; der Optimierer von Next
+1 -1
View File
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
href={link.url} href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster. // Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer" 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"> <span className="min-w-0 flex-1 font-medium break-words">
{link.label} {link.label}
+2 -2
View File
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
title={title || filename || "PDF-Dokument"} title={title || filename || "PDF-Dokument"}
subtitle={description || null} 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"> <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"> <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" /> <Icon name="document" className="size-6" />
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
</a> </a>
<a <a
href={`${href}?download`} 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" /> <Icon name="download" className="size-4" />
Herunterladen Herunterladen
+1 -1
View File
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
<a <a
href={profile.url} href={profile.url}
rel="noopener noreferrer" 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 <span
aria-hidden="true" aria-hidden="true"
+1 -1
View File
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
<a <a
href={url} href={url}
rel="noopener noreferrer" 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"> <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" /> <Icon name="video" className="size-5" />
+2 -2
View File
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
<button <button
type="button" type="button"
onClick={() => setGestartet(true)} 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"> <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" /> <Icon name="video" className="size-8" />
</span> </span>
<span className="font-medium text-white">Video abspielen</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 Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
werden Daten an den Anbieter übertragen. werden Daten an den Anbieter übertragen.
</span> </span>
+1 -1
View File
@@ -39,7 +39,7 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn. // Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose(); if (event.target === ref.current) onClose();
}} }}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40" className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
> >
<div className="p-5 sm:p-6"> <div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
+4 -4
View File
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
<img <img
src={`/api/uploads/${image.fileId}`} src={`/api/uploads/${image.fileId}`}
alt="" 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"> <div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-slate-600"> <p className="truncate text-sm text-slate-600">
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0} disabled={disabled || index === 0}
onClick={() => move(index, -1)} onClick={() => move(index, -1)}
aria-label={`Bild ${index + 1} nach oben`} 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" /> <Icon name="arrowLeft" className="size-4 rotate-90" />
</button> </button>
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === images.length - 1} disabled={disabled || index === images.length - 1}
onClick={() => move(index, 1)} onClick={() => move(index, 1)}
aria-label={`Bild ${index + 1} nach unten`} 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" /> <Icon name="arrowLeft" className="size-4 -rotate-90" />
</button> </button>
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
setImages(images.filter((_, i) => i !== index)) setImages(images.filter((_, i) => i !== index))
} }
aria-label={`Bild ${index + 1} entfernen`} 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" /> <Icon name="trash" className="size-4" />
</button> </button>
+3 -3
View File
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0} disabled={disabled || index === 0}
onClick={() => move(index, -1)} onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`} 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" /> <Icon name="arrowLeft" className="size-4 rotate-90" />
</button> </button>
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === links.length - 1} disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)} onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`} 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" /> <Icon name="arrowLeft" className="size-4 -rotate-90" />
</button> </button>
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || links.length === 1} disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))} onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`} 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" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
onChange({ ...value, fileId: "", filename: "", size: 0 }) onChange({ ...value, fileId: "", filename: "", size: 0 })
} }
aria-label="Datei entfernen" 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" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
setProfiles(profiles.filter((_, i) => i !== index)) setProfiles(profiles.filter((_, i) => i !== index))
} }
aria-label={`Profil ${index + 1} entfernen`} 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" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
onClick={() => onClick={() =>
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` }) 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 https:// voranstellen
</button> </button>
+48 -11
View File
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design"; import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module"; import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
import { describeSlugProblem } from "@/lib/slug";
type PreviewState = { dataUrl: string; content: string }; type PreviewState = { dataUrl: string; content: string };
@@ -42,7 +43,14 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b); return stableStringify(a) === stableStringify(b);
} }
export function QrGenerator({ typeId }: { typeId: QrTypeId }) { export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
}) {
const qrType = qrTypes[typeId]; const qrType = qrTypes[typeId];
const Form = qrForms[typeId]; const Form = qrForms[typeId];
@@ -51,6 +59,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch. // Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic); const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState(""); 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 [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -164,6 +174,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/* ----------------------------------------------- Dynamischen Code speichern */ /* ----------------------------------------------- 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 = const hasUnsavedChanges =
saved !== null && saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) || (!sameData(saved.data, validation.ok ? validation.data : data) ||
@@ -171,7 +186,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
(saved.label ?? "") !== label.trim()); (saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!validation.ok || !designValid) return; if (!validation.ok || !designValid || slugProblem) return;
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
@@ -180,6 +195,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
data: validation.data, data: validation.data,
design, design,
label: label.trim() || undefined, label: label.trim() || undefined,
slug: wunschSlug.trim() || undefined,
}; };
const result = saved const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -200,7 +216,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [validation, designValid, typeId, design, label, saved]); }, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
if (!saved) return; if (!saved) return;
@@ -226,7 +242,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
</Link> </Link>
<div className="mb-7 flex flex-wrap items-center gap-3"> <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" /> <Icon name={qrType.icon} className="size-6" />
</span> </span>
<div> <div>
@@ -290,8 +306,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
className={cx( className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50", "rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic !isDynamic
? "border-indigo-600 bg-indigo-50/60" ? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-white hover:bg-slate-50", : "border-slate-300 bg-surface hover:bg-slate-50",
)} )}
> >
<span className="flex items-center gap-2 font-medium text-slate-900"> <span className="flex items-center gap-2 font-medium text-slate-900">
@@ -314,8 +330,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
className={cx( className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50", "rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic isDynamic
? "border-indigo-600 bg-indigo-50/60" ? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-white hover:bg-slate-50", : "border-slate-300 bg-surface hover:bg-slate-50",
)} )}
> >
<span className="flex items-center gap-2 font-medium text-slate-900"> <span className="flex items-center gap-2 font-medium text-slate-900">
@@ -347,13 +363,34 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/> />
</Field> </Field>
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
{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>
)}
{saved ? ( {saved ? (
<div className="space-y-3"> <div className="space-y-3">
<Notice tone="success"> <Notice tone="success">
<span className="font-medium">Code gespeichert.</span> Der <span className="font-medium">Code gespeichert.</span> Der
QR-Code zeigt dauerhaft auf diese Kurz-URL: QR-Code zeigt dauerhaft auf diese Kurz-URL:
<span className="mt-2 flex flex-wrap items-center gap-2"> <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} {saved.shortUrl}
</code> </code>
<Button <Button
@@ -383,7 +420,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
) : null} ) : null}
<Link <Link
href="/meine-codes" 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“ Zu „Meine Codes“
<Icon name="external" className="size-3.5" /> <Icon name="external" className="size-3.5" />
@@ -408,7 +445,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving || !ready} disabled={saving || !ready || Boolean(slugProblem)}
> >
{saving ? "Speichert …" : "Dynamischen Code speichern"} {saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button> </Button>
+1 -1
View File
@@ -42,7 +42,7 @@ export function QrPreview({
</div> </div>
)} )}
{loading ? ( {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 … aktualisiert …
</span> </span>
) : null} ) : null}
+84
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+2 -2
View File
@@ -42,7 +42,7 @@ function Toggle({
> >
<span <span
className={cx( className={cx(
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform", "inline-block size-5 rounded-full bg-surface shadow-sm transition-transform",
checked ? "translate-x-5.5" : "translate-x-0.5", checked ? "translate-x-5.5" : "translate-x-0.5",
)} )}
/> />
@@ -114,7 +114,7 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
className={cx( className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-xl", "flex size-10 shrink-0 items-center justify-center rounded-xl",
entry.enabled && entry.implemented entry.enabled && entry.implemented
? "bg-indigo-50 text-indigo-600" ? "bg-accent-soft text-accent-text"
: "bg-slate-100 text-slate-400", : "bg-slate-100 text-slate-400",
)} )}
> >
+5 -5
View File
@@ -22,7 +22,7 @@ export function Card({
return ( return (
<section <section
className={cx( className={cx(
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6", "rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6",
className, className,
)} )}
> >
@@ -57,7 +57,7 @@ const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary: primary:
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600", "bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
secondary: secondary:
"border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 disabled:hover:bg-white", "border border-slate-300 bg-surface text-slate-700 hover:bg-slate-50 disabled:hover:bg-surface",
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent", ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600", danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
}; };
@@ -124,7 +124,7 @@ export function TextInput({
return ( return (
<input <input
className={cx( className={cx(
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400", "w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
invalid invalid
? "border-red-400 focus-visible:outline-red-500" ? "border-red-400 focus-visible:outline-red-500"
: "border-slate-300", : "border-slate-300",
@@ -137,7 +137,7 @@ export function TextInput({
/** Gemeinsame Grundgestaltung aller Eingabefelder. */ /** Gemeinsame Grundgestaltung aller Eingabefelder. */
export const CONTROL_BASE = export const CONTROL_BASE =
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500"; "w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
export function TextArea({ export function TextArea({
invalid, invalid,
@@ -243,7 +243,7 @@ export function Badge({
neutral: "bg-slate-100 text-slate-600", neutral: "bg-slate-100 text-slate-600",
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen // Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
// „Beliebt" auf der Startseite gedacht war. // „Beliebt" auf der Startseite gedacht war.
accent: "bg-indigo-100 text-indigo-700", accent: "bg-accent-soft text-accent-soft-fg",
soon: "bg-slate-100 text-slate-500", soon: "bg-slate-100 text-slate-500",
success: "bg-emerald-100 text-emerald-700", success: "bg-emerald-100 text-emerald-700",
}; };
+52
View File
@@ -96,6 +96,58 @@ export function redirectUri(): string {
return `${getBaseUrl()}/api/auth/oidc/callback`; return `${getBaseUrl()}/api/auth/oidc/callback`;
} }
/**
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
*
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
* Ursache gut versteckt.
*
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
*/
export function assertHostMatchesBaseUrl(headers: Headers): void {
const angekommen = (
headers.get("x-forwarded-host") ??
headers.get("host") ??
""
)
.split(",")[0]
.trim()
.toLowerCase();
if (!angekommen) return;
let erwartet = "";
try {
erwartet = new URL(getBaseUrl()).host.toLowerCase();
} catch {
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
}
if (angekommen === erwartet) return;
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
// interne Adresse der Installation geht Fremde nichts an.
console.error(
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
);
throw new QrRequestError(
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
`Protokoll des Containers.`,
400,
);
}
type Discovery = { type Discovery = {
issuer: string; issuer: string;
authorization_endpoint: string; authorization_endpoint: string;
+27 -1
View File
@@ -21,12 +21,30 @@ export type Action =
| "codes:update" | "codes:update"
/** Codes löschen. */ /** Codes löschen. */
| "codes:delete" | "codes:delete"
/**
* Den Kurz-Link selbst vergeben oder ändern.
*
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
* Administratoren — freigeben lässt es sich in der Verwaltung.
*/
| "codes:slug"
/** Mitglieder und Rollen einer Gruppe verwalten. */ /** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage" | "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */ /** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage"; | "instance:manage";
export type Scope = { groupId?: string }; export type Scope = {
groupId?: string;
/**
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
*/
editorsMaySetSlug?: boolean;
};
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
case "codes:update": case "codes:update":
case "codes:delete": case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR; return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
case "codes:slug":
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
if (role === "ADMIN") return true;
return (
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
);
case "group:manage": case "group:manage":
return role === "ADMIN"; return role === "ADMIN";
default: default:
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete": "codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:slug":
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
"group:manage": "group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.", "Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage": "instance:manage":
+112
View File
@@ -0,0 +1,112 @@
/**
* Erzeugt aus einer einzigen Akzentfarbe die volle Abstufung 50–950.
*
* Warum das reicht, um die ganze Oberfläche umzufärben: Tailwind 4 übersetzt
* `bg-indigo-600` zu `background-color: var(--color-indigo-600)`. Wer diese
* Variablen zur Laufzeit überschreibt, färbt damit jede der rund 60 Stellen
* um — ohne eine einzige Klasse anzufassen.
*
* Vom gewählten Ton werden nur **Farbton und Sättigung** übernommen, nicht
* die Helligkeit. Die Helligkeitskurve stammt aus der Tailwind-Vorlage. Das
* ist Absicht: Auf `-600` steht weiße Schrift. Käme die Helligkeit aus der
* Eingabe, würde ein helles Gelb die Kopfzeile unlesbar machen.
*/
/** Die Abstufung von Tailwind 4 (indigo) als Vorlage für Helligkeit und Form. */
const VORLAGE = [
{ stop: 50, l: 96.2, c: 0.018, h: 272.314 },
{ stop: 100, l: 93.0, c: 0.034, h: 272.788 },
{ stop: 200, l: 87.0, c: 0.065, h: 274.039 },
{ stop: 300, l: 78.5, c: 0.115, h: 274.713 },
{ stop: 400, l: 67.3, c: 0.182, h: 276.935 },
{ stop: 500, l: 58.5, c: 0.233, h: 277.117 },
{ stop: 600, l: 51.1, c: 0.262, h: 276.966 },
{ stop: 700, l: 45.7, c: 0.24, h: 277.023 },
{ stop: 800, l: 39.8, c: 0.195, h: 277.366 },
{ stop: 900, l: 35.9, c: 0.144, h: 278.697 },
{ stop: 950, l: 25.7, c: 0.09, h: 281.288 },
] as const;
const BEZUG = VORLAGE.find((eintrag) => eintrag.stop === 600)!;
export const DEFAULT_ACCENT = "#4F46E5";
export function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
}
/** sRGB-Kanal (0–1) in seinen linearen Anteil. */
function toLinear(kanal: number): number {
return kanal <= 0.04045
? kanal / 12.92
: Math.pow((kanal + 0.055) / 1.055, 2.4);
}
/**
* Hex nach OKLCH.
*
* OKLab ist so gebaut, dass gleiche Zahlenabstände auch gleich große
* wahrgenommene Unterschiede sind — anders als HSL, wo dasselbe „50 %
* Helligkeit" bei Gelb und Blau völlig verschieden aussieht.
*/
export function hexToOklch(hex: string): { l: number; c: number; h: number } {
const wert = hex.trim().replace("#", "");
const r = toLinear(Number.parseInt(wert.slice(0, 2), 16) / 255);
const g = toLinear(Number.parseInt(wert.slice(2, 4), 16) / 255);
const b = toLinear(Number.parseInt(wert.slice(4, 6), 16) / 255);
const lMs = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const mMs = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const sMs = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const l = 0.2104542553 * lMs + 0.793617785 * mMs - 0.0040720468 * sMs;
const a = 1.9779984951 * lMs - 2.428592205 * mMs + 0.4505937099 * sMs;
const bb = 0.0259040371 * lMs + 0.7827717662 * mMs - 0.808675766 * sMs;
const c = Math.sqrt(a * a + bb * bb);
let h = (Math.atan2(bb, a) * 180) / Math.PI;
if (h < 0) h += 360;
return { l: l * 100, c, h };
}
export type ColorRamp = { stop: number; value: string }[];
/**
* Die volle Abstufung zur gewählten Akzentfarbe.
*
* Die Sättigung wird so skaliert, dass der Ton auf `-600` der Eingabe
* entspricht; nach oben begrenzt, damit ein sehr kräftiger Wert die hellen
* Stufen nicht in ein grelles Pastell kippt. Browser rechnen Werte außerhalb
* des darstellbaren Bereichs selbst zurück.
*/
export function buildRamp(hex: string): ColorRamp {
const gewaehlt = hexToOklch(isHexColor(hex) ? hex : DEFAULT_ACCENT);
// Ein nahezu grauer Wert soll grau bleiben und nicht auf die Sättigung der
// Vorlage hochgezogen werden.
const faktor = Math.min(gewaehlt.c / BEZUG.c, 1.25);
const drift = (h: number) => (h - BEZUG.h + 540) % 360 - 180;
return VORLAGE.map(({ stop, l, c, h }) => {
const ton = (gewaehlt.h + drift(h) + 360) % 360;
const saettigung = Math.max(c * faktor, 0);
return {
stop,
value: `oklch(${l.toFixed(1)}% ${saettigung.toFixed(4)} ${ton.toFixed(3)})`,
};
});
}
/**
* Die Abstufung als CSS, fertig zum Einsetzen in einen `<style>`-Block.
*
* Überschrieben wird bewusst die Indigo-Reihe selbst — so wirkt die Farbe
* überall dort, wo die Oberfläche sie schon benutzt.
*/
export function accentCss(hex: string): string {
const zeilen = buildRamp(hex)
.map(({ stop, value }) => `--color-indigo-${stop}:${value};`)
.join("");
return `:root{${zeilen}}`;
}
+143
View File
@@ -0,0 +1,143 @@
import "server-only";
import { DEFAULT_ACCENT, isHexColor } from "@/lib/branding-colors";
import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module";
export type Branding = {
appName: string;
footerText: string;
accentColor: string;
logoUploadId: string | null;
faviconUploadId: string | null;
/** MIME-Typ des Favicons — der `<link>` braucht ihn. */
faviconMimeType: string | null;
};
export const DEFAULT_BRANDING: Branding = {
appName: "QR-Code-Generator",
footerText: "statische und dynamische QR-Codes",
accentColor: DEFAULT_ACCENT,
logoUploadId: null,
faviconUploadId: null,
faviconMimeType: null,
};
export const MAX_APP_NAME = 60;
export const MAX_FOOTER_TEXT = 120;
/**
* Bewusst **ohne** Zwischenspeicher.
*
* Ein erster Versuch hielt den Wert 15 Sekunden lang fest, um die Abfrage je
* Seitenaufruf zu sparen. Das ging schief: Die API-Route und das Layout
* laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
* die andere Instanz also gar nicht. Nach dem Speichern blieb die Oberfläche
* unverändert — der schlechteste denkbare Eindruck bei einer Einstellung, die
* man ansieht.
*
* Es bleibt bei einer Abfrage auf den Primärschlüssel einer Tabelle mit einer
* einzigen Zeile. Das ist zu verschmerzen; jede Seite fragt ohnehin mehr ab.
*/
export async function getBranding(): Promise<Branding> {
let wert = DEFAULT_BRANDING;
try {
const zeile = await prisma.branding.findUnique({
where: { id: "branding" },
include: {
faviconUpload: { select: { mimeType: true } },
},
});
if (zeile) {
wert = {
appName: zeile.appName,
footerText: zeile.footerText,
accentColor: isHexColor(zeile.accentColor)
? zeile.accentColor
: DEFAULT_ACCENT,
logoUploadId: zeile.logoUploadId,
faviconUploadId: zeile.faviconUploadId,
faviconMimeType: zeile.faviconUpload?.mimeType ?? null,
};
}
} catch (error) {
// Ein Problem mit der Datenbank darf nicht dazu führen, dass gar keine
// Seite mehr erscheint — dann eben im Auslieferungszustand.
console.error("Erscheinungsbild konnte nicht geladen werden:", error);
}
return wert;
}
export type BrandingUpdate = {
appName?: string;
footerText?: string;
accentColor?: string;
/** `null` entfernt das Bild, `undefined` lässt es unverändert. */
logoUploadId?: string | null;
faviconUploadId?: string | null;
};
/**
* Schreibt das Erscheinungsbild und räumt ersetzte Bilder weg.
*
* Ohne das Aufräumen bliebe jedes einmal hochgeladene Logo für immer in der
* Datenbank liegen: Es gehört zu keinem Code, und die Aufräumroutine für
* herrenlose Dateien lässt „branding" bewusst in Ruhe.
*/
export async function saveBranding(update: BrandingUpdate): Promise<Branding> {
if (update.accentColor !== undefined && !isHexColor(update.accentColor)) {
throw new QrRequestError(
"Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
);
}
const vorher = await prisma.branding.findUnique({
where: { id: "branding" },
select: { logoUploadId: true, faviconUploadId: true },
});
const daten = {
...(update.appName !== undefined ? { appName: update.appName.trim() } : {}),
...(update.footerText !== undefined
? { footerText: update.footerText.trim() }
: {}),
...(update.accentColor !== undefined
? { accentColor: update.accentColor.trim().toUpperCase() }
: {}),
...(update.logoUploadId !== undefined
? { logoUploadId: update.logoUploadId }
: {}),
...(update.faviconUploadId !== undefined
? { faviconUploadId: update.faviconUploadId }
: {}),
};
await prisma.branding.upsert({
where: { id: "branding" },
update: daten,
create: { id: "branding", ...daten },
});
// Die ersetzten Dateien erst danach löschen — solange die neue Zeile nicht
// steht, wird noch die alte gebraucht.
const abgeloest = [
update.logoUploadId !== undefined &&
vorher?.logoUploadId &&
vorher.logoUploadId !== update.logoUploadId &&
vorher.logoUploadId,
update.faviconUploadId !== undefined &&
vorher?.faviconUploadId &&
vorher.faviconUploadId !== update.faviconUploadId &&
vorher.faviconUploadId,
].filter((id): id is string => typeof id === "string");
if (abgeloest.length > 0) {
await prisma.upload
.deleteMany({ where: { id: { in: abgeloest } } })
.catch(() => undefined);
}
return getBranding();
}
+2
View File
@@ -31,6 +31,7 @@ export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null; group?: { name: string } | null;
domain?: { hostname: string } | null; domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null; createdBy?: { name: string | null; email: string } | null;
aliases?: { slug: string }[];
}; };
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
@@ -60,6 +61,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
return { return {
id: code.id, id: code.id,
slug: code.slug, slug: code.slug,
aliases: (code.aliases ?? []).map((alias) => alias.slug),
type: code.type as QrTypeId, type: code.type as QrTypeId,
label: code.label, label: code.label,
data, data,
+2
View File
@@ -8,6 +8,8 @@ import type { QrTypeId } from "@/lib/qr/type-module";
export type QrCodeDto = { export type QrCodeDto = {
id: string; id: string;
slug: string; slug: string;
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
aliases: string[];
type: QrTypeId; type: QrTypeId;
label: string | null; label: string | null;
data: Record<string, unknown>; data: Record<string, unknown>;
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { prisma } from "@/lib/db";
export type InstanceSettings = {
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
editorsMaySetSlug: boolean;
};
export const DEFAULT_SETTINGS: InstanceSettings = {
editorsMaySetSlug: false,
};
/**
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
*/
export async function getSettings(): Promise<InstanceSettings> {
try {
const zeile = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
});
return zeile
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
: DEFAULT_SETTINGS;
} catch (error) {
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
// Im Zweifel gilt die zurückhaltendere Einstellung.
console.error("Einstellungen konnten nicht geladen werden:", error);
return DEFAULT_SETTINGS;
}
}
export async function saveSettings(
update: Partial<InstanceSettings>,
): Promise<InstanceSettings> {
await prisma.instanceSetting.upsert({
where: { id: "instance" },
update,
create: { id: "instance", ...update },
});
return getSettings();
}
+91
View File
@@ -0,0 +1,91 @@
import "server-only";
import type { QrCode } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module";
import { isValidSlug, normalizeSlug } from "@/lib/slug";
/**
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
*
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
* geht über den exakten Index. Erst wenn er nichts findet, wird
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
*/
export async function findCodeBySlug(
domainId: string,
slug: string,
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
if (!isValidSlug(slug)) return null;
const exakt = await prisma.qrCode.findUnique({
where: { domainId_slug: { domainId, slug } },
});
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
const normalisiert = normalizeSlug(slug);
const egalGeschrieben = await prisma.qrCode.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
});
if (egalGeschrieben) {
return { code: egalGeschrieben, ueberFrueheAdresse: false };
}
const alias = await prisma.qrCodeAlias.findFirst({
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
include: { qrCode: true },
});
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
return null;
}
/**
* Ist dieser Kurz-Link auf dieser Domain noch frei?
*
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
* wegnehmen.
*/
export async function assertSlugAvailable(
domainId: string,
slug: string,
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
eigenerCodeId?: string,
): Promise<void> {
const normalisiert = normalizeSlug(slug);
const belegtVomCode = await prisma.qrCode.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtVomCode) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
409,
);
}
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
where: {
domainId,
slug: { equals: normalisiert, mode: "insensitive" },
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
},
select: { id: true },
});
if (belegtAlsAlias) {
throw new QrRequestError(
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
409,
);
}
}
+91 -3
View File
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8; export const SLUG_LENGTH = 8;
/** Muster, gegen das eingehende Slugs geprüft werden. */ /** Höchstens so viele durch `/` getrennte Abschnitte. */
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/; export const MAX_SLUG_SEGMENTS = 3;
/** Länge eines Abschnitts. */
export const MAX_SEGMENT_LENGTH = 48;
/** Länge des ganzen Kurz-Links. */
export const MAX_SLUG_LENGTH = 100;
export function generateSlug(length: number = SLUG_LENGTH): string { export function generateSlug(length: number = SLUG_LENGTH): string {
let slug = ""; let slug = "";
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
return slug; return slug;
} }
/**
* Vergleichsform eines Kurz-Links.
*
* Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer
* `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo
* `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen —
* beim Auflösen wie bei der Prüfung auf Eindeutigkeit.
*/
export function normalizeSlug(value: string): string {
return value.trim().toLowerCase();
}
/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Prüft einen Kurz-Link, wie er in der Adresse stehen darf.
*
* Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner —
* damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht**
* erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert),
* Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts.
*
* Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese
* Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde.
*
* Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und
* zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf
* denselben Code, und es gäbe zwei Adressen für dieselbe Sache.
*/
export function isValidSlug(value: string): boolean { export function isValidSlug(value: string): boolean {
return SLUG_PATTERN.test(value); const slug = normalizeSlug(value);
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) {
return false;
}
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) return false;
return abschnitte.every(
(abschnitt) =>
abschnitt.length > 0 &&
abschnitt.length <= MAX_SEGMENT_LENGTH &&
SEGMENT.test(abschnitt),
);
}
/**
* Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung,
* wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand
* zusieht und wissen will, was falsch war.
*/
export function describeSlugProblem(eingabe: string): string | null {
const roh = eingabe.trim();
if (roh.length === 0) return "Bitte gib einen Kurz-Link ein.";
if (/^\/|\/$/.test(roh)) {
return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden.";
}
if (roh.includes("//")) {
return "Zwei Schrägstriche hintereinander sind nicht möglich.";
}
if (roh.length > MAX_SLUG_LENGTH) {
return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`;
}
const slug = normalizeSlug(roh);
const abschnitte = slug.split("/");
if (abschnitte.length > MAX_SLUG_SEGMENTS) {
return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`;
}
for (const abschnitt of abschnitte) {
if (abschnitt.length > MAX_SEGMENT_LENGTH) {
return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`;
}
if (/^-|-$/.test(abschnitt)) {
return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen.";
}
if (!SEGMENT.test(abschnitt)) {
return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner.";
}
}
return null;
} }
+48 -8
View File
@@ -6,6 +6,15 @@
* Dateiarten und die Größenformatierung. * Dateiarten und die Größenformatierung.
*/ */
/** Bilder, die die Oberfläche selbst anzeigt. */
const BILD_TYPEN = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/avif",
] as const;
export const UPLOAD_KINDS = { export const UPLOAD_KINDS = {
pdf: { pdf: {
label: "PDF-Dokument", label: "PDF-Dokument",
@@ -14,21 +23,52 @@ export const UPLOAD_KINDS = {
}, },
image: { image: {
label: "Bild", label: "Bild",
mimeTypes: [ mimeTypes: BILD_TYPEN,
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/avif",
],
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif", accept: "image/png,image/jpeg,image/webp,image/gif,image/avif",
}, },
logo: {
label: "Logo",
mimeTypes: BILD_TYPEN,
accept: "image/png,image/jpeg,image/webp,image/avif",
// Ein Logo in der Kopfzeile ist ein paar Dutzend Kilobyte groß. Die
// Grenze soll verhindern, dass jemand ein Foto aus der Kamera hochlädt,
// das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024,
},
favicon: {
label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen.
mimeTypes: [...BILD_TYPEN, "image/x-icon"],
accept: "image/png,image/x-icon,.ico,image/webp",
maxBytes: 512 * 1024,
},
} as const; } as const;
/**
* Wozu die Datei gehört.
*
* `code` hängt an einem QR-Code und verschwindet mit ihm; `branding` gehört
* zur Oberfläche und darf deshalb nicht als herrenlos weggeräumt werden.
*/
export const UPLOAD_PURPOSES = ["code", "branding"] as const;
export type UploadPurpose = (typeof UPLOAD_PURPOSES)[number];
export function uploadPurposeOf(kind: UploadKind): UploadPurpose {
return kind === "logo" || kind === "favicon" ? "branding" : "code";
}
/** Obergrenze dieser Art — sonst gilt die allgemeine aus MAX_UPLOAD_MB. */
export function kindMaxBytes(kind: UploadKind): number | null {
const eintrag = UPLOAD_KINDS[kind] as { maxBytes?: number };
return eintrag.maxBytes ?? null;
}
export type UploadKind = keyof typeof UPLOAD_KINDS; export type UploadKind = keyof typeof UPLOAD_KINDS;
export function isUploadKind(value: unknown): value is UploadKind { export function isUploadKind(value: unknown): value is UploadKind {
return value === "pdf" || value === "image"; return (
typeof value === "string" && Object.hasOwn(UPLOAD_KINDS, value)
);
} }
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
+19 -3
View File
@@ -4,7 +4,9 @@ import { prisma } from "@/lib/db";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
import { import {
formatBytes, formatBytes,
kindMaxBytes,
UPLOAD_KINDS, UPLOAD_KINDS,
uploadPurposeOf,
type UploadKind, type UploadKind,
} from "@/lib/uploads.shared"; } from "@/lib/uploads.shared";
@@ -13,6 +15,8 @@ export {
UPLOAD_KINDS, UPLOAD_KINDS,
formatBytes, formatBytes,
isUploadKind, isUploadKind,
kindMaxBytes,
uploadPurposeOf,
} from "@/lib/uploads.shared"; } from "@/lib/uploads.shared";
export type { UploadKind } from "@/lib/uploads.shared"; export type { UploadKind } from "@/lib/uploads.shared";
@@ -41,6 +45,10 @@ export function sniffMimeType(bytes: Uint8Array): string | null {
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8 if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8
// ICO: Reserviert 0, Typ 1, dann die Anzahl der Bilder (mindestens eines).
if (starts(0x00, 0x00, 0x01, 0x00) && (bytes[4] | bytes[5]) !== 0) {
return "image/x-icon";
}
// RIFF....WEBP // RIFF....WEBP
if ( if (
@@ -102,7 +110,10 @@ export async function storeUpload(
file: File, file: File,
kind: UploadKind, kind: UploadKind,
): Promise<StoredUpload> { ): Promise<StoredUpload> {
const limit = maxUploadBytes(); // Die engere der beiden Grenzen gewinnt: die allgemeine aus MAX_UPLOAD_MB
// und die der Dateiart.
const eigene = kindMaxBytes(kind);
const limit = eigene === null ? maxUploadBytes() : Math.min(eigene, maxUploadBytes());
if (file.size === 0) { if (file.size === 0) {
throw new QrRequestError("Die Datei ist leer."); throw new QrRequestError("Die Datei ist leer.");
} }
@@ -126,6 +137,8 @@ export async function storeUpload(
throw new QrRequestError( throw new QrRequestError(
kind === "pdf" kind === "pdf"
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch." ? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
: kind === "favicon"
? "Das ist kein unterstütztes Bild. Erlaubt sind PNG, ICO, WebP, JPEG, GIF und AVIF."
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.", : "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
); );
} }
@@ -134,11 +147,12 @@ export async function storeUpload(
data: { data: {
filename: sanitizeFilename( filename: sanitizeFilename(
file.name, file.name,
kind === "pdf" ? "dokument.pdf" : "bild", kind === "pdf" ? "dokument.pdf" : kind === "favicon" ? "favicon" : "bild",
), ),
mimeType: erkannt, mimeType: erkannt,
size: bytes.byteLength, size: bytes.byteLength,
data: Buffer.from(bytes), data: Buffer.from(bytes),
purpose: uploadPurposeOf(kind),
}, },
select: { id: true, filename: true, mimeType: true, size: true }, select: { id: true, filename: true, mimeType: true, size: true },
}); });
@@ -188,7 +202,9 @@ const ORPHAN_HOURS = 24;
export async function cleanupOrphanedUploads(): Promise<number> { export async function cleanupOrphanedUploads(): Promise<number> {
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000); const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
const { count } = await prisma.upload.deleteMany({ const { count } = await prisma.upload.deleteMany({
where: { qrCodeId: null, createdAt: { lt: grenze } }, // Nur Dateien, die zu einem Code gehören sollten. Logo und Favicon haben
// nie einen Code und wären sonst nach einem Tag verschwunden.
where: { qrCodeId: null, purpose: "code", createdAt: { lt: grenze } },
}); });
return count; return count;
} }
+35 -2
View File
@@ -70,10 +70,43 @@ BRANCH="$(git rev-parse --abbrev-ref HEAD)"
info "Hole neuen Stand von origin/$BRANCH …" info "Hole neuen Stand von origin/$BRANCH …"
git fetch --quiet origin git fetch --quiet origin
# --ff-only: lieber abbrechen als eine Merge-Situation erzeugen, die auf einem # --ff-only: lieber abbrechen als eine Merge-Situation erzeugen, die auf einem
# Server niemand auflösen will. # Server niemand auflösen will.
git merge --ff-only --quiet "origin/$BRANCH" || if ! git merge --ff-only --quiet "origin/$BRANCH" 2>/dev/null; then
fail "Der lokale Stand lässt sich nicht ohne Merge aktualisieren. Bitte von Hand prüfen." echo "" >&2
echo "✗ Der lokale Stand lässt sich nicht ohne Merge aktualisieren." >&2
echo "" >&2
if git merge-base --is-ancestor HEAD "origin/$BRANCH" 2>/dev/null; then
# Kann eigentlich nicht sein — dann wäre das Vorspulen geglückt.
echo " Grund: unklar. Bitte 'git status' und 'git log --oneline -5' ansehen." >&2
elif ! git merge-base HEAD "origin/$BRANCH" >/dev/null 2>&1; then
# Kein gemeinsamer Vorfahre: Die Historie wurde neu geschrieben, etwa
# durch einen Force-Push. Der Inhalt ist derselbe, die Commits haben
# nur neue Kennungen.
cat >&2 <<'HINWEIS'
Grund: Der Verlauf auf dem Server wurde neu geschrieben (Force-Push).
Alte und neue Commits haben keinen gemeinsamen Vorfahren mehr.
Der lokale Stand lässt sich verwerfen und durch den vom Server ersetzen:
git fetch origin
git reset --hard origin/main
./update.sh --force
Das betrifft nur versionierte Dateien. Die .env und alles, was nicht im
Repository liegt, bleibt unberührt.
HINWEIS
else
echo " Grund: Hier liegen eigene Commits, die es auf dem Server nicht gibt:" >&2
git --no-pager log --oneline "origin/$BRANCH..HEAD" | sed 's/^/ /' >&2
echo "" >&2
echo " Entweder pushen — oder mit 'git reset --hard origin/$BRANCH' verwerfen." >&2
fi
echo "" >&2
exit 1
fi
AFTER="$(git rev-parse HEAD)" AFTER="$(git rev-parse HEAD)"