28 Commits
Author SHA1 Message Date
florian.wilde b96e9ddbe5 Merge #9: Versionshinweis mit Aenderungsverlauf 2026-08-21 00:06:30 +00:00
Florian Wilde 586e1f042f Versionshinweis mit Aenderungsverlauf
Bisher liess sich der laufenden Anwendung nicht ansehen, welche Fassung sie
ist. Nach einem Update war damit auch nicht zu erkennen, ob es angekommen
ist — die haeufigste Frage im Betrieb.

Die Fussleiste nennt jetzt die Version und fuehrt auf /version mit dem
vollstaendigen Verlauf. Zusammengetragen ist er aus dem, was es schon gab:
dem Abschnitt „Gebaut" der Roadmap und der Git-Historie.

Gepflegt wird der Verlauf an genau einer Stelle, src/lib/changelog.ts. Daraus
entsteht sowohl die Seite als auch die CHANGELOG.md (npm run changelog); eine
Pruefung mit --check wacht darueber, dass beides zusammenpasst. Zwei gepflegte
Listen liefen unweigerlich auseinander, und ein Changelog, der nicht stimmt,
ist schlimmer als keiner.

Die Historie hatte keine Tags. Die Eintraege sind deshalb nach Erscheinungstag
zu Versionen zusammengefasst — die Nummern sind nachtraeglich vergeben, die
Daten stammen aus den Commits. Zu jeder Version gibt es jetzt einen
annotierten Tag im Repo; v1.6.0 folgt beim Merge.

Die Roadmap fuehrt den Verlauf nicht mehr doppelt, sondern verweist darauf und
behaelt den Blick nach vorn.

Refs #9
2026-08-21 01:58:24 +02:00
florian.wilde 7f67a35218 Merge #2: Statische Codes benennen und auf Wunsch ablegen 2026-08-20 23:26:45 +00:00
Florian Wilde eeaf3820b7 Statische Codes benennen und auf Wunsch ablegen
Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen
Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu
geben.

Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den
Dateinamen beim Herunterladen — aus „Plakat Eingang" wird
qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich
damit ein statischer Code in „Meine Codes" ablegen.

Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel.
Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim
Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen
Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der
Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt
aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt
als eigener Schnappschuss.

Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL
schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die
Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert.

Refs #2
2026-08-20 19:33:49 +02:00
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
Florian Wilde 0ec532296d Sicherheitsbericht: warum es keine E-Mail-Verifizierung gibt
Die Frage kam auf und die Antwort gehoert festgehalten, sonst stellt sie sich
in einem halben Jahr wieder — dann ohne die Begruendung.

Kurz: Es gibt keine Selbstregistrierung. Konten legt ein Administrator an, der
die Adresse selbst eintraegt; eine Bestaetigungsmail wuerde also niemanden
hereinlassen und niemanden fernhalten. Und weil es keinen Reset per E-Mail
gibt, ist die unbestaetigte Adresse auch kein Uebernahmeweg. Noetig wuerde sie
in dem Moment, in dem sich Leute selbst registrieren duerfen.

Dazu das Zeitfenster der Ersteinrichtung ausdruecklich benannt: Das erste
Konto entsteht ohne Legitimation und ist gleich Administrator. Dagegen hilft
keine Verifizierung, sondern nur die Reihenfolge beim Aufsetzen — oder ein
einmaliges Token, das jetzt auf der Roadmap steht.
2026-08-15 11:06:08 +02:00
Florian Wilde 75252b32b4 Anmeldung und Sitzungen nach OWASP durchgesehen und gehaertet
Zehn Befunde, alle behoben. Die wichtigsten:

scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17.
Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden
still erneuert — das ist der einzige Moment, in dem das Klartextpasswort
vorliegt.

Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette
scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128
Zeichen, geprueft vor dem Hashen.

Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich,
dass das allein nicht genuegt — besonders beim Upload, der multipart
entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt
wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage
geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains).

Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und
per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options,
nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg.

Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette
durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS
neben SESSION_IDLE_DAYS, beide einstellbar.

Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde
Konten bei der Anmeldung, eine Bremse je Absender gegen breites
Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen
werden aufgeraeumt.

Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention
umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert
Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst
unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst.

Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
2026-08-15 08:31:29 +02:00
Florian Wilde 1064915d37 Startseite: „Beliebt" ersetzt durch „nur dynamisch"
Das Abzeichen stammte aus Version 1, als genau ein Typ nutzbar war. Seit alle
15 gebaut sind, stand es an jeder Kachel und sagte damit nichts.

An seiner Stelle steht jetzt nur noch dort etwas, wo es die Wahl beeinflusst:
sechs Typen brauchen zwingend eine Kurz-URL und funktionieren deshalb nicht
ohne diesen Server. Die uebrigen neun Kacheln bleiben unbeschriftet.

Die Badge-Variante hiess "popular" und wird an drei weiteren Stellen als
Hervorhebung benutzt — sie heisst jetzt "accent", weil sie mit Beliebtheit
nichts zu tun hat.

Dazu die Roadmap neu geordnet: Typen je Gruppe, Statistik, Papierkorb, Logo
im Code, Ablaufdatum — in dieser Reihenfolge, jeweils mit dem, was dabei zu
entscheiden ist.
2026-08-15 07:39:20 +02:00
Florian Wilde 7fcdcba694 Funktionsuebersicht und Roadmap
Kurz im README: was die Anwendung kann, auf einen Blick. Lang in
docs/funktionen.md: jeder Typ mit seinen Grenzen, dazu Landingpages, Dateien,
Feedback, Rechte, Single Sign-on, Domains und Betrieb — samt der Frage, warum
manches so entschieden ist.

docs/roadmap.md haelt fest, was in welcher Reihenfolge gebaut wurde, und
sammelt Vorschlaege fuer danach. Bewusst als Vorschlaege mit grober
Aufwandsschaetzung, nicht als Zusage: was zuerst kommt, entscheidet, was im
Betrieb fehlt.
2026-08-15 07:15:25 +02:00
Florian Wilde 055d8caaa0 README: Ausblick durch den tatsaechlichen Stand ersetzt
Alle 15 Typen sind gebaut — der Abschnitt beschrieb noch die Planung. Statt
dessen: wie ein weiterer Typ dazukommt, und was als Naechstes sinnvoll waere.
2026-08-15 00:33:18 +02:00
Florian Wilde e4ff55a6c9 Bedienungsanleitung mit Bildern
docs/anleitung.md fuehrt vom ersten Code bis zur Auswertung von
Rueckmeldungen: statisch gegen dynamisch, Gestaltung, Meine Codes, die fuenf
Typen mit eigener Seite, Verwaltung, Konto und haeufige Fragen.

Die Bilder erzeugt docs/screenshots.mjs. Von Hand aufgenommene Bilder
veralten beim ersten Umbau — so sind sie in einer Minute neu erzeugt, samt
Beispieldaten, die das Skript hinterher wieder entfernt.
2026-08-15 00:26:46 +02:00
Florian Wilde c8d60d8006 Anmeldung ueber Authentik und andere OIDC-Anbieter
Anmeldefluss mit PKCE (S256), State und Nonce. Das ID-Token wird gegen den
JWKS des Anbieters geprueft — Signatur, Aussteller, Empfaenger, Ablauf und
Nonce; alles Noetige kommt aus dem Discovery-Dokument, damit die Anbindung
nicht auf Authentik festgelegt ist.

Wiedererkannt wird jemand an der Kennung des Anbieters (sub), nicht an der
E-Mail-Adresse — Adressen aendern sich. Ein bestehendes Konto mit derselben
Adresse wird verknuepft statt verdoppelt, aber nur bei bestaetigter Adresse:
sonst koennte sich beim Anbieter jemand eine fremde Adresse eintragen und
damit ein hiesiges Konto uebernehmen.

Uebernommen wird bewusst nur, wer jemand ist — plus die Instanz-Administration,
wenn OIDC_ADMIN_GROUP gesetzt ist. Gruppen und Rollen bleiben Sache dieser
Anwendung: die Gruppen im Verzeichnis bedeuten etwas anderes als die hier.

Konten aus dem Verzeichnis haben kein Passwort mehr (passwordHash ist jetzt
optional); Konto-Bereich und Passwort-Anmeldung sagen das statt an einer
leeren Pruefung zu scheitern.

Anleitung mit allen Feldern in Authentik: docs/authentik.md
2026-08-15 00:12:21 +02:00
Florian Wilde e296b1e6c2 Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Zwei Fehler dabei gefunden und behoben:

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

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

Dazu zwei Abschnitte, die vorher fehlten:

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

Erweiterungen am Typ-Vertrag, die dafuer noetig waren:

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

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

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

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

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

- Neue Tabelle QrTypeSetting; fehlender Eintrag heisst freigegeben, damit
  eine frische Installation ohne Vorbelegung laeuft und spaetere Typen
  automatisch erscheinen.
- Registry-Feld 'active' heisst jetzt 'implemented' und beschreibt nur noch,
  ob der Typ ausprogrammiert ist. Ob er erscheint, entscheidet zusaetzlich
  die Freigabe aus der Datenbank (src/lib/qr/availability.ts).
- Bestehende dynamische Codes bleiben von einer Abschaltung unberuehrt: die
  Weiterleitung funktioniert weiter und Bearbeiten bleibt moeglich. Ein
  gedruckter Code darf durch einen Schalter nicht ungueltig werden.
- resolveQrRequest ist dadurch asynchron geworden; QrRequestError liegt jetzt
  in type-module.ts, sonst gaebe es einen Ringschluss mit availability.ts.
- Startseite meldet jetzt auch den Fall, dass nur noch Platzhalter
  freigegeben sind - vorher standen dort 14 tote Kacheln ohne Erklaerung.
- Kopfzeile: Schriftzug auf schmalen Geraeten ausgeblendet, sonst passte die
  dritte Navigationsschaltflaeche nicht mehr in die Zeile (7px Ueberlauf).
2026-08-14 10:48:21 +02:00
169 changed files with 17290 additions and 572 deletions
+53
View File
@@ -13,6 +13,59 @@ BASE_URL=http://localhost:3000
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
APP_PORT=3000
# Anmeldung mit Benutzern, Gruppen und Rollen.
# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
# Administrator an.
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
AUTH_ENABLED=false
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
# grosse Werte machen die Sicherung entsprechend schwer.
MAX_UPLOAD_MB=10
# Sitzungsdauer in Tagen.
# SESSION_IDLE_DAYS — ohne Aktivitaet laeuft die Sitzung ab.
# SESSION_MAX_DAYS — harte Obergrenze ab dem Anmelden, unabhaengig davon,
# wie oft sie benutzt wurde.
# OWASP empfiehlt fuer oeffentliche Anwendungen deutlich kuerzere Werte
# (Minuten bzw. Stunden); siehe docs/sicherheit.md.
SESSION_IDLE_DAYS=30
SESSION_MAX_DAYS=90
# --- Anmeldung ueber Authentik oder einen anderen OIDC-Anbieter -------------
# Setzt AUTH_ENABLED=true voraus. Einrichtung siehe docs/authentik.md.
OIDC_ENABLED=false
# Basis-URL des Anbieters, unter der /.well-known/openid-configuration liegt.
# Bei Authentik: https://auth.example.com/application/o/<slug>/
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Beschriftung des Knopfs auf der Anmeldeseite.
OIDC_BUTTON_LABEL=Mit Authentik anmelden
# Angeforderte Bereiche. "openid" ist Pflicht, "email" wird gebraucht.
OIDC_SCOPES=openid email profile
# Nur noch ueber den Anbieter anmelden? Dann verschwindet das Passwortfeld.
# Vorsicht: Faellt der Anbieter aus, kommt niemand mehr hinein — zum Loesen
# hier auf false setzen und neu starten.
OIDC_ONLY=false
# Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen.
# VIEWER (nur lesen), EDITOR (Codes anlegen und aendern) oder ADMIN.
OIDC_DEFAULT_ROLE=VIEWER
# Gruppe beim Anbieter, deren Mitglieder die Verwaltung oeffnen duerfen.
# Leer lassen, um die Administratorrechte hier in der Anwendung zu pflegen.
OIDC_ADMIN_GROUP=
# Name des Anspruchs, in dem die Gruppen stehen (Authentik: groups).
OIDC_GROUPS_CLAIM=groups
# --- PostgreSQL ------------------------------------------------------------
POSTGRES_USER=qrcode
POSTGRES_PASSWORD=bitte-aendern
+74
View File
@@ -0,0 +1,74 @@
# Änderungen
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
Anwendung unter **Version und Änderungen**, erreichbar über die
Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.6.0 — 21.08.2026
Sichtbare Version mit Änderungsverlauf.
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
## 1.5.0 — 20.08.2026
Statische Codes lassen sich benennen und ablegen.
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
- Der Name bestimmt den Dateinamen beim Herunterladen.
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
## 1.4.0 — 17.08.2026
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
## 1.3.0 — 16.08.2026
Eigenes Erscheinungsbild und ein Dark Mode.
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
## 1.2.0 — 15.08.2026
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
## 1.1.0 — 14.08.2026
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
## 1.0.0 — 13.08.2026
Erste lauffähige Fassung.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
- Download als PNG und SVG, serverseitig gerendert.
- Update-Skript für Linux, macOS und Windows.
+679 -39
View File
@@ -3,47 +3,399 @@
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
**Version 1** unterstützt den QR-Typ **Website/URL**, statisch wie dynamisch.
Die übrigen 14 Typen sind bereits in der Oberfläche sichtbar und für Version 2
vorbereitet.
Umgesetzt sind **alle 15 QR-Typen**. Welche davon in der Oberfläche erscheinen,
entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
---
## Was die Anwendung kann — kurz
- **15 QR-Typen:** Website, vCard, App-Link, Mehrere Links, Social Media,
WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN, Feedback.
- **Statisch oder dynamisch.** Dynamische Codes zeigen auf eine Kurz-URL:
Ziel jederzeit änderbar, ohne neu zu drucken — mit Scan-Zähler.
- **Eigene Seiten** für Linksammlungen, Profile, hinterlegte PDFs und Bilder
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
ablegen, um sie wiederzufinden.
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
erhoben, ohne Name und IP-Adresse.
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
Administrator, Bearbeiter und Betrachter.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **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.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
→ Ausführlich: **[Alle Funktionen im Detail](docs/funktionen.md)**
---
## Anleitungen
- **[Bedienungsanleitung mit Bildern](docs/anleitung.md)** — vom ersten Code
bis zur Auswertung von Rückmeldungen
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
Härtungen und was bewusst offen bleibt
---
## Inhalt
- [Schnellstart](#schnellstart)
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
- [Hinter einem Reverse Proxy (Nginx Proxy Manager)](#hinter-einem-reverse-proxy-nginx-proxy-manager)
- [Wenn etwas nicht läuft](#wenn-etwas-nicht-läuft)
- [Aktualisieren](#aktualisieren)
- [Konfiguration](#konfiguration)
- [Bedienung](#bedienung)
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
- [Technischer Aufbau](#technischer-aufbau)
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
- [API](#api)
- [Ausblick Version 2](#ausblick-version-2)
- [Einen weiteren Typ ergänzen](#einen-weiteren-typ-ergänzen)
---
## Schnellstart
## Schritt für Schritt zum laufenden Stack
Voraussetzung: Docker mit Docker Compose.
Vom leeren Server bis zum ersten QR-Code. Alle Befehle laufen auf dem
Rechner, auf dem die Anwendung später läuft.
### Schritt 1 — Voraussetzungen prüfen
Gebraucht werden Docker mit Compose v2 und Git:
```bash
docker compose version # erwartet: Docker Compose version v2.x oder höher
git --version
```
Kommt `docker: command not found`, fehlt Docker — die
[offizielle Anleitung](https://docs.docker.com/engine/install/) installiert
Engine und Compose in einem Rutsch.
### Schritt 2 — Projekt holen
```bash
git clone https://git.wilde-server.de/florian.wilde/qrcode-generator.git
cd qrcode-generator
```
### Schritt 3 — Konfiguration anlegen
```bash
cp .env.example .env
nano .env
```
Zwei Werte **musst** du anfassen:
| Wert | Was eintragen |
| --- | --- |
| `POSTGRES_PASSWORD` | Ein eigenes Passwort. Es steht nur in dieser Datei und wird nirgends sonst gebraucht. |
| `DATABASE_URL` | Dasselbe Passwort noch einmal — es steckt mitten im Verbindungsstring. |
Und einen solltest du bewusst setzen:
| Wert | Was eintragen |
| --- | --- |
| `BASE_URL` | Die Adresse, unter der die Anwendung **von außen** erreichbar ist. |
Beispiel für ein eigenes Passwort `MeinGeheimes123`:
```bash
POSTGRES_PASSWORD=MeinGeheimes123
DATABASE_URL=postgresql://qrcode:MeinGeheimes123@db:5432/qrcode?schema=public
BASE_URL=http://192.168.1.50:3000
```
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
> umbiegen, also am besten vor dem ersten produktiven Code festlegen.
>
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
>
> | Wer scannt | Was in `BASE_URL` gehört |
> | --- | --- |
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
>
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
> Hinweis darauf, woran es liegt.
>
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
> über einen externen Anbieter, siehe
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
Servicename aus der `docker-compose.yml`, nicht dein Server.
### Schritt 4 — Starten
```bash
cp .env.example .env # anschließend POSTGRES_PASSWORD und BASE_URL anpassen
docker compose up -d --build
```
Danach ist die Anwendung unter <http://localhost:3000> erreichbar. Die
Datenbankmigrationen laufen beim Start des App-Containers automatisch — es
sind keine weiteren Schritte nötig.
Der erste Durchlauf baut das Image und dauert je nach Maschine **5 bis 15
Minuten**. Spätere Starts brauchen nur wenige Sekunden.
Nützliche Befehle:
### Schritt 5 — Prüfen, ob alles läuft
```bash
docker compose logs -f app # Protokoll der Anwendung
docker compose ps # Status beider Container
docker compose down # Stack stoppen (Daten bleiben erhalten)
docker compose down -v # Stack stoppen und Datenbank löschen
docker compose ps
```
Erwartet werden zwei Container, `db` mit dem Zusatz `(healthy)`:
```
NAME SERVICE STATUS
qrcode-generator-app-1 app Up 30 seconds
qrcode-generator-db-1 db Up 45 seconds (healthy)
```
Und im Protokoll:
```bash
docker compose logs app
```
```
→ Datenbankmigrationen werden angewendet …
All migrations have been successfully applied.
→ Anwendung wird gestartet …
▲ Next.js 16.3.0
✓ Ready
```
Die Datenbank richtet sich beim ersten Start selbst ein — es gibt keinen
Extraschritt dafür.
### Schritt 6 — Aufrufen
Im Browser die Adresse öffnen, die du als `BASE_URL` eingetragen hast, zum
Beispiel <http://192.168.1.50:3000>. Es erscheint „Was möchtest du
erstellen?“ mit den Typ-Kacheln.
### Schritt 7 — Ersten Code erstellen
1. Kachel **Website** anklicken.
2. Adresse eingeben, etwa `https://example.com` — die Vorschau erscheint
sofort.
3. **PNG** oder **SVG** herunterladen. Fertig.
Für einen Code, dessen Ziel du später ändern kannst:
1. Unter „Art des Codes“ auf **Dynamisch** wechseln.
2. Optional einen Namen vergeben, damit du ihn wiederfindest.
3. **Dynamischen Code speichern** — erst dabei entsteht die Kurz-URL, deshalb
ist der Download bis dahin gesperrt.
4. Danach herunterladen. Unter **Meine Codes** lässt sich das Ziel jederzeit
ändern, ohne den gedruckten Code anzufassen.
### Schritt 8 — Aufräumen, was du nicht brauchst
Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — 15
Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Ausgeblendete
Typen verschwinden aus der Auswahl; bereits erstellte Codes dieses Typs
funktionieren unverändert weiter.
### Die wichtigsten Befehle im Betrieb
```bash
docker compose logs -f app # Protokoll mitlesen (Abbruch mit Strg+C)
docker compose ps # Status beider Container
docker compose restart app # Anwendung neu starten
docker compose down # Stack stoppen — die Daten bleiben erhalten
docker compose up -d # wieder starten
docker compose down -v # Stack stoppen UND die Datenbank löschen
```
---
## Hinter einem Reverse Proxy (Nginx Proxy Manager)
Soll die Anwendung unter einer eigenen Domain mit HTTPS laufen, übernimmt der
Proxy die Zertifikate; die Anwendung selbst bleibt unverändert.
**1. Port nur lokal veröffentlichen.** Wenn Proxy und Anwendung auf demselben
Rechner laufen, muss Port 3000 nicht nach außen offen sein. In der `.env`:
```bash
APP_PORT=3000
BASE_URL=https://qr.deine-domain.de
```
**2. Im Nginx Proxy Manager** einen *Proxy Host* anlegen:
| Feld | Wert |
| --- | --- |
| Domain Names | `qr.deine-domain.de` |
| Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` |
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
| Block Common Exploits | an |
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
Läuft der Proxy als Container im selben Docker-Netz wie dieser Stack, kann
statt der IP auch der Servicename `app` mit Port `3000` stehen — dann muss
das Port-Mapping gar nicht nach außen offen sein.
**3. `BASE_URL` auf die öffentliche Adresse setzen** — mit `https://` und ohne
Port. Danach:
```bash
docker compose up -d
```
> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
> scheitert (siehe unten).
### Was der Proxy weiterreichen muss
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
sie eintragen.
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
| --- | --- | --- |
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
Für ein reines nginx (ohne NPM) heißt das:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
von sich aus.
### Größe der Uploads
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
Oberfläche.
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
**Advanced → Custom Nginx Configuration**:
```nginx
client_max_body_size 20m;
```
### Nachprüfen, ob es stimmt
```bash
# Kommt die Anwendung überhaupt an?
curl -I https://qr.deine-domain.de/
# Kennt der Container dieselbe Adresse wie der Browser?
docker compose exec app env | grep BASE_URL
```
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
- jeder neue dynamische Code zeigt auf die interne Adresse, und
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
### Erreichbar für wen?
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
öffentlich auflösbar und der Proxy von außen erreichbar sein.
### Mehrere Domains
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
lege sie unter **Verwaltung → Domains** an und richte im Proxy für jede einen
eigenen Proxy Host auf dieselbe Anwendung ein.
Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
404 — über einen gefälschten Header lässt sich also keine fremde Domain
ansprechen.
Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
Slug darf auf zwei Domains liegen und zeigt dort auf verschiedene Ziele.
- Eine Domain kann **mehreren Gruppen** dienen. Das heißt aber nicht, dass
diese die Codes der jeweils anderen sehen — die Übersicht bleibt strikt
nach Gruppe gefiltert.
`BASE_URL` bleibt dabei wichtig: sie liefert die Standard-Domain für Aufrufe
über eine nicht registrierte Adresse (etwa direkt über die IP im LAN) und
bestimmt, ob Kurz-URLs mit `http` oder `https` gebildet werden.
---
## Wenn etwas nicht läuft
**`POSTGRES_PASSWORD fehlt — bitte .env aus .env.example anlegen`**
Die `.env` fehlt oder ist leer. Schritt 3 nachholen.
**`ports are not available: ... bind: address already in use`**
Port 3000 ist belegt. Entweder den anderen Dienst beenden oder in der `.env`
einen anderen Port setzen, z. B. `APP_PORT=3100`, dann `docker compose up -d`.
**Der Container `db` bleibt `unhealthy` oder startet immer neu**
Meist ein Datenverzeichnis aus einer älteren PostgreSQL-Version. Protokoll
ansehen mit `docker compose logs db`. Wenn dort von einem inkompatiblen
Datenlayout die Rede ist und du die Daten nicht brauchst:
`docker compose down -v` — Achtung, das löscht die Datenbank.
**Der QR-Code führt auf dem Handy ins Leere**
`BASE_URL` zeigt auf `localhost`. Auf die LAN-Adresse oder Domain ändern,
`docker compose up -d`, und die betroffenen dynamischen Codes neu
herunterladen. Statische Codes sind davon nicht betroffen.
**Die Anwendung antwortet nicht, `docker compose ps` zeigt sie aber als `Up`**
`docker compose logs app` ansehen. Kommt dort ein Datenbankfehler, stimmt
meist das Passwort in `DATABASE_URL` nicht mit `POSTGRES_PASSWORD` überein —
beide Werte müssen identisch sein.
**Nach einem Update fehlt etwas oder die Seite ist leer**
`docker compose up -d --build` erzwingt einen Neubau. Hilft das nicht:
`docker compose down && docker compose up -d --build`.
---
## Aktualisieren
@@ -102,6 +454,7 @@ Alle Einstellungen stehen in der `.env` im Projektverzeichnis (Vorlage:
| ------------------- | ------------------------------------------------------------- | ------------------------ |
| `BASE_URL` | Öffentliche Adresse der App — landet in dynamischen QR-Codes | `http://localhost:3000` |
| `APP_PORT` | Host-Port, auf dem die App veröffentlicht wird | `3000` |
| `AUTH_ENABLED` | Anmeldung mit Benutzern, Gruppen und Rollen | `false` |
| `DATABASE_URL` | Verbindungsstring der App zur Datenbank | — |
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
| `POSTGRES_PASSWORD` | Datenbankpasswort (**muss gesetzt sein**) | — |
@@ -137,9 +490,52 @@ werden — der Hostname darin bleibt `db` (der Servicename aus
### 1. Typ wählen
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid aller
15 QR-Typen. In Version 1 ist **Website** aktiv (Badge „Beliebt“); die übrigen
Kacheln sind sichtbar, aber deaktiviert (Badge „Bald verfügbar“).
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der
freigegebenen QR-Typen. Alle 15 Typen sind umgesetzt.
| Typ | Kodiert als | Statisch | Dynamisch |
| --- | --- | :-: | :-: |
| Website | die URL selbst | ✓ | ✓ |
| E-Mail | `mailto:` mit Betreff und Text | ✓ | ✓ |
| SMS | `SMSTO:` | ✓ | ✓ |
| WhatsApp | `https://wa.me/…` | ✓ | ✓ |
| Standort | `geo:` — dynamisch über OpenStreetMap | ✓ | ✓ |
| Video | die Videoadresse — dynamisch mit Player-Seite | ✓ | ✓ |
| vCard | vCard 3.0 | ✓ | — |
| WLAN | `WIFI:` | ✓ | — |
| App-Link | Weiterleitung je nach Gerät | — | ✓ |
| Zahlung | GiroCode (EPC069-12) oder Zahlungslink | ✓ | nur Link |
| Mehrere Links | eigene Seite mit Linkliste | — | ✓ |
| Social Media | eigene Seite mit den Profilen | — | ✓ |
| PDF | hinterlegte Datei auf eigener Seite | — | ✓ |
| Bild | hinterlegte Bilder auf eigener Seite | — | ✓ |
| Feedback | Bewertungsformular auf eigener Seite | — | ✓ |
Warum manche Typen nur eine Betriebsart können:
- **vCard und WLAN** werden vom Gerät direkt ausgewertet — die Kamera legt
einen Kontakt an oder richtet das Netz ein. Über eine Kurz-URL würde
stattdessen nur ein Browser aufgehen.
- **App-Link** kann nicht statisch sein: welches Ziel richtig ist, steht erst
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus und schickt
iOS in den App Store, Android zu Google Play und alles Übrige auf die
Ausweichadresse.
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
dynamischer Code funktioniert daher nur die Variante „Zahlungslink“ — die
Anwendung weist das beim Speichern ausdrücklich ab, statt einen Code
entstehen zu lassen, der beim Scannen ins Leere führt.
- **Mehrere Links, Social Media, PDF, Bild und Feedback** brauchen eine Seite,
die die Anwendung selbst ausliefert — eine Linkliste oder ein Formular passt
nicht in einen einzelnen Code. Diese Seiten liegen unter `/s/{slug}`; der
gedruckte Code zeigt weiterhin auf `/r/{slug}` und wird dorthin
weitergeleitet. Der Scan-Zähler zählt dabei den Aufruf des Codes, nicht das
Neuladen der Seite.
> **Zum WLAN-Code:** Das Passwort steht im Klartext im Code — wer ihn scannt
> oder abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht ein
> Netzwerkname oder Passwort ausschließlich aus Hex-Ziffern (etwa `abc123`),
> setzt die Anwendung ihn wie in der Spezifikation vorgesehen in
> Anführungszeichen — sonst würden manche Geräte den Wert als Hexzahl deuten.
### 2. Statisch oder dynamisch
@@ -176,7 +572,33 @@ exakt das, was auch heruntergeladen wird.
**PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung
(Plakate, Schilder, Druckvorstufe).
### 5. Meine Codes
### 5. Verwaltung: welche Typen erscheinen
Unter **Verwaltung → QR-Typen** lässt sich jeder der 15 Typen einzeln
ein- und ausblenden. Ausgeblendete Typen verschwinden vollständig von der
Startseite und lassen sich nicht mehr neu anlegen — praktisch, um die noch
nicht umgesetzten Typen gar nicht erst zu zeigen.
Zwei Eigenschaften sind dabei zu unterscheiden:
| | Bedeutung | Woher |
| --- | --- | --- |
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code |
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
> nicht ungültig — gesperrt ist nur das Anlegen neuer Codes.
Ohne Eintrag in der Datenbank gilt ein Typ als freigegeben. Eine frische
Installation zeigt also alles, und Typen aus späteren Versionen erscheinen
automatisch.
### 6. Meine Codes
Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild,
Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan.
@@ -187,11 +609,213 @@ Dort lässt sich
- der Code **löschen** (mit Bestätigungsdialog),
- das Bild erneut als PNG oder SVG **herunterladen**.
**Liste oder Karten.** Voreingestellt ist die **Liste** — eine Zeile je Code,
gedacht für viele Projekte nebeneinander. Über die beiden Schalter rechts neben
der Sortierung wechselst du zur **Kartenansicht** mit großer Vorschau. Die Wahl
merkt sich der Browser.
Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht.
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
> Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine
> Benutzerverwaltung**. Wer die Anwendung erreicht, sieht und ändert alle
> Codes. Die App gehört daher nicht ungeschützt ins offene Internet.
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
---
### 7. Eigene Seiten: Links, Profile, Dateien, Feedback
Fünf Typen haben kein fremdes Ziel, auf das sich weiterleiten ließe — sie
brauchen eine Seite. Die liefert die Anwendung selbst aus, unter
`{BASE_URL}/s/{slug}`:
| Typ | Was die Seite zeigt |
| --- | --- |
| Mehrere Links | Überschrift, kurzer Text, bis zu 20 beschriftete Links |
| Social Media | dieselbe Form, aber mit Netzwerk-Auswahl aus einem Katalog |
| PDF | das hinterlegte Dokument mit „Öffnen“ und „Herunterladen“ |
| Bild | ein Bild oder eine Galerie aus bis zu 10 Bildern mit Bildunterschriften |
| Feedback | ein Formular mit Sternen oder Gesichtern und optionalem Kommentar |
Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung:
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer einen
Aushang scannt, soll nur den Inhalt sehen.
Der gedruckte Code zeigt weiterhin auf `/r/{slug}`; von dort geht es auf die
Seite. Der Scan-Zähler zählt dabei den Aufruf des Codes — ein Neuladen der
Seite zählt nicht mit.
**Bei Social Media** genügt als Eingabe das Kürzel: aus `@firma` wird beim
Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt
unverändert, damit auch abweichende Profil-Adressen möglich sind.
**Beim Video-Typ** wird der Player von YouTube oder Vimeo erst nach einem Klick
geladen, und davor steht, dass dabei Daten an den Anbieter gehen. Ungefragt
einen Drittanbieter einzubinden wäre auf einer Seite, die Fremde nach einem
Scan zu sehen bekommen, nicht in Ordnung.
#### Hochgeladene Dateien
PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt
es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
alles.
- Die Grenze steht in der `.env` unter `MAX_UPLOAD_MB` (Voreinstellung 10).
- Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF. **SVG bewusst nicht** —
eine SVG-Datei darf Skripte enthalten und würde von derselben Adresse wie die
Anwendung ausgeliefert.
- Geprüft wird der **Inhalt**, nicht die Dateiendung. Eine Textdatei mit dem
Namen `harmlos.pdf` wird abgewiesen.
- Wird ein Code gelöscht, verschwindet seine Datei mit ihm. Eine ausgetauschte
Datei und eine hochgeladene, aber nie gespeicherte werden nach 24 Stunden
aufgeräumt.
#### Rückmeldungen
Ein Feedback-Eintrag besteht aus einer Note von 1 bis 5 und einem optionalen
Text. Gespeichert wird **kein Name, keine IP-Adresse, keine Gerätekennung** —
wer einen Aushang scannt und eine Note vergibt, bleibt anonym. Gegen Fluten
gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender; die Adresse
dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends gespeichert.
> Ohne eingeschaltete Anmeldung sieht und ändert jeder, der die Anwendung
> erreicht, alle Codes. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt;
> ins offene Internet gehört die Anwendung dann nicht. Mit `AUTH_ENABLED=true`
> gibt es Benutzer, Gruppen und Rollen — siehe unten.
---
## Anmeldung, Gruppen und Rollen
Standardmäßig ist die Anmeldung **aus**: wer die Anwendung erreicht, darf
alles. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt. Sobald mehrere
Leute damit arbeiten, lässt sie sich einschalten.
### Einschalten
In der `.env`:
```bash
AUTH_ENABLED=true
```
Danach:
```bash
docker compose up -d
```
Beim nächsten Aufruf erscheint die **Ersteinrichtung** und du legst den
ersten Administrator an. Diese Seite schließt sich, sobald es einen Benutzer
gibt — danach führt sie zur normalen Anmeldung.
> **Fehlt die Zeile in deiner `.env`?** Die `.env` wird von `git pull` nicht
> angefasst — sie gehört bewusst nicht ins Repository. Wurde sie vor
> Einführung der Anmeldung angelegt, fehlt `AUTH_ENABLED` darin. Einfach
> ergänzen; ohne die Zeile gilt `false`.
Ob es angekommen ist, zeigt:
```bash
docker compose exec app env | grep AUTH_ENABLED
```
> **Aussperren ist nicht möglich.** Der Schalter steht bewusst in der `.env`
> und nicht in der Oberfläche. Wer den Zugang verliert, setzt
> `AUTH_ENABLED=false`, startet neu und kommt wieder an alles heran.
Bestehende Codes gehen dabei nicht verloren: die Migration legt eine Gruppe
**Standard** an und ordnet ihnen diese zu.
### Die vier Ebenen
| Ebene | Darf |
| --- | --- |
| **Instanz-Administrator** | alles: Benutzer, Gruppen, QR-Typen und sämtliche Codes |
| **Administrator** einer Gruppe | alles in seiner Gruppe, einschließlich Mitglieder und Rollen |
| **Bearbeiter** | Codes der Gruppe anlegen, ändern, löschen |
| **Betrachter** | Codes der Gruppe ansehen und herunterladen — sonst nichts |
Ein Benutzer kann in mehreren Gruppen sein, mit unterschiedlicher Rolle je
Gruppe. Er sieht ausschließlich die Codes der Gruppen, in denen er Mitglied
ist.
| Aktion | Betrachter | Bearbeiter | Admin der Gruppe | Instanz-Admin |
| --- | :-: | :-: | :-: | :-: |
| Codes sehen, Bilder herunterladen | ✓ | ✓ | ✓ | ✓ |
| Statische Codes erzeugen | ✓ | ✓ | ✓ | ✓ |
| Dynamische Codes anlegen und ändern | – | ✓ | ✓ | ✓ |
| Codes löschen | – | ✓ | ✓ | ✓ |
| Mitglieder und Rollen der Gruppe | – | – | ✓ | ✓ |
| Gruppen und Benutzer anlegen | – | – | – | ✓ |
| QR-Typen freigeben | – | – | – | ✓ |
### Was unabhängig von der Anmeldung bleibt
Die Weiterleitung unter `/r/{slug}` ist **immer öffentlich** — sonst könnte
niemand einen gedruckten Code scannen. Sie verrät nur das Ziel, nicht die
Verwaltung.
### Benutzer verwalten
Unter **Verwaltung → Benutzer**: Konten anlegen, Passwörter setzen, Konten
deaktivieren oder löschen.
- Passwörter werden mit **scrypt** gespeichert (Node-Standardbibliothek, kein
natives Modul — dadurch keine Abhängigkeit, die zur Container-Plattform
passen muss).
- Ein neues Passwort oder das Deaktivieren beendet **alle offenen Sitzungen**
des Betroffenen sofort.
- Nach acht Fehlversuchen ist ein Konto 15 Minuten gesperrt; ein Administrator
kann die Sperre vorzeitig aufheben.
- Der **letzte aktive Administrator** kann sich weder selbst deaktivieren noch
sich die Rechte entziehen noch gelöscht werden.
- Passwörter kann nur ein Administrator setzen. Einen Versand per E-Mail gibt
es bewusst nicht — das spart einen SMTP-Zugang.
### Gruppen verwalten
Unter **Verwaltung → Gruppen**: Gruppen anlegen und Mitglieder mit Rollen
zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
sonst führten gedruckte Codes ins Leere.
---
## Anmeldung über Authentik (OIDC)
Statt Passwörter hier zu pflegen, lässt sich die Anmeldung an einen
OpenID-Connect-Anbieter abgeben — getestet mit **Authentik**, ebenso nutzbar
mit Keycloak, Zitadel oder Entra ID.
```bash
AUTH_ENABLED=true
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
OIDC_CLIENT_ID=…
OIDC_CLIENT_SECRET=…
```
Auf der Anmeldeseite erscheint dann ein Knopf für den Anbieter; das
Passwortfeld bleibt daneben stehen, bis `OIDC_ONLY=true` gesetzt wird.
**Übernommen wird, wer jemand ist** — Kennung, E-Mail-Adresse, Anzeigename —
und auf Wunsch über `OIDC_ADMIN_GROUP` das Recht zur Verwaltung. Gruppen und
Rollen bleiben Sache dieser Anwendung: Die Gruppen im Verzeichnis bedeuten
etwas anderes als die hier, und eine automatische Übersetzung wäre eine Quelle
stiller Überraschungen.
Bestehende Konten mit Passwort werden beim ersten Anmelden anhand der
**bestätigten** E-Mail-Adresse verknüpft, nicht verdoppelt.
→ **Vollständige Anleitung mit allen Feldern in Authentik, Fehlerbildern und
dem Weg zurück: [docs/authentik.md](docs/authentik.md)**
---
@@ -340,6 +964,8 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
Beispiel:
@@ -357,25 +983,39 @@ curl -X POST http://localhost:3000/api/codes \
---
## Ausblick Version 2
## Einen weiteren Typ ergänzen
Geplant sind die übrigen 14 Typen: vCard, App-Link, Mehrere Links, Social
Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN und
Feedback.
Die Architektur ist darauf ausgelegt — ein neuer Typ besteht aus:
Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er
aus:
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
3. **je einem Eintrag** in `src/lib/qr/registry.ts` (ersetzt dort den
`defineComingSoonType`-Platzhalter) und in
`src/components/qr-forms/index.ts`.
3. **je einem Eintrag** in `src/lib/qr/registry.ts` und in
`src/components/qr-forms/index.ts`,
4. **einer Landingpage** in `src/components/landing/`, falls er `hasLandingPage`
meldet.
Am bestehenden Code aus Version 1 ist dafür nichts zu ändern: Vorschau,
Download, Gestaltung, Speicherung dynamischer Codes und die Weiterleitung
arbeiten für alle Typen gleich.
Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung,
Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen
gleich. Die Kennung gehört zusätzlich in `QR_TYPE_IDS` in
`src/lib/qr/type-module.ts`.
Typen, die statt einer Weiterleitung eine eigene Landingpage brauchen (etwa
„Mehrere Links“ oder „Feedback“), setzen dafür `redirectTarget` auf eine
interne Adresse — auch dafür ist keine Änderung am Datenmodell nötig, weil
`data` als JSONB gespeichert wird.
Für einen angekündigten, aber noch nicht gebauten Typ gibt es
`defineComingSoonType`: Er erscheint als Kachel mit dem Badge „Bald
verfügbar“ und lässt sich nicht öffnen.
Braucht der Typ eine hochgeladene Datei, meldet er ihre Ids über `uploadIds` —
daran hängt, was beim Löschen des Codes mitgeht und was als verwaist
weggeräumt wird.
### Was als Nächstes sinnvoll wäre
- **Gruppen aus dem Verzeichnis übernehmen.** Die OIDC-Anbindung übernimmt
heute bewusst nur die Identität. Eine Zuordnung von Verzeichnisgruppen zu
Gruppen dieser Anwendung wäre möglich, braucht aber eine klare Regel dafür,
was mit von Hand vergebenen Rollen passiert.
- **Statistik über die Zeit.** Der Zähler weiß heute nur, wie oft und wann
zuletzt. Ein Verlauf je Tag wäre eine eigene Tabelle — und die Frage, wie
lange man ihn aufbewahrt.
- **Codes exportieren.** Eine CSV aller Codes einer Gruppe, für die Übergabe
an Druckereien.
+20
View File
@@ -28,9 +28,29 @@ services:
db:
condition: service_healthy
environment:
# Achtung beim Ergänzen: Compose liest die .env nur, um Platzhalter wie
# ${BASE_URL} zu ersetzen. In den Container gelangt ausschließlich, was
# hier aufgeführt ist — eine neue Variable in der .env allein bleibt
# wirkungslos.
#
# Öffentliche Adresse der App — landet in jedem dynamischen QR-Code.
BASE_URL: ${BASE_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL}
# Anmeldung mit Benutzern, Gruppen und Rollen.
AUTH_ENABLED: ${AUTH_ENABLED:-false}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_BUTTON_LABEL: ${OIDC_BUTTON_LABEL:-Mit Authentik anmelden}
OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile}
OIDC_ONLY: ${OIDC_ONLY:-false}
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-VIEWER}
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
ports:
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
- "${APP_PORT:-3000}:3000"
+322
View File
@@ -0,0 +1,322 @@
# Bedienungsanleitung
Diese Anleitung führt durch die tägliche Arbeit mit dem QR-Code-Generator —
vom ersten Code bis zur Auswertung von Rückmeldungen. Wie der Stack
aufgesetzt wird, steht im [README](../README.md); die Anmeldung über
Authentik in [authentik.md](authentik.md).
> Die Bilder zeigen die Oberfläche mit eingeschalteter Anmeldung. Ohne
> Anmeldung fehlen Kopfzeile rechts und der Bereich „Verwaltung“, alles
> Übrige ist gleich.
---
## Inhalt
- [Der schnellste Weg zum Code](#der-schnellste-weg-zum-code)
- [Statisch oder dynamisch — die wichtigste Entscheidung](#statisch-oder-dynamisch--die-wichtigste-entscheidung)
- [Gestaltung](#gestaltung)
- [Meine Codes](#meine-codes)
- [Die Typen mit eigener Seite](#die-typen-mit-eigener-seite)
- [Feedback einsammeln und auswerten](#feedback-einsammeln-und-auswerten)
- [Verwaltung](#verwaltung)
- [Konto](#konto)
- [Häufige Fragen](#häufige-fragen)
---
## Der schnellste Weg zum Code
Die Startseite fragt „Was möchtest du erstellen?“ und zeigt alle
freigegebenen Typen als Kacheln.
![Startseite mit den QR-Typen](bilder/01-startseite.png)
Ein Klick auf **Website**, die Adresse eintippen — die Vorschau erscheint
sofort — und mit **PNG** oder **SVG** herunterladen. Fertig.
![Website-Formular mit Vorschau](bilder/02-website-formular.png)
Links stehen Inhalt, Art des Codes und Gestaltung, rechts die Vorschau mit
den beiden Download-Knöpfen. Die Vorschau aktualisiert sich bei jeder
Änderung.
**PNG oder SVG?**
| | PNG | SVG |
| --- | --- | --- |
| Wofür | Bildschirm, Office, Web | Druck, große Formate, Schilder |
| Größe | fest (256–2048 px) | frei skalierbar ohne Qualitätsverlust |
| Faustregel | wenn du es einfügst | wenn es jemand druckt |
---
## Statisch oder dynamisch — die wichtigste Entscheidung
| | Statisch | Dynamisch |
| --- | --- | --- |
| Im Code steht | die Zieladresse selbst | eine Kurz-URL dieser Anwendung |
| Ziel später änderbar | **nein** | **ja**, jederzeit |
| Scans werden gezählt | nein | ja |
| Braucht diesen Server | nein, nie | ja, dauerhaft |
| Code ist | dichter (mehr Daten) | schlanker, leichter scannbar |
**Nimm statisch,** wenn die Adresse feststeht und nie wieder geändert wird —
und wenn der Code auch dann noch funktionieren soll, wenn es diese Anwendung
nicht mehr gibt.
**Nimm dynamisch,** wenn etwas gedruckt wird. Ein Aufsteller, ein Flyer, ein
Schild am Werkstor: Solange der Code dynamisch ist, kannst du das Ziel
umbiegen, ohne neu zu drucken.
![Umschalten auf einen dynamischen Code](bilder/03-dynamisch.png)
Beim Umschalten auf **Dynamisch** erscheint ein Feld für den Namen und der
Knopf **Dynamischen Code speichern**. Erst beim Speichern entsteht die
Kurz-URL — deshalb ist der Download bis dahin gesperrt.
> **Der Name ist nur für dich.** Er steht nirgends im Code und sieht ihn
> niemand, der scannt. Er hilft dir, den Code später in der Liste
> wiederzufinden — bei vielen Projekten ist er Gold wert.
---
## Gestaltung
Farben, Größe, Fehlerkorrektur und Rand gelten für Vorschau und Download
gleichermaßen.
**Farben.** Fünf Vorschläge stehen bereit, eigene Werte gehen über den
Farbwähler oder als Hex-Wert. Wird der Kontrast zu schwach, warnt die
Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem. Faustregel:
dunkler Vordergrund auf hellem Hintergrund. Umgekehrt kommen viele
Scanner-Apps nicht zurecht.
**Größe.** 256 bis 2048 Pixel, betrifft nur den PNG-Download. Für Druck lieber
SVG nehmen und die Größe im Layoutprogramm bestimmen.
**Fehlerkorrektur.** Wie viel vom Code beschädigt sein darf, ohne dass er
unlesbar wird:
| Stufe | Verträgt | Wofür |
| --- | --- | --- |
| L | ~7 % | saubere Bildschirmanzeige |
| **M** | ~15 % | **Voreinstellung, für fast alles richtig** |
| Q | ~25 % | Aufkleber, die abgerieben werden |
| H | ~30 % | Außenbereich, Logo mitten im Code |
Höhere Stufen machen den Code dichter — er braucht mehr Fläche, um noch
gescannt zu werden.
**Rand.** Die weiße Zone rundherum. Vier Module sind Standard; weniger kann
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
trennen kann.
---
## Meine Codes
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
werden nicht gespeichert.
![Meine Codes als Liste](bilder/04-meine-codes-liste.png)
Die **Liste** ist die Voreinstellung: eine Zeile je Code, gedacht für viele
Projekte nebeneinander.
- Das **Suchfeld** durchsucht Name, Ziel, Kurz-URL und Typ.
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
liegen.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
Vorschau. Die Wahl merkt sich der Browser.
![Meine Codes als Karten](bilder/05-meine-codes-karten.png)
### Ziel ändern
![Dialog zum Ändern des Ziels](bilder/06-ziel-bearbeiten.png)
Das ist der eigentliche Sinn dynamischer Codes: **Die Kurz-URL bleibt, der
gedruckte Code bleibt gültig, nur das Ziel wechselt.** Die Änderung wirkt
sofort — beim nächsten Scan.
### Löschen
Löschen fragt nach und nennt den betroffenen Code. Danach führt der gedruckte
Code ins Leere und zeigt eine freundliche Seite „Diese Seite gibt es nicht“.
Ein gelöschter Code lässt sich nicht wiederherstellen.
---
## Die Typen mit eigener Seite
Fünf Typen leiten nicht auf eine fremde Adresse weiter, sondern auf eine
Seite, die diese Anwendung selbst ausliefert: **Mehrere Links**, **Social
Media**, **PDF**, **Bild** und **Feedback**.
Diese Seiten sind öffentlich und zeigen bewusst nichts von der Anwendung —
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer
einen Aushang scannt, soll nur den Inhalt sehen.
### Mehrere Links
![Landingpage mit mehreren Links](bilder/07-landing-links.png)
Eine Überschrift, ein kurzer Text und bis zu 20 beschriftete Links. Mit den
Pfeilen im Formular lässt sich die Reihenfolge ändern — das oberste wird am
häufigsten geklickt, also gehört das Wichtigste nach oben.
### Social Media
![Landingpage mit Social-Media-Profilen](bilder/08-landing-social.png)
Dasselbe Prinzip, aber mit einem Katalog von Netzwerken. Als Eingabe genügt
das Kürzel: Aus `@firma` wird beim Speichern `https://instagram.com/firma`.
Eine vollständige Adresse bleibt unverändert, damit auch abweichende
Profil-Adressen möglich sind. Jedes Netzwerk kann nur einmal vorkommen.
### PDF und Bild
![Landingpage mit hinterlegtem PDF](bilder/09-landing-pdf.png)
Beim PDF-Typ lädst du ein Dokument hoch; die Seite zeigt Titel, Dateiname,
Größe und zwei Knöpfe: **Dokument öffnen** und **Herunterladen**.
Beim Bild-Typ sind es bis zu zehn Bilder mit Bildunterschriften, untereinander
als Galerie.
Beides lässt sich später austauschen, **ohne den Code neu zu drucken** — genau
wie ein geändertes Ziel. Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF
bis zur eingestellten Grenze (Voreinstellung 10 MB, siehe `MAX_UPLOAD_MB`).
> SVG wird bewusst nicht angenommen: Eine SVG-Datei darf Skripte enthalten,
> und sie würde von derselben Adresse ausgeliefert wie die Anwendung selbst.
### Video
Der Video-Typ erkennt YouTube, Vimeo und direkte Videodateien. Auf der Seite
erscheint ein Abspieler — der Rahmen zum Anbieter lädt aber **erst nach einem
Klick**, und davor steht, dass dabei Daten an den Anbieter gehen.
Video ist der einzige dieser Typen, der auch **statisch** funktioniert: Dann
steht die Videoadresse direkt im Code und führt ohne Umweg dorthin.
---
## Feedback einsammeln und auswerten
![Feedback-Formular auf dem Handy](bilder/09b-landing-feedback.png)
Der Feedback-Typ erzeugt ein Formular mit fünf Sternen oder fünf Gesichtern
und wahlweise einem Textfeld. Alle Texte — Überschrift, Frage, Beschriftung
des Textfelds, Dank nach dem Absenden — lassen sich frei formulieren.
Gespeichert werden nur die Note und der Text. **Kein Name, keine IP-Adresse,
keine Gerätekennung.** Wer einen Aushang scannt und eine Note vergibt, bleibt
anonym — das steht auch für die Scannenden sichtbar unter dem Formular.
![Auswertung der Rückmeldungen](bilder/10-feedback-auswertung.png)
Die Auswertung erreichst du in „Meine Codes“ über das Stern-Symbol in der
Zeile. Sie zeigt den Durchschnitt, die Verteilung der Noten und alle
Kommentare mit Datum.
**Ein praktischer Hinweis:** Ein Code pro Ort. Zwei Codes — einer im
Gastraum, einer am Ausgang — sagen mehr als einer für beides, weil du die
Auswertungen getrennt lesen kannst.
---
## Verwaltung
Der Bereich **Verwaltung** erscheint nur für Administratoren der Installation.
![Übersicht der Verwaltung](bilder/11-verwaltung.png)
### QR-Typen
![QR-Typen freigeben oder abschalten](bilder/12-verwaltung-typen.png)
15 Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Hier lassen
sich Typen abschalten; sie verschwinden dann aus der Auswahl.
**Bereits erstellte Codes eines abgeschalteten Typs funktionieren
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
### Gruppen
![Gruppen verwalten](bilder/13-verwaltung-gruppen.png)
Eine Gruppe bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
gehört genau einer Gruppe; wer nicht in der Gruppe ist, sieht ihn nicht.
Die drei Rollen je Gruppe:
| Rolle | Darf |
| --- | --- |
| **Betrachter** | Codes sehen und herunterladen |
| **Bearbeiter** | zusätzlich anlegen, ändern und löschen |
| **Administrator** | zusätzlich Mitglieder und deren Rollen verwalten |
Darüber liegt der **Administrator der Installation**: Nur er legt Gruppen,
Benutzer und Domains überhaupt an.
### Domains
![Domains verwalten](bilder/14-verwaltung-domains.png)
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
werden die Domains hier eingetragen und Gruppen zugeteilt. Eine Domain kann
mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
getrennt.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
---
## Konto
![Der eigene Kontobereich](bilder/15-konto.png)
Hier stehen die eigene Adresse, die Gruppen mit der jeweiligen Rolle, die
Passwortänderung und der Weg hinaus.
Meldet sich das Konto über Authentik an, steht statt der Passwortänderung ein
Hinweis darauf — das Passwort wird dann beim Anbieter geändert.
---
## Häufige Fragen
**Der Code funktioniert auf dem Handy nicht, am Rechner aber schon.**
Dann zeigt die Kurz-URL auf eine Adresse, die das Handy nicht erreicht.
`BASE_URL` in der `.env` muss die Adresse sein, unter der die Anwendung **von
außen** erreichbar ist — nicht `localhost`.
**Ich habe den Code schon gedruckt und brauche ein anderes Ziel.**
Ist er dynamisch: „Meine Codes“ → Ziel bearbeiten. Ist er statisch: Der Code
muss neu erstellt und neu gedruckt werden. Deshalb bei allem Gedruckten
dynamisch nehmen.
**Der Scan-Zähler steht auf 0, obwohl jemand gescannt hat.**
Gezählt wird nur der Aufruf der Kurz-URL. Statische Codes zählen gar nicht,
weil sie diesen Server nie berühren. Bei Typen mit eigener Seite zählt der
Scan, nicht das Neuladen der Seite.
**Ein Kollege sieht meine Codes nicht.**
Er ist nicht in derselben Gruppe. Verwaltung → Gruppen → Mitglied hinzufügen.
**Der QR-Code wird nicht erkannt.**
Meist zu wenig Kontrast, ein zu kleiner Rand oder zu kleiner Druck.
Faustregel für Druck: mindestens 2 × 2 cm, bei Entfernung mehr — als grober
Anhalt ein Zehntel des Leseabstands.
**Kann ich ein Logo in die Mitte setzen?**
Nicht in dieser Anwendung. Wer es im Layoutprogramm macht: SVG nehmen,
Fehlerkorrektur **H** wählen und höchstens ein Fünftel der Fläche verdecken —
danach unbedingt mit mehreren Geräten testen.
+320
View File
@@ -0,0 +1,320 @@
# Anmeldung über Authentik (OIDC)
Diese Anleitung richtet die Anmeldung am QR-Code-Generator über **Authentik**
ein. Sie funktioniert genauso mit anderen OpenID-Connect-Anbietern — Keycloak,
Zitadel, Entra ID —, weil die Anwendung alles Nötige aus dem
Discovery-Dokument des Anbieters liest. Die Beispiele nennen Authentik, weil
die Feldnamen dort so heißen.
**Zeitbedarf:** etwa 15 Minuten.
---
## Vorher klären
| Frage | Antwort für diese Anleitung |
| --- | --- |
| Adresse der Anwendung | `https://qr.example.com` |
| Adresse von Authentik | `https://auth.example.com` |
| Läuft die Anwendung über HTTPS? | Ja — siehe Hinweis unten |
> **HTTPS ist Pflicht, sobald es über den eigenen Rechner hinausgeht.** Beim
> Anmelden gehen Token über die Leitung, und der Sitzungs-Cookie wird nur bei
> `https://` in der `BASE_URL` als `Secure` gesetzt. Für einen Versuch im LAN
> geht auch HTTP; produktiv gehört ein Reverse Proxy davor (siehe README,
> Abschnitt „Hinter einem Reverse Proxy“).
Die Anwendung muss Authentik erreichen können und umgekehrt der Browser beide.
Laufen beide in Docker auf demselben Host, genügt es meist, sie ins selbe
Netz zu hängen — der Browser braucht aber in jedem Fall öffentlich auflösbare
Adressen.
---
## Schritt 1 — In Authentik einen Provider anlegen
**Applications → Providers → Create → OAuth2/OpenID Provider**
| Feld | Wert |
| --- | --- |
| Name | `QR-Code-Generator` |
| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit, wenn niemand zustimmen soll) |
| Client type | **Confidential** |
| Client ID | notieren — kommt gleich in die `.env` |
| Client Secret | notieren — ebenfalls |
| Redirect URIs | **deine** `BASE_URL` + `/api/auth/oidc/callback` — siehe unten |
| Signing Key | ein vorhandenes Zertifikat auswählen |
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
Anmeldung mit einer entsprechenden Meldung ab.
### Die Redirect URI
Das ist der häufigste Stolperstein. Der Wert ist **kein Beispiel zum
Abschreiben**, sondern ergibt sich aus deiner `BASE_URL`:
```
{BASE_URL}/api/auth/oidc/callback
```
Was dein Container als `BASE_URL` kennt, zeigt:
```bash
docker compose exec app env | grep BASE_URL
```
Ein paar Beispiele:
| `BASE_URL` | Redirect URI |
| --- | --- |
| `https://qr.example.com` | `https://qr.example.com/api/auth/oidc/callback` |
| `http://10.10.10.150:3005` | `http://10.10.10.150:3005/api/auth/oidc/callback` |
| `http://192.168.1.50:3000` | `http://192.168.1.50:3000/api/auth/oidc/callback` |
Zu beachten:
- **Kein Schrägstrich am Ende.**
- **Schema und Port gehören dazu** — `http` ist nicht `https`, und `:3005`
darf nicht fehlen.
- Neben dem Feld steht in neueren Authentik-Fassungen eine Auswahl **Strict**
oder **Regex**. Für eine feste Adresse muss dort **Strict** stehen. Bei
`Regex` wäre jeder Punkt einer IP-Adresse ein Platzhalter und müsste
maskiert werden.
- Bleibt das Feld leer, lässt Authentik **gar nichts** durch.
> **Wenn es trotzdem klemmt:** Authentik zeigt den Fehler „Redirect URI Error“
> noch auf seiner eigenen Seite an — die Anwendung bekommt davon nichts mit.
> Welche Adresse tatsächlich ankam, steht zum Vergleich
> 1. in der Adresszeile des Browsers, im Parameter `redirect_uri`, und
> 2. in Authentik unter **Events → Logs** im Eintrag `redirect_uri_error`.
>
> Weicht die ankommende Adresse von deiner `BASE_URL` ab, ist nicht Authentik
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
Anwendung tatsächlich aufruft.
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
hat.
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
Übereinstimmung gebracht:
| | |
| --- | --- |
| Aufruf im Browser | `https://qr.example.com` |
| `BASE_URL` in der `.env` | `https://qr.example.com` |
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
---
## Schritt 2 — Eine Application anlegen
**Applications → Applications → Create**
| Feld | Wert |
| --- | --- |
| Name | `QR-Code-Generator` |
| Slug | `qrcode-generator` |
| Provider | der eben angelegte Provider |
| Launch URL | `https://qr.example.com` |
Der **Slug** steckt gleich in der Issuer-Adresse — merken.
Wer den Zugang einschränken will, hängt hier unter **Policy / Group / User
Bindings** eine Gruppe ein. Das ist der empfohlene Weg: Wer sich bei Authentik
anmelden darf, kommt sonst auch in den QR-Code-Generator.
---
## Schritt 3 — Die Issuer-Adresse herausfinden
Authentik zeigt sie beim Provider unter **Provider Info** als *OpenID
Configuration Issuer*. Sie sieht so aus:
```
https://auth.example.com/application/o/qrcode-generator/
```
Prüfen lässt sich das direkt:
```bash
curl https://auth.example.com/application/o/qrcode-generator/.well-known/openid-configuration
```
Kommt ein JSON-Dokument mit `authorization_endpoint`, `token_endpoint` und
`jwks_uri` zurück, stimmt die Adresse.
---
## Schritt 4 — Die `.env` ergänzen
```bash
AUTH_ENABLED=true
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
OIDC_CLIENT_ID=<Client ID aus Schritt 1>
OIDC_CLIENT_SECRET=<Client Secret aus Schritt 1>
OIDC_BUTTON_LABEL=Mit Authentik anmelden
OIDC_SCOPES=openid email profile
OIDC_DEFAULT_ROLE=EDITOR
OIDC_ADMIN_GROUP=qr-admins
OIDC_GROUPS_CLAIM=groups
OIDC_ONLY=false
```
Danach neu starten:
```bash
docker compose up -d
```
> `OIDC_ENABLED` allein genügt nicht — **`AUTH_ENABLED` muss ebenfalls `true`
> sein**. Ohne Anmeldung gibt es keine Benutzer, denen eine Anmeldung
> zugeordnet werden könnte.
---
## Schritt 5 — Ausprobieren
`https://qr.example.com/anmelden` aufrufen. Über dem Passwortfeld steht jetzt
**„Mit Authentik anmelden“**. Ein Klick führt zu Authentik, nach der Anmeldung
geht es zurück in die Anwendung.
Alle Anmeldenden bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
Standardgruppe. Wer die **Verwaltung** öffnen darf, hängt davon ab, ob
`OIDC_ADMIN_GROUP` gesetzt ist:
- **Leer:** Der erste Mensch, der sich anmeldet, wird Administrator dieser
Installation — genau wie bei der Ersteinrichtung mit Passwort. Danach vergibt
man das Recht unter Verwaltung → Benutzer.
- **Gesetzt:** Dann entscheidet allein die Gruppe, und zwar bei **jeder**
Anmeldung neu — auch beim allerersten Benutzer.
> **Achtung beim ersten Mal:** Wenn du `OIDC_ADMIN_GROUP` setzt, vergewissere
> dich, dass dein eigenes Konto in dieser Gruppe ist. Sonst hat niemand Zugang
> zur Verwaltung. Der Weg zurück ist einfach: die Variable leeren,
> `docker compose up -d`, neu anmelden — dann greift wieder die Regel „der
> erste wird Administrator“, sofern es noch gar keinen gibt.
---
## Was die Anwendung aus Authentik übernimmt — und was nicht
| Übernommen | Bleibt in der Anwendung |
| --- | --- |
| Wer jemand ist (`sub`) | Mitgliedschaft in Gruppen |
| E-Mail-Adresse | Rolle je Gruppe (Admin/Bearbeiter/Betrachter) |
| Anzeigename | Zuteilung von Domains |
| Instanz-Administration, falls `OIDC_ADMIN_GROUP` gesetzt ist | alles Übrige |
Das ist eine bewusste Entscheidung. Die Gruppen in Authentik und die Gruppen
hier bedeuten nicht dasselbe: dort geht es um Organisation und Zugang, hier um
die Frage, wer welche QR-Codes sehen und ändern darf. Eine automatische
Übersetzung wäre eine Quelle stiller Überraschungen — deshalb pflegt man
Gruppen und Rollen weiterhin unter **Verwaltung → Gruppen**.
Wiedererkannt wird jemand an der **Kennung von Authentik** (`sub`), nicht an
der E-Mail-Adresse. Ändert sich die Adresse, bleibt es dasselbe Konto.
### Die Admin-Gruppe
Steht in `OIDC_ADMIN_GROUP` ein Gruppenname, entscheidet Authentik bei **jeder
Anmeldung** neu, wer die Verwaltung öffnen darf: Mitglieder der Gruppe
bekommen das Recht, alle anderen verlieren es. Bleibt die Variable leer,
rührt die Anmeldung das Recht nicht an — dann pflegt man es hier.
---
## Bestehende Konten übernehmen
Gibt es hier schon Konten mit Passwort, werden sie beim ersten Anmelden über
Authentik **anhand der E-Mail-Adresse verknüpft**. Aus dem lokalen
Administrator wird also kein zweites Konto; Gruppen, Rollen und die
angelegten Codes bleiben.
Verknüpft wird nur, wenn Authentik die Adresse als **bestätigt** meldet. Sonst
könnte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein
hiesiges Konto übernehmen. Meldet Authentik sie als unbestätigt, bricht die
Anmeldung mit einer Erklärung ab.
Ist ein Konto einmal verknüpft, gibt es dafür kein Passwort mehr: Der Bereich
**Konto** zeigt dann statt der Passwortänderung einen Hinweis auf den
Anbieter.
---
## Nur noch über Authentik anmelden
```bash
OIDC_ONLY=true
```
Danach verschwindet das Passwortfeld von der Anmeldeseite.
> **Vorher an den Notausgang denken.** Fällt Authentik aus oder ist der
> Provider falsch konfiguriert, kommt niemand mehr hinein. Der Weg zurück:
> `OIDC_ONLY=false` in der `.env`, `docker compose up -d` — dann geht die
> Anmeldung mit Passwort wieder. Lege dir dafür ein lokales Administratorkonto
> an, **bevor** du umstellst.
---
## Wenn etwas nicht klappt
| Symptom | Ursache und Abhilfe |
| --- | --- |
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
Ausführlichere Meldungen stehen im Protokoll:
```bash
docker compose logs -f app
```
---
## Was technisch passiert
1. Klick auf den Knopf → `/api/auth/oidc/start`. Die Anwendung holt das
Discovery-Dokument, würfelt `state`, `nonce` und ein PKCE-Geheimnis und legt
sie in einen kurzlebigen, `httpOnly`-Cookie.
2. Weiterleitung zu Authentik mit `code_challenge` (S256).
3. Nach der Anmeldung kommt Authentik mit `code` und `state` zurück auf
`/api/auth/oidc/callback`. Stimmt der `state` nicht, endet es hier.
4. Die Anwendung tauscht den Code gegen Token — mit Client-Geheimnis **und**
PKCE-Verifier. Ein abgefangener Code allein nützt also niemandem.
5. Das ID-Token wird gegen den JWKS von Authentik geprüft: Signatur,
Aussteller, Empfänger, Ablauf und `nonce`.
6. Konto suchen (über `sub`, ersatzweise über die bestätigte Adresse), anlegen
oder verknüpfen — und die Sitzung wie bei der Passwort-Anmeldung setzen.
Die Sitzung selbst ist danach dieselbe wie sonst: ein Zufallstoken im Cookie,
in der Datenbank nur dessen SHA-256. Abmelden beendet sie hier; bei Authentik
bleibt man angemeldet.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+556
View File
@@ -0,0 +1,556 @@
# Alle Funktionen im Detail
Die lange Fassung der Übersicht aus dem [README](../README.md). Sie
beschreibt, was die Anwendung kann, wo die Grenzen liegen und warum manches
so entschieden ist. Wie man es bedient, steht in der
[Bedienungsanleitung](anleitung.md).
---
## Inhalt
- [QR-Typen](#qr-typen)
- [Statisch und dynamisch](#statisch-und-dynamisch)
- [Landingpages](#landingpages)
- [Dateien](#dateien)
- [Feedback](#feedback)
- [Gestaltung und Ausgabe](#gestaltung-und-ausgabe)
- [Meine Codes](#meine-codes)
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Single Sign-on](#single-sign-on)
- [Mehrere Domains](#mehrere-domains)
- [Erscheinungsbild](#erscheinungsbild)
- [Verwaltung](#verwaltung)
- [Betrieb](#betrieb)
- [Schnittstelle](#schnittstelle)
- [Was die Anwendung bewusst nicht tut](#was-die-anwendung-bewusst-nicht-tut)
---
## QR-Typen
Alle 15 vorgesehenen Typen sind gebaut.
| Typ | Was hineinkommt | Statisch | Dynamisch | Eigene Seite |
| --- | --- | :-: | :-: | :-: |
| **Website** | eine Adresse | ✓ | ✓ | — |
| **vCard** | Name, Firma, Telefon, E-Mail, Anschrift, Web | ✓ | — | — |
| **App-Link** | je eine Adresse für iOS, Android und Ausweichfall | — | ✓ | — |
| **Mehrere Links** | Überschrift, Text, bis zu 20 beschriftete Links | — | ✓ | ✓ |
| **Social Media** | Überschrift, Text, bis zu 12 Profile aus 15 Netzwerken | — | ✓ | ✓ |
| **WhatsApp** | Rufnummer und vorbereitete Nachricht | ✓ | ✓ | — |
| **PDF** | eine hochgeladene Datei, Titel, Text | — | ✓ | ✓ |
| **Bild** | bis zu 10 hochgeladene Bilder mit Bildunterschriften | — | ✓ | ✓ |
| **Video** | YouTube, Vimeo oder eine direkte Videodatei | ✓ | ✓ | ✓ |
| **E-Mail** | Empfänger, Betreff, Text | ✓ | ✓ | — |
| **SMS** | Rufnummer und Text | ✓ | ✓ | — |
| **Standort** | Koordinaten oder Adresse | ✓ | ✓ | — |
| **Zahlung** | GiroCode (EPC069-12) **oder** Zahlungslink | ✓ | nur Link | — |
| **WLAN** | Netzwerkname, Passwort, Verschlüsselung, versteckt | ✓ | — | — |
| **Feedback** | Frage, Skala, Textfeld, Dankestext | — | ✓ | ✓ |
### Warum manche Typen nur eine Betriebsart können
- **vCard und WLAN** wertet das Gerät direkt aus — die Kamera legt einen
Kontakt an oder richtet das Netz ein. Über eine Kurz-URL ginge stattdessen
nur ein Browser auf.
- **App-Link** kann nicht statisch sein: Welches Ziel richtig ist, steht erst
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus — iOS in den
App Store, Android zu Google Play, alles Übrige auf die Ausweichadresse.
(iPadOS meldet sich seit Version 13 als Macintosh und landet deshalb
mitunter auf dem Desktop-Weg; für einen Store-Link ist das hinnehmbar.)
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
dynamischer Code funktioniert nur die Variante „Zahlungslink“. Die
Anwendung weist das beim Speichern **und beim Bearbeiten** ab, statt einen
Code entstehen zu lassen, der erst beim Scannen auffällt — also dann, wenn
er schon gedruckt ist.
- **Die fünf Typen mit eigener Seite** brauchen eine Seite, die die Anwendung
ausliefert. Eine Linkliste oder ein Formular passt nicht in einen einzelnen
Code. Video ist die Ausnahme: statisch steht dort die Videoadresse selbst.
### Besonderheiten einzelner Typen
**Social Media.** Als Eingabe genügt das Kürzel — aus `@firma` wird beim
Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt
unverändert, damit auch abweichende Profil-Adressen möglich sind. Jedes
Netzwerk kann nur einmal vorkommen. Der Katalog umfasst Instagram, Facebook,
LinkedIn, Xing, YouTube, TikTok, X, Threads, Mastodon, WhatsApp, Telegram,
Pinterest, Snapchat, GitHub und „Website“.
**Video.** Erkannt werden `youtube.com/watch`, `youtu.be`, `/shorts/`,
`/embed/`, `/live/`, Vimeo-Adressen sowie die Endungen `.mp4`, `.webm`,
`.ogv`, `.ogg`, `.mov` und `.m4v`. Alles Übrige erscheint als schlichter Link
— ehrlicher als ein eingebetteter Rahmen, den der Anbieter nicht erlaubt.
**WLAN.** Das Passwort steht im Klartext im Code; wer ihn scannt oder
abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht Netzwerkname
oder Passwort nur aus Hex-Ziffern, setzt die Anwendung den Wert wie in der
Spezifikation vorgesehen in Anführungszeichen — sonst deuten manche Geräte ihn
als Hexzahl.
**Standort.** Statisch entsteht ein `geo:`-Link, den Karten-Apps direkt
öffnen. Dynamisch führt der Weg über OpenStreetMap, weil eine Kurz-URL im
Browser landet.
---
## Statisch und dynamisch
| | Statisch | Dynamisch |
| --- | --- | --- |
| Im Code steht | die Zieladresse selbst | `{BASE_URL}/r/{slug}` |
| Ziel später änderbar | nein | ja, jederzeit |
| Scans gezählt | nein | ja, mit Datum des letzten |
| Braucht diesen Server | nie | dauerhaft |
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
### Ein statischer Code mit Namen
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
wiederzufinden und erneut herunterzuladen.
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
Liste steht dort „statisch“ und ein Strich.
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
gelegentlich abgetippt.
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
| | |
| --- | --- |
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
dürfen sich nicht nur in der Schreibweise unterscheiden.
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
ließen sich fremde gedruckte Codes umlenken.
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
weiterhin.
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
zwei Domains liegen und dort auf Verschiedenes zeigen.
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
und genau die Änderbarkeit zunichtemachen, wegen der man dynamische Codes
nimmt. Der Scan-Zähler wird beiläufig erhöht; scheitert er, geht die
Weiterleitung trotzdem durch.
---
## Landingpages
Fünf Typen führen auf eine Seite unter `{BASE_URL}/s/{slug}`, die die
Anwendung selbst ausliefert.
Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung:
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator, und im
Seitentitel steht der Inhalt statt des Produktnamens. Wer einen Aushang
scannt, hat mit dem Generator nichts zu tun.
Technisch liegt das App-Chrome in einer Routengruppe, die Landingpages
außerhalb — sie können es also gar nicht versehentlich erben.
Weitere Eigenschaften:
- Alle Texte kommen aus den Formulardaten und werden als **Text** ausgegeben,
nie als HTML. Eingegebene Zeichen wie `<b>` erscheinen als solche.
- Verlinkte Ziele bekommen `rel="noopener noreferrer"` — kein Zugriff auf das
Fenster, aus dem sie geöffnet wurden.
- Adressen müssen mit `http://` oder `https://` beginnen; `javascript:` und
Ähnliches wird abgewiesen.
- Passen die gespeicherten Daten nicht mehr zum Schema, erscheint eine
freundliche Erklärung statt eines Fehlers.
- Der Scan-Zähler zählt den Aufruf des **Codes**, nicht das Neuladen der
Seite.
**Video-Landingpage.** Der Rahmen zu YouTube oder Vimeo lädt erst nach einem
Klick, und davor steht, dass dabei Daten an den Anbieter übertragen werden.
Eingebettet wird die Variante ohne Cookies (`youtube-nocookie.com`). Direkte
Videodateien laden mit `preload="none"` — wer unterwegs scannt, zahlt das
Volumen.
---
## Dateien
PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt
es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
alles. Ein zweites Volumen wird beim Sichern zu leicht vergessen.
| | |
| --- | --- |
| Erlaubt | PDF; PNG, JPEG, WebP, GIF, AVIF |
| Nicht erlaubt | SVG und alles Übrige |
| Größe | `MAX_UPLOAD_MB`, Voreinstellung 10 MB |
| Geprüft wird | der Dateiinhalt, nicht die Endung |
**Warum kein SVG:** Eine SVG-Datei darf Skripte enthalten. Ausgeliefert von
derselben Adresse wie die Anwendung könnte sie damit auf angemeldete
Sitzungen zugreifen.
**Wie geprüft wird:** Der vom Browser gemeldete Typ kommt aus der Endung und
lässt sich beliebig setzen. Entschieden wird deshalb anhand der ersten Bytes
der Datei. Eine Textdatei namens `harmlos.pdf` wird abgewiesen.
**Wie ausgeliefert wird:** mit dem erkannten Typ, `X-Content-Type-Options:
nosniff` und einer `Content-Security-Policy: sandbox` — damit auch ein PDF mit
eingebetteten Skripten nichts anrichten kann. Ein Anhängen von `?download`
erzwingt das Speichern statt der Anzeige.
**Wem eine Datei gehört:** Sie hängt am Code. Wird er gelöscht, verschwindet
sie mit ihm. Eine ausgetauschte Datei verliert ihren Bezug und wird nach 24
Stunden weggeräumt — ebenso eine hochgeladene, deren Code nie gespeichert
wurde. Eine fremde Datei lässt sich nicht an den eigenen Code hängen; sonst
könnte man sie einem anderen Code entziehen.
Die Adresse einer Datei ist nicht erratbar, aber öffentlich abrufbar —
dieselbe Überlegung wie beim Slug einer Kurz-URL. Vertrauliche Unterlagen
gehören nicht hinter einen QR-Code, den man abfotografieren kann.
---
## Feedback
Ein Feedback-Code erzeugt ein Formular mit fünf **Sternen** oder fünf
**Gesichtern** und wahlweise einem Textfeld. Überschrift, Frage, Beschriftung
des Textfelds und der Text nach dem Absenden sind frei formulierbar.
Gespeichert werden **nur die Note von 1 bis 5 und der Text**. Kein Name, keine
IP-Adresse, keine Gerätekennung — das steht auch für die Antwortenden sichtbar
unter dem Formular.
Gegen Fluten gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender.
Die Adresse dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends
gespeichert. Rückmeldungen zu Codes, die gar kein Feedback einsammeln, werden
abgewiesen.
Die **Auswertung** ist aus der Codeliste erreichbar und zeigt Durchschnitt,
Verteilung der Noten und alle Kommentare mit Datum (die 200 jüngsten). Sie
sieht nur, wer den Code auch sehen darf.
---
## Gestaltung und Ausgabe
| Einstellung | Bereich | Voreinstellung |
| --- | --- | --- |
| Vordergrundfarbe | frei (Hex oder Farbwähler) | `#0F172A` |
| Hintergrundfarbe | frei | `#FFFFFF` |
| Größe | 256–2048 px | 512 px |
| Fehlerkorrektur | L, M, Q, H | M |
| Rand | 0–16 Module | 4 |
Fünf Farbvorschläge (Klassisch, Indigo, Waldgrün, Bordeaux, Marine) und ein
Knopf zum Tauschen von Vorder- und Hintergrund. Reicht der Kontrast nicht,
warnt die Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem.
Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
Änderung. Herunterladen geht als **PNG** (in der eingestellten Größe) und als
**SVG** (frei skalierbar). Die Größe betrifft nur das PNG.
---
## Meine Codes
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
gespeichert werden.
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
mit großer Vorschau. Die Wahl merkt sich der Browser.
- **Suche** über Name, Ziel, Kurz-URL und Typ.
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
es auch eine Gruppenspalte.
- **Kurz-URL kopieren** mit einem Klick.
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
- **PNG und SVG** erneut herunterladen.
- Bei Feedback-Codes ein Weg zur **Auswertung**.
Auf schmalen Bildschirmen weichen die hinteren Spalten; die Kurz-URL rückt
unter den Namen, Bearbeiten und Löschen bleiben erreichbar.
---
## Anmeldung, Gruppen und Rollen
Die Anmeldung ist **abschaltbar** (`AUTH_ENABLED`). Bewusst über die
Konfiguration und nicht über die Oberfläche: So kann man sich nicht
aussperren.
**Ohne Anmeldung** darf jeder, der die Anwendung erreicht, alles. Für den
Einzelplatz- oder LAN-Betrieb ist das gewollt; ins offene Internet gehört die
Anwendung dann nicht.
**Mit Anmeldung** gibt es vier Ebenen:
1. **Benutzer** — meldet sich mit E-Mail und Passwort an.
2. **Gruppe** — bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
gehört genau einer Gruppe.
3. **Rolle je Gruppe:**
| Rolle | Darf |
| --- | --- |
| Betrachter | sehen und herunterladen |
| Bearbeiter | zusätzlich anlegen, ändern, löschen |
| Administrator | zusätzlich Mitglieder und Rollen der Gruppe verwalten |
4. **Administrator der Installation** — legt Gruppen, Benutzer und Domains
überhaupt erst an.
Zur Umsetzung:
- Passwörter werden mit **scrypt** gehasht (Node-Bordmittel, kein natives
Modul). Mindestlänge 10 Zeichen.
- Nach **8 Fehlversuchen** ist das Konto 15 Minuten gesperrt.
- Sitzungen liegen in der **Datenbank**, nicht in einem signierten Cookie —
so lassen sie sich serverseitig beenden, etwa beim Deaktivieren eines
Benutzers. Im Cookie steht ein Zufallstoken, in der Datenbank nur dessen
SHA-256. Laufzeit 30 Tage.
- Der Cookie ist `httpOnly` und `SameSite=Lax`; `Secure` wird gesetzt, sobald
`BASE_URL` mit `https://` beginnt.
- Alle Berechtigungsregeln stehen an **einer** Stelle
(`src/lib/auth/policy.ts`) — nicht verteilt über die Oberfläche.
- Im **Konto** sieht man die eigenen Gruppen und Rollen, ändert sein Passwort
und meldet sich ab.
---
## Single Sign-on
Die Anmeldung lässt sich an einen **OpenID-Connect-Anbieter** abgeben —
getestet mit Authentik, ebenso nutzbar mit Keycloak, Zitadel oder Entra ID.
Alles Nötige kommt aus dem Discovery-Dokument des Anbieters.
- Anmeldefluss mit **PKCE** (S256), `state` und `nonce`.
- Das ID-Token wird gegen den **JWKS** des Anbieters geprüft: Signatur,
Aussteller, Empfänger, Ablauf und Nonce.
- Wiedererkannt wird jemand an der **Kennung des Anbieters** (`sub`), nicht an
der E-Mail-Adresse — Adressen ändern sich.
- Ein bestehendes Konto mit derselben Adresse wird **verknüpft statt
verdoppelt**, aber nur bei **bestätigter** Adresse. Sonst könnte sich beim
Anbieter jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
übernehmen.
- Neue Konten bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
Standardgruppe.
- `OIDC_ADMIN_GROUP` hängt die Instanz-Administration an eine Gruppe beim
Anbieter; sie wird dann bei jeder Anmeldung neu entschieden.
- `OIDC_ONLY=true` blendet die Passwort-Anmeldung aus.
**Bewusst nicht übernommen** werden Gruppen und Rollen. Die Gruppen im
Verzeichnis bedeuten etwas anderes als die hier: dort geht es um Organisation
und Zugang, hier darum, wer welche Codes sehen und ändern darf. Eine
automatische Übersetzung wäre eine Quelle stiller Überraschungen.
→ Einrichtung Feld für Feld: [authentik.md](authentik.md)
---
## Mehrere Domains
Kurz-URLs können unter verschiedenen Adressen laufen — etwa je Kunde eine.
Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
- Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
**prüft ihn gegen die registrierten Domains**. Ein unbekannter Hostname
endet mit 404 — über einen gefälschten Header lässt sich also keine fremde
Domain ansprechen.
- Der **Slug ist je Domain eindeutig**, nicht global.
- Eine Domain kann **mehreren Gruppen** dienen. Die Codes bleiben trotzdem
strikt nach Gruppe getrennt.
- Je Gruppe lässt sich eine Domain als Voreinstellung festlegen.
- Eine Domain lässt sich abschalten, ohne sie zu löschen; ihre Codes
antworten dann mit einer Erklärung.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
Anwendung leiten.
---
## Erscheinungsbild
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
| | |
| --- | --- |
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
benutzbar.
### Hell und dunkel
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
Kopfzeile steht ein Umschalter mit drei Zuständen:
| | |
| --- | --- |
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
| **Hell** | immer hell |
| **Dunkel** | immer dunkel |
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
Tab zieht automatisch nach.
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
sogar **ohne JavaScript**.
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
aussehen.
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
als eigener Punkt auf der [Roadmap](roadmap.md).
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
---
## Verwaltung
| Bereich | Was dort geht |
| --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
Zu den **QR-Typen**: 15 Kacheln sind viel, wenn ihr nur drei davon braucht.
Ein abgeschalteter Typ verschwindet aus der Auswahl; **bereits erstellte Codes
funktionieren unverändert weiter** — sie leiten weiter, zählen Scans und
lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
---
## Betrieb
- **Zwei Container:** die Next.js-Anwendung und PostgreSQL. Kein Reverse Proxy
nötig — Port 3000 wird direkt veröffentlicht. Ein Proxy davor geht
selbstverständlich auch.
- **Migrationen laufen beim Start** von selbst. Es gibt keinen Extraschritt
nach `docker compose up -d --build`.
- Die Datenbank hat einen **Healthcheck**; die Anwendung startet erst, wenn
sie wirklich Anfragen annimmt.
- **Ein Volumen** für die Datenbank — dort liegen auch die hochgeladenen
Dateien.
- Die Konfiguration steht vollständig in einer **`.env`**; eine `.env.example`
ist mitgeliefert und dokumentiert jeden Wert.
- Die Anwendung liest die Werte **zur Laufzeit**, nicht beim Bauen — eine
geänderte `BASE_URL` braucht keinen neuen Build.
- Die Oberfläche ist durchgängig **deutsch**, hell, responsiv und ohne
externe Schriften oder Skripte.
---
## Version und Änderungen
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
Steht dort noch die alte Nummer, ist er es nicht.
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
Daraus entstehen sowohl die Seite in der Anwendung als auch die
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
einen Git-Tag.
---
## Schnittstelle
Alles, was die Oberfläche tut, geht auch über HTTP:
| Route | Zweck |
| --- | --- |
| `POST /api/qr/preview` | Vorschau erzeugen (statisch oder dynamisch) |
| `POST /api/qr/download` | PNG oder SVG erzeugen |
| `GET/POST /api/codes` | dynamische Codes auflisten und anlegen |
| `GET/PATCH/DELETE /api/codes/{id}` | einzelnen Code lesen, ändern, löschen |
| `GET /api/codes/{id}/qr` | Bild eines gespeicherten Codes |
| `POST /api/uploads` | Datei hochladen (`GET` nennt die Größengrenze) |
| `GET /api/uploads/{id}` | Datei ausliefern (`?download` erzwingt Speichern) |
| `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage |
Fehler kommen als `{ "error": "…" }` mit einer deutschen Meldung, die sich
unverändert anzeigen lässt.
---
## Was die Anwendung bewusst nicht tut
- **Kein Logo im QR-Code.** Wer es braucht, nimmt das SVG, setzt es im
Layoutprogramm ein, wählt Fehlerkorrektur H und testet mit mehreren Geräten.
- **Keine Statistik über die Zeit.** Der Zähler weiß, wie oft und wann zuletzt
— kein Verlauf, keine Herkunft, keine Geräte. Das wäre eine eigene Tabelle
und die Frage, wie lange man so etwas aufbewahrt.
- **Keine Übernahme von Gruppen aus dem Verzeichnis.** Siehe oben.
- **Keine Stapelverarbeitung.** Codes entstehen einzeln.
- **Keine Mehrsprachigkeit.** Die Oberfläche ist deutsch.
- **Kein Papierkorb.** Ein gelöschter Code ist weg, samt seiner Dateien und
Rückmeldungen.
Was davon als Nächstes sinnvoll wäre, steht in der [Roadmap](roadmap.md).
+164
View File
@@ -0,0 +1,164 @@
# Roadmap
Was gebaut ist, was als Nächstes kommt und was danach denkbar wäre. Die
Aufwände sind grobe Einschätzungen, keine Zusagen.
---
## Gebaut
Der vollständige Verlauf mit Versionsnummern steht im
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
**Version und Änderungen**, erreichbar über die Versionsnummer in der
Fußzeile.
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
und 1.6.0 diesen Versionshinweis.
---
## Als Nächstes
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
Person, die den Code kennt.
### 1. QR-Typen je Gruppe freigeben · mittel
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
entscheidet die gewählte Gruppe.
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
wahrscheinlich die richtige.
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
eine Gruppenauswahl bekommt.
### 2. Statistik über die Zeit · mittel
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
eingekehrt?
Geplant: eine Tabelle mit einer Zeile je Code und Tag, dazu ein Diagramm in
der Codeansicht und die Zahlen der letzten 7, 30 und 365 Tage.
Bewusst **ohne** Herkunft, Gerät oder Verweisseite. Ein reiner Tageszähler
sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
andere Entscheidung als „ein Diagramm wäre schön“.
### 3. Papierkorb statt Löschen · klein
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
samt seiner Dateien und Rückmeldungen.
Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
„Meine Codes“ ein Reiter „Papierkorb“ mit Wiederherstellen. Solange ein Code
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
### 4. Logo im QR-Code · mittel
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
die verdeckte Fläche.
### 5. Ablaufdatum und Zeitsteuerung · mittel
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
Geplant, in zwei Stufen:
- **Ablauf:** ab einem Zeitpunkt zeigt der Code eine erklärende Seite statt
des Ziels. Wichtig ist der Text — wer davorsteht, soll erfahren, dass die
Aktion vorbei ist, nicht dass „etwas nicht gefunden“ wurde.
- **Zeitplan:** mehrere Ziele mit Gültigkeitsfenstern, damit derselbe
Aufsteller montags auf die Mittagskarte und samstags auf die Veranstaltung
zeigt.
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
Fenstern geschieht.
---
## Später denkbar
**Das Fenster der Ersteinrichtung schließen** · klein
Heute wird das erste Konto ohne jede Legitimation angelegt und ist gleich
Administrator. Wer die Anwendung öffentlich stellt, bevor er sein Konto
angelegt hat, kann sie an einen Fremden verlieren. Vorschlag: ein einmaliges
Token, das beim ersten Start ins Protokoll geschrieben wird und für die
Ersteinrichtung nötig ist. Klein im Aufwand — und das Erste, was zu tun ist,
falls die Anwendung je aus dem eigenen Netz herausgeht. Siehe
[sicherheit.md](sicherheit.md).
**Codes exportieren** · klein
Eine CSV aller Codes einer Gruppe mit Name, Kurz-URL, Ziel und Scans — für
die Übergabe an eine Druckerei oder die Ablage.
**Codes duplizieren** · klein
„Wie dieser, aber mit anderem Ziel“ — bei vielen ähnlichen Projekten spart
das jedes Mal ein paar Minuten.
**Stapelverarbeitung** · mittel
Eine CSV hochladen und daraus 200 Codes erzeugen, als ZIP zurück. Sinnvoll,
sobald jedes Produkt oder jeder Tisch einen eigenen Code bekommt.
**Gruppen aus dem Verzeichnis übernehmen** · mittel
Die OIDC-Anbindung übernimmt heute bewusst nur die Identität. Eine Zuordnung
von Verzeichnisgruppen zu Gruppen dieser Anwendung ist möglich, braucht aber
eine klare Regel dafür, was mit von Hand vergebenen Rollen geschieht — sonst
verliert jemand beim nächsten Anmelden stillschweigend seine Rechte.
**Passwortgeschützte Landingpages** · mittel
Für Unterlagen, die nicht jeder sehen soll, der den Aushang fotografiert.
Heute ist die Empfehlung klar: Vertrauliches gehört nicht hinter einen
offenen QR-Code.
**Dateien außerhalb der Datenbank** · mittel
Heute liegen Uploads in PostgreSQL, damit es bei einem Volumen bleibt. Wer
sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
die Auslieferungsroute — aber auch die Sicherungsstrategie.
**Erscheinungsbild je Gruppe** · groß
Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit
eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch
überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit
nichts Unlesbares entsteht.
**Mehrsprachige Oberfläche** · groß
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
Landingpages. Eine zweite Sprache berührt jede Datei.
---
## Wie ein neuer Typ dazukommt
Falls doch noch einer fehlt: Er besteht aus einem Modul in
`src/lib/qr/types/`, einer Formular-Komponente, je einem Registry-Eintrag und
— wenn er eine eigene Seite braucht — einer Landingpage. Am übrigen Code
ändert sich nichts. Näheres im [README](../README.md), Abschnitt „Einen
weiteren Typ ergänzen“.
+270
View File
@@ -0,0 +1,270 @@
/**
* Erzeugt die Bildschirmfotos für docs/anleitung.md.
*
* Warum als Skript und nicht von Hand: Bilder in einer Anleitung veralten
* beim ersten Umbau. So lassen sie sich in einer Minute neu erzeugen.
*
* Voraussetzungen:
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
* Codes im Bild landen), erreichbar unter BASE
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium
*
* Aufruf:
* node docs/screenshots.mjs http://localhost:3000
*/
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { chromium } from "playwright";
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
await mkdir(ZIEL, { recursive: true });
const ADMIN = { email: "demo@example.com", password: "nur-fuer-die-bilder-1" };
const DESIGN = { fgColor: "#312E81", bgColor: "#FFFFFF", size: 512, ecLevel: "M", margin: 4 };
/* ------------------------------------------------------------- Anmelden */
let cookie = null;
async function api(pfad, init = {}) {
const headers = new Headers(init.headers ?? {});
if (init.json !== undefined) headers.set("Content-Type", "application/json");
if (cookie) headers.set("Cookie", cookie);
const response = await fetch(BASE + pfad, {
...init,
headers,
body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
redirect: "manual",
});
const gesetzt = response.headers.get("set-cookie");
if (gesetzt) cookie = gesetzt.split(";")[0];
let body = null;
try {
body = await response.json();
} catch {
/* leerer Rumpf */
}
return { status: response.status, body };
}
const zustand = await api("/api/auth");
if (zustand.body?.authEnabled) {
const aktion = zustand.body.setupOpen ? "setup" : "login";
const angemeldet = await api("/api/auth", {
method: "POST",
json: { action: aktion, ...ADMIN, name: "Demo Betreiber" },
});
if (angemeldet.status >= 400) {
console.error("Anmeldung fehlgeschlagen:", angemeldet.body);
process.exit(1);
}
}
/* --------------------------------------------------------- Beispieldaten */
const angelegt = [];
async function code(type, data, label) {
const erstellt = await api("/api/codes", {
method: "POST",
json: { type, data, design: DESIGN, label },
});
if (erstellt.status !== 201) {
console.error(`Beispielcode „${label}“ ging nicht:`, erstellt.body);
process.exit(1);
}
angelegt.push(erstellt.body.code);
return erstellt.body.code;
}
await code("website", { url: "https://example.com/nordwind" }, "Projekt Nordwind");
await code("website", { url: "https://example.com/messe" }, "Messestand Halle 3");
const links = await code(
"links",
{
title: "Projekt Nordwind",
description: "Alle Unterlagen auf einen Blick.",
links: [
{ label: "Preisliste als PDF", url: "https://example.com/preise.pdf" },
{ label: "Anfahrt zum Werk", url: "https://example.com/anfahrt" },
{ label: "Ansprechpartner", url: "https://example.com/kontakt" },
],
},
"Nordwind Linkseite",
);
const social = await code(
"social",
{
title: "Bäckerei Sonnenschein",
description: "Folge uns für Neues aus der Backstube.",
profiles: [
{ network: "instagram", url: "@sonnenschein" },
{ network: "facebook", url: "sonnenschein.backt" },
{ network: "youtube", url: "@sonnenschein" },
{ network: "website", url: "https://sonnenschein.example.com" },
],
},
"Sonnenschein Profile",
);
/* --- Eine Datei zum Vorzeigen: klein, aber gültig ------------------------ */
async function hochladen(kind, name, inhalt, typ) {
const form = new FormData();
form.set("kind", kind);
form.set("file", new File([inhalt], name, { type: typ }));
const antwort = await api("/api/uploads", { method: "POST", body: form });
if (antwort.status !== 201) {
console.error(`Upload „${name}“ ging nicht:`, antwort.body);
process.exit(1);
}
return antwort.body.upload;
}
const pdfDatei = await hochladen(
"pdf",
"preisliste-2026.pdf",
Buffer.from(
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n" +
"trailer<</Root 1 0 R>>\n%%EOF\n",
"latin1",
),
"application/pdf",
);
const pdf = await code(
"pdf",
{
fileId: pdfDatei.id,
filename: pdfDatei.filename,
size: pdfDatei.size,
title: "Preisliste 2026",
description: "Gültig ab Januar, alle Preise ohne Mehrwertsteuer.",
},
"Preisliste",
);
const feedback = await code(
"feedback",
{
title: "Wie war dein Besuch?",
question: "Wie zufrieden bist du?",
scale: "stars",
allowComment: true,
commentLabel: "Möchtest du uns noch etwas sagen?",
thankYou: "Danke, das hilft uns weiter!",
},
"Gastraum",
);
// Ein paar Rückmeldungen, damit die Auswertung nicht leer ist.
for (const [note, text] of [
[5, "Sehr freundlich, gerne wieder."],
[4, ""],
[5, "Der Kuchen war hervorragend."],
[3, "Etwas lange Wartezeit."],
]) {
await api("/api/feedback", {
method: "POST",
json: { codeId: feedback.id, rating: note, comment: text },
});
}
/* ------------------------------------------------------------ Aufnehmen */
const browser = await chromium.launch();
const desktop = await browser.newContext({
viewport: { width: 1200, height: 820 },
deviceScaleFactor: 1,
locale: "de-DE",
});
if (cookie) {
const [name, ...rest] = cookie.split("=");
await desktop.addCookies([{ name, value: rest.join("="), url: BASE }]);
}
const handy = await browser.newContext({
viewport: { width: 390, height: 800 },
deviceScaleFactor: 1,
locale: "de-DE",
});
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
await page.close();
console.log(" ✓", datei);
}
console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png");
await schuss(desktop, "/erstellen/website", "02-website-formular.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.waitForTimeout(900);
},
});
await schuss(desktop, "/erstellen/website", "03-dynamisch.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.locator('button[data-mode="dynamic"]').click();
await page.waitForTimeout(900);
},
});
await schuss(desktop, "/meine-codes", "04-meine-codes-liste.png");
await schuss(desktop, "/meine-codes", "05-meine-codes-karten.png", {
vorher: async (page) => {
await page.locator('button[data-view="cards"]').click();
await page.waitForTimeout(600);
},
});
await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
ganzeSeite: false,
vorher: async (page) => {
await page.locator('button[data-view="list"]').click();
await page.waitForTimeout(400);
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
},
});
await schuss(handy, `/s/${links.slug}`, "07-landing-links.png");
await schuss(handy, `/s/${social.slug}`, "08-landing-social.png");
await schuss(handy, `/s/${pdf.slug}`, "09-landing-pdf.png");
await schuss(handy, `/s/${feedback.slug}`, "09b-landing-feedback.png");
await schuss(desktop, `/meine-codes/${feedback.id}/feedback`, "10-feedback-auswertung.png");
await schuss(desktop, "/verwaltung", "11-verwaltung.png");
await schuss(desktop, "/verwaltung/typen", "12-verwaltung-typen.png");
await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
await schuss(desktop, "/konto", "15-konto.png");
await browser.close();
/* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) {
await api(`/api/codes/${eintrag.id}`, { method: "DELETE" });
}
console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`);
console.log(`Die Bilder liegen in ${ZIEL}.`);
+285
View File
@@ -0,0 +1,285 @@
# Anmeldung und Sitzungen — Durchsicht nach OWASP
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
Geprüft gegen:
- [Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
> **Wozu das gehört.** Die Anwendung ist ein selbst gehostetes Werkzeug für
> ein Team, nicht ein öffentlicher Dienst mit Zehntausenden Konten. Wo eine
> Empfehlung erkennbar auf den zweiten Fall gemünzt ist, steht unten, warum
> hier etwas anderes gilt — und wie man es strenger einstellt.
---
## Was behoben wurde
### 1. scrypt lag unter der empfohlenen Stärke
**Befund:** Die Passwörter wurden mit scrypt bei `N = 2^15` gehasht. Das
Merkblatt nennt `N = 2^17 (128 MiB), r = 8, p = 1`.
**Behoben:** `N = 2^17`. Alte Hashes bleiben gültig — die Parameter stehen im
Hash. Beim nächsten erfolgreichen Anmelden wird das Passwort still neu
gehasht (`needsRehash()`); das ist der einzige Moment, in dem das
Klartextpasswort vorliegt.
**Nebenwirkung:** Ein Anmeldevorgang kostet jetzt spürbar mehr Rechenzeit und
rund 128 MiB Arbeitsspeicher. Genau das ist der Zweck.
### 2. Passwörter hatten keine Obergrenze
**Befund:** Die Eingabe war nach oben offen. Ein „Passwort“ von mehreren
Megabyte hätte scrypt beschäftigt — an einem Endpunkt, der **ohne Anmeldung**
erreichbar ist. Ein Denial-of-Service mit wenigen Anfragen.
**Behoben:** 128 Zeichen, geprüft vor dem Hashen, in allen drei Formularen
(Anmelden, Ersteinrichtung, Passwortwechsel) sowie in der Benutzerverwaltung.
### 3. Kein CSRF-Schutz außer `SameSite`
**Befund:** Der Sitzungs-Cookie war `SameSite=Lax`, mehr nicht. Das Merkblatt
sagt ausdrücklich, dass das allein nicht genügt: `SameSite` wirkt auf der
Ebene der registrierbaren Domain, nicht des Ursprungs. Besonders heikel war
`POST /api/uploads` — es nimmt `multipart/form-data` entgegen, und das kann
ein Formular auf einer fremden Seite abschicken.
**Behoben:** Jeder schreibende Zugriff (POST, PUT, PATCH, DELETE) wird gegen
den `Origin`-Header geprüft, verglichen mit dem Host der Anfrage — nicht mit
`BASE_URL`, weil die Anwendung absichtlich unter mehreren Domains läuft.
Fehlt der Header ganz, wird durchgelassen: Browser setzen ihn bei fremden
Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
### 4. Keine Schutzheader
**Befund:** Die Anwendung setzte keinen einzigen: kein `X-Frame-Options`,
keine `Content-Security-Policy`, kein `Referrer-Policy`,
kein `X-Content-Type-Options`. Die Verwaltung ließ sich also in eine fremde
Seite einbetten und per Clickjacking fernsteuern. Dazu verriet
`X-Powered-By: Next.js`, wonach jemand Schwachstellen suchen müsste.
**Behoben** in `src/proxy.ts`:
| Header | Wert |
| --- | --- |
| `Content-Security-Policy` | `frame-ancestors 'none'` |
| `X-Frame-Options` | `DENY` |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | Kamera, Mikrofon, Standort, Bezahlen aus |
`X-Powered-By` ist abgeschaltet.
**Bewusst nicht gesetzt** ist eine vollständige `Content-Security-Policy` für
Skripte. Next.js bringt eingebettete Skripte mit; dafür bräuchte es Nonces
durch die ganze Anwendung. Eine Policy mit `unsafe-inline` sähe nach Schutz
aus, ohne einer zu sein. Siehe [Offen](#offen).
### 5. Sitzungen ohne absolute Obergrenze
**Befund:** Die Sitzung lief 30 Tage nach der letzten Nutzung ab — und wurde
bei jeder Nutzung verlängert. Eine einmal entwendete Sitzung hätte durch
regelmäßige Nutzung **unbegrenzt** weitergelebt.
**Behoben:** Zusätzlich eine harte Grenze ab dem Anmelden. Über die
Verlängerung hinaus wird nie verlängert.
| Einstellung | Voreinstellung | Empfehlung des Merkblatts |
| --- | --- | --- |
| `SESSION_IDLE_DAYS` | 30 Tage | 15–30 **Minuten** bei geringem Risiko |
| `SESSION_MAX_DAYS` | 90 Tage | 4–8 **Stunden** absolut |
Die Voreinstellungen liegen bewusst weit darüber. Die Zahlen des Merkblatts
zielen auf öffentlich erreichbare Anwendungen; bei einem internen Werkzeug,
an dem man täglich arbeitet, wäre eine halbstündige Abmeldung eine Zumutung
und würde nur dazu führen, dass jemand den Browser dauerhaft offen lässt. Wer
strenger fahren will, setzt beide Werte in der `.env` — dafür sind sie
einstellbar.
### 6. `Secure` fehlte hinter einem TLS-Proxy
**Befund:** Das `Secure`-Flag am Cookie hing allein an `BASE_URL`. Wer die
Anwendung hinter einem Proxy mit HTTPS betreibt, aber `BASE_URL` auf `http`
stehen lässt, bekam den Cookie ohne `Secure` — er wäre über eine
unverschlüsselte Verbindung mitgegangen.
**Behoben:** Zusätzlich wird `X-Forwarded-Proto` ausgewertet.
### 7. Die Anmeldung verriet, welche Konten es gibt
**Befund:** Bei falschem Passwort kam immer dieselbe Meldung — richtig. Aber
drei Fälle fielen aus der Reihe und bestätigten damit die Existenz eines
Kontos:
- ein deaktiviertes Konto („Dieses Konto ist deaktiviert“),
- ein Konto aus dem Verzeichnis („meldet sich über den externen Anbieter an“),
- ein gesperrtes Konto (429 mit Wartezeit).
**Behoben** für die ersten beiden:
- Die Prüfung auf „deaktiviert“ steht jetzt **nach** der Passwortprüfung. Wer
bis dahin kommt, hat das Konto nachweislich in der Hand — ihm darf man
sagen, warum es nicht weitergeht.
- Verzeichnis-Konten bekommen die allgemeine Meldung. Der Knopf für den
Anbieter steht daneben.
**Bewusst offen gelassen:** die Sperrmeldung. Sie muss vor der Passwortprüfung
kommen — das ist ihr Zweck. Wer sie versteckt, lässt einen rechtmäßigen
Benutzer 15 Minuten im Dunkeln stehen. Bei einem Werkzeug, in dem die
E-Mail-Adressen der Kollegen ohnehin bekannt sind, wiegt die Verständlichkeit
schwerer als ein Hinweis, den ein Angreifer auch anders bekommt.
### 8. Kontosperre schützte nicht gegen breites Durchprobieren
**Befund:** Nach 8 Fehlversuchen wurde ein **Konto** 15 Minuten gesperrt. Wer
stattdessen ein Passwort gegen viele Konten probiert, löst diese Sperre nie
aus.
**Behoben:** zusätzlich eine Bremse je Absender — 20 Versuche in 5 Minuten,
danach 429. Im Arbeitsspeicher und damit pro Container; die Adresse dient nur
als Schlüssel und wird nirgends gespeichert.
### 9. Ersteinrichtung mit Wettlauf
**Befund:** Zwischen „gibt es schon einen Benutzer?“ und dem Anlegen lag eine
Lücke. Zwei gleichzeitige Aufrufe auf einer frischen Installation hätten
**zwei** Administratoren erzeugt — einer davon ungebeten.
**Behoben:** Prüfung und Anlegen laufen in einer serialisierbaren
Transaktion. Ein Test schickt drei Anfragen gleichzeitig; genau eine kommt
durch.
### 10. Abgelaufene Sitzungen blieben liegen
**Befund:** `pruneExpiredSessions()` existierte, wurde aber nirgends
aufgerufen. Kein Sicherheitsproblem — abgelaufene Sitzungen werden bei der
Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
wird beiläufig aufgeräumt.
---
## Was schon vorher stimmte
| Anforderung | Umsetzung |
| --- | --- |
| Sitzungs-Token aus einem CSPRNG, ≥ 128 Bit | 32 Byte aus `randomBytes` |
| Token nicht im Klartext gespeichert | in der Datenbank nur der SHA-256 |
| `HttpOnly` | gesetzt |
| `SameSite` | `Lax` |
| Pfad so eng wie möglich | `/` — die Anwendung liegt an der Wurzel |
| Neue Sitzung beim Anmelden | immer ein frisches Token; keine Sitzungsfixierung möglich |
| Alle Sitzungen beenden beim Passwortwechsel | ja, danach sofort eine neue |
| Sitzung entwerten beim Deaktivieren | bei jedem Zugriff wird `isActive` geprüft |
| Passwortvergleich in konstanter Zeit | `timingSafeEqual` |
| Gleiche Antwortzeit für unbekannte Adressen | Vergleich gegen einen Attrappen-Hash mit denselben Parametern |
| Berechtigungen an einer Stelle | `src/lib/auth/policy.ts`; jede Route fragt sie |
| Serverseitige Prüfung, nicht nur ausgeblendete Knöpfe | ja |
Zur **Anmeldung über OIDC** (siehe [authentik.md](authentik.md)): PKCE mit
S256, `state` gegen CSRF, `nonce` gegen wiedereingereichte Token, Prüfung des
ID-Tokens gegen den JWKS des Anbieters samt Aussteller, Empfänger und Ablauf.
Die Rückkehradresse ist fest verdrahtet — es gibt keinen Parameter, über den
sich eine offene Weiterleitung bauen ließe. Verknüpft wird ein bestehendes
Konto nur bei **bestätigter** E-Mail-Adresse.
---
## Offen
Bewusst nicht umgesetzt, mit Begründung.
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
`login`, `setup`, `logout` und `change-password`. Konten legt ein
Administrator an, der die Adresse selbst einträgt. Eine Bestätigungsmail
würde also belegen, dass eine Adresse existiert, aber niemanden hereinlassen
und niemanden fernhalten.
Dazu kommt: Es gibt **kein „Passwort vergessen" per E-Mail**. Zurücksetzen
kann nur ein Administrator unter Verwaltung → Benutzer. Damit ist die
unbestätigte Adresse auch kein Übernahmeweg — bei einem Reset per Mail wäre
sie einer. Die Adresse ist hier schlicht ein Anmeldename.
Nötig würde die Verifizierung in dem Moment, in dem sich Leute selbst
registrieren dürfen. Dann allerdings zwingend, zusammen mit einem
SMTP-Versand, Ablauffristen für die Bestätigungslinks und einem Weg für
nicht zugestellte Mails.
Zwei Stellen bleiben davon ohnehin unberührt, weil dort nicht der
Administrator die Adresse einträgt:
- **Die Ersteinrichtung.** Wer zuerst kommt, wird Administrator. Dagegen hilft
keine Verifizierung, sondern nur, das Fenster zu schließen — siehe unten.
- **Die Anmeldung über einen Anbieter.** Dort bürgt der Anbieter für die
Adresse, und genau das prüft die Anwendung: Ein bestehendes Konto wird nur
bei `email_verified` verknüpft.
**Das Zeitfenster der Ersteinrichtung.** Solange es keinen Benutzer gibt und
die Anmeldung eingeschaltet ist, wird das erste Konto ohne jede Legitimation
angelegt — und ist Administrator der Installation. Steht die Anwendung schon
öffentlich, bevor der Betreiber sein Konto anlegt, kann ein Fremder sie
übernehmen.
Heute ist das eine Frage der Reihenfolge: erst starten, sofort das erste
Konto anlegen, dann erreichbar machen. Sauberer wäre ein einmaliges Token,
das beim ersten Start ins Protokoll geschrieben wird und für die
Ersteinrichtung nötig ist. Steht als Vorschlag in der
[Roadmap](roadmap.md).
**Vollständige Content-Security-Policy für Skripte.** Bräuchte Nonces durch
die ganze Anwendung, weil Next.js eingebettete Skripte ausliefert. Der Nutzen
wäre echt — es ist die zweite Verteidigungslinie gegen XSS —, aber halb
umgesetzt ist sie wertlos. Gehört als eigenes Vorhaben auf die
[Roadmap](roadmap.md), nicht nebenbei erledigt.
**`__Host-` als Präfix des Cookie-Namens.** Das Merkblatt nennt es die
stärkste Variante. Es setzt `Secure` voraus — der Name müsste sich also je
nach Betriebsart ändern, und beim Umstellen von HTTP auf HTTPS würden alle
Sitzungen still ungültig. Für eine Anwendung, die auch im LAN über HTTP läuft,
ist das mehr Verwirrung als Gewinn. Unter reinem HTTPS wäre es eine sinnvolle
Ergänzung.
**Abgleich gegen bekannt gewordene Passwörter.** Eine Prüfung gegen die
Have-I-Been-Pwned-Liste (k-Anonymität, ohne das Passwort zu übertragen) wäre
der wirksamste einzelne Schritt für die Passwortqualität. Sie setzt aber
voraus, dass die Anwendung ins Internet telefonieren darf — bei einer selbst
gehosteten Installation nicht selbstverständlich. Sinnvoll als abschaltbare
Option.
**Zweiter Faktor.** Nicht vorhanden. Wer ihn braucht, bekommt ihn heute über
den Umweg Authentik: Dort lässt sich TOTP oder WebAuthn erzwingen, und diese
Anwendung übernimmt nur das Ergebnis. Das ist die bessere Arbeitsteilung als
eine eigene halbgare Umsetzung.
**Protokollierung sicherheitsrelevanter Ereignisse.** Fehlversuche, Sperren
und Rechteänderungen werden nicht eigens protokolliert. Für den Betrieb im
Team verzichtbar; bei mehr Konten wäre es das Erste, was fehlt.
**Verteilte Bremse.** Die Zähler für Anmeldeversuche und Rückmeldungen liegen
im Arbeitsspeicher und gelten damit je Container. Bei mehreren Instanzen
hinter einem Lastverteiler bräuchte es einen gemeinsamen Speicher.
---
## Nachgeprüft
Die Härtungen sind durch Tests belegt (21 Prüfungen), unter anderem:
- Alle fünf Schutzheader sind an jeder Antwort vorhanden, `X-Powered-By` ist
weg.
- Ein schreibender Zugriff mit fremdem `Origin` wird abgewiesen — auch beim
Upload; mit eigenem oder ohne `Origin` geht er durch.
- Hochgeladene Dateien behalten ihre eigene, schärfere Sandbox-Regel.
- Ein 5000 Zeichen langes Passwort wird abgewiesen, bevor gerechnet wird.
- Drei gleichzeitige Ersteinrichtungen erzeugen genau einen Administrator.
- Eine unbekannte Adresse ist an der Antwort nicht von einem falschen
Passwort zu unterscheiden — auch nicht bei einem Verzeichnis-Konto.
- Der Sitzungs-Cookie trägt `HttpOnly`, `SameSite=Lax`, `Path=/` und ein
ausreichend langes Token; `Secure` erscheint nur unter HTTPS.
+4
View File
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
// sollen nicht vom Bundler verarbeitet werden.
serverExternalPackages: ["qrcode", "pg"],
// „X-Powered-By: Next.js" verrät nur, wonach jemand Schwachstellen suchen
// müsste. Die Schutzheader setzt src/middleware.ts.
poweredByHeader: false,
};
export default nextConfig;
+60 -2
View File
@@ -1,15 +1,16 @@
{
"name": "qrcode-generator",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qrcode-generator",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
"jose": "^6.2.8",
"next": "16.3.0",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
@@ -28,6 +29,7 @@
"dotenv": "^17.2.4",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1",
"tailwindcss": "^4",
"typescript": "^5"
@@ -5024,6 +5026,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5950,6 +5967,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7085,6 +7111,38 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+4 -1
View File
@@ -1,11 +1,12 @@
{
"name": "qrcode-generator",
"version": "1.0.0",
"version": "1.6.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
@@ -16,6 +17,7 @@
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
"jose": "^6.2.8",
"next": "16.3.0",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
@@ -34,6 +36,7 @@
"dotenv": "^17.2.4",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1",
"tailwindcss": "^4",
"typescript": "^5"
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "QrTypeSetting" (
"type" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeSetting_pkey" PRIMARY KEY ("type")
);
@@ -0,0 +1,124 @@
-- Anmeldung, Gruppen und Rollen.
--
-- Die Reihenfolge ist wichtig: `QrCode.groupId` ist verpflichtend, es gibt
-- aber möglicherweise schon Codes. Deshalb wird die Spalte erst ohne
-- Einschränkung angelegt, dann mit der Standardgruppe gefüllt und erst
-- danach auf NOT NULL gesetzt. Ohne diesen Zwischenschritt scheitert die
-- Migration auf jeder Installation, die bereits benutzt wurde.
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"failedLogins" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Membership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"role" "Role" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
-- CreateIndex
CREATE INDEX "Membership_groupId_idx" ON "Membership"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "Membership_userId_groupId_key" ON "Membership"("userId", "groupId");
-- Standardgruppe anlegen. Sie ist auch bei ausgeschalteter Anmeldung die
-- Heimat aller Codes, damit es keinen Sonderfall "Code ohne Gruppe" gibt.
INSERT INTO "Group" ("id", "name", "description", "createdAt", "updatedAt")
VALUES (
'grp_standard',
'Standard',
'Beim ersten Start angelegt. Enthält alle Codes, die vor Einführung der Benutzerverwaltung entstanden sind.',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- AlterTable: erst nullable anlegen …
ALTER TABLE "QrCode" ADD COLUMN "createdById" TEXT,
ADD COLUMN "groupId" TEXT;
-- … bestehende Codes der Standardgruppe zuordnen …
UPDATE "QrCode" SET "groupId" = 'grp_standard' WHERE "groupId" IS NULL;
-- … und erst jetzt verpflichtend machen.
ALTER TABLE "QrCode" ALTER COLUMN "groupId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "QrCode_groupId_idx" ON "QrCode"("groupId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,86 @@
-- Mehrere Domains für Kurz-URLs.
--
-- Zwei Dinge sind hier heikel und deshalb ausdrücklich schrittweise gelöst:
--
-- 1. `QrCode.domainId` ist verpflichtend, es gibt aber schon Codes. Die
-- Spalte entsteht zuerst ohne Einschränkung, wird gefüllt und erst danach
-- auf NOT NULL gesetzt.
-- 2. `slug` war global eindeutig und ist es künftig nur noch je Domain. Der
-- alte Index muss weichen, bevor der neue greifen kann.
-- CreateTable
CREATE TABLE "Domain" (
"id" TEXT NOT NULL,
"hostname" TEXT NOT NULL,
"label" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Domain_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainGrant" (
"id" TEXT NOT NULL,
"domainId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DomainGrant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Domain_hostname_key" ON "Domain"("hostname");
-- CreateIndex
CREATE INDEX "DomainGrant_groupId_idx" ON "DomainGrant"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainGrant_domainId_groupId_key" ON "DomainGrant"("domainId", "groupId");
-- Standard-Domain. Der Hostname ist zunächst ein Platzhalter: die
-- Migration kennt BASE_URL nicht. Beim ersten Start ersetzt
-- ensureDefaultDomain() ihn durch den Hostnamen aus BASE_URL.
INSERT INTO "Domain" ("id", "hostname", "label", "isActive", "createdAt", "updatedAt")
VALUES (
'dom_standard',
'standard.invalid',
'Standard-Domain',
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
-- Alle bestehenden Gruppen bekommen die Standard-Domain als Voreinstellung.
INSERT INTO "DomainGrant" ("id", "domainId", "groupId", "isDefault", "createdAt")
SELECT 'dg_std_' || "Group"."id", 'dom_standard', "Group"."id", true, CURRENT_TIMESTAMP
FROM "Group";
-- AlterTable: erst nullable …
ALTER TABLE "QrCode" ADD COLUMN "domainId" TEXT;
-- … bestehende Codes der Standard-Domain zuordnen …
UPDATE "QrCode" SET "domainId" = 'dom_standard' WHERE "domainId" IS NULL;
-- … und erst jetzt verpflichtend machen.
ALTER TABLE "QrCode" ALTER COLUMN "domainId" SET NOT NULL;
-- Eindeutigkeit des Slugs von global auf „je Domain“ umstellen.
DROP INDEX "QrCode_slug_key";
-- CreateIndex
CREATE UNIQUE INDEX "QrCode_domainId_slug_key" ON "QrCode"("domainId", "slug");
-- CreateIndex
CREATE INDEX "QrCode_domainId_idx" ON "QrCode"("domainId");
-- AddForeignKey
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DomainGrant" ADD CONSTRAINT "DomainGrant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- Hochgeladene Dateien für die Typen „PDF" und „Bild".
--
-- Der Inhalt liegt als bytea in der Datenbank. Damit bleibt es bei einem
-- Volumen, das gesichert werden muss.
CREATE TABLE "Upload" (
"id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"data" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"qrCodeId" TEXT,
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Upload_qrCodeId_idx" ON "Upload"("qrCodeId");
CREATE INDEX "Upload_createdAt_idx" ON "Upload"("createdAt");
-- Wird ein Code gelöscht, verschwindet seine Datei mit ihm.
ALTER TABLE "Upload"
ADD CONSTRAINT "Upload_qrCodeId_fkey"
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- Rückmeldungen zu einem Feedback-Code.
--
-- Ohne IP-Adresse und ohne Gerätekennung: Wer einen Aushang scannt und eine
-- Note vergibt, soll nicht wiedererkennbar sein.
CREATE TABLE "Feedback" (
"id" TEXT NOT NULL,
"qrCodeId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Feedback_qrCodeId_createdAt_idx" ON "Feedback"("qrCodeId", "createdAt");
ALTER TABLE "Feedback"
ADD CONSTRAINT "Feedback_qrCodeId_fkey"
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- Anmeldung über einen externen Anbieter (OpenID Connect).
--
-- `oidcSubject` ist die stabile Kennung beim Anbieter. Sie und nicht die
-- E-Mail-Adresse entscheidet, wer wiedererkannt wird — Adressen ändern sich.
ALTER TABLE "User" ADD COLUMN "oidcSubject" TEXT;
CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject");
-- Konten, die sich nur über den Anbieter anmelden, haben kein Passwort.
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
@@ -0,0 +1,9 @@
-- Harte Obergrenze für Sitzungen, unabhängig von der Aktivität.
--
-- Bestehende Sitzungen bekommen 90 Tage ab jetzt — sie sollen durch die
-- Änderung nicht plötzlich alle ungültig werden.
ALTER TABLE "Session"
ADD COLUMN "absoluteExpiresAt" TIMESTAMP(3) NOT NULL
DEFAULT (CURRENT_TIMESTAMP + INTERVAL '90 days');
CREATE INDEX "Session_absoluteExpiresAt_idx" ON "Session"("absoluteExpiresAt");
@@ -0,0 +1,31 @@
-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation.
-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an
-- einem Code, deshalb ist die Voreinstellung richtig.
ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code';
CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose");
CREATE TABLE "Branding" (
"id" TEXT NOT NULL DEFAULT 'branding',
"appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator',
"footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes',
"accentColor" TEXT NOT NULL DEFAULT '#4F46E5',
"logoUploadId" TEXT,
"faviconUploadId" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Branding_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId");
CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId");
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey"
FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey"
FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss.
INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP);
@@ -0,0 +1,56 @@
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
-- gedruckten Codes entwerten.
CREATE TABLE "QrCodeAlias" (
"id" TEXT NOT NULL,
"domainId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"qrCodeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
--
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
-- bricht die Migration in dem Fall lieber ab.
DO $$
DECLARE kollisionen INT;
BEGIN
SELECT COUNT(*) INTO kollisionen FROM (
SELECT "domainId", LOWER("slug")
FROM "QrCode"
GROUP BY "domainId", LOWER("slug")
HAVING COUNT(*) > 1
) AS doppelt;
IF kollisionen > 0 THEN
RAISE EXCEPTION
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
kollisionen;
END IF;
END $$;
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
-- koennen.
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
ON "QrCode"("domainId", LOWER("slug"));
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
ON "QrCodeAlias"("domainId", LOWER("slug"));
@@ -0,0 +1,12 @@
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
CREATE TABLE "InstanceSetting" (
"id" TEXT NOT NULL DEFAULT 'instance',
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
);
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
@@ -0,0 +1,12 @@
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
-- und erneut herunterladen kann.
-- Alles, was es bisher gibt, ist dynamisch.
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
+295 -4
View File
@@ -10,15 +10,170 @@ datasource db {
provider = "postgresql"
}
/// Rolle eines Benutzers innerhalb einer Gruppe.
///
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
/// erste Gruppe erstellt.
enum Role {
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
ADMIN
/// Darf Codes anlegen, ändern und löschen.
EDITOR
/// Darf nur lesen und herunterladen.
VIEWER
}
model User {
id String @id @default(cuid())
email String @unique
name String?
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
passwordHash String?
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
/// sich das Konto nur mit Passwort anmeldet.
oidcSubject String? @unique
/// Darf Gruppen, Benutzer und Domains verwalten.
isInstanceAdmin Boolean @default(false)
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
/// werden beim nächsten Zugriff verworfen.
isActive Boolean @default(true)
failedLogins Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
memberships Membership[]
createdCodes QrCode[] @relation("QrCodeCreator")
@@index([email])
}
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
/// deaktiviert wird.
model Session {
id String @id @default(cuid())
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
expiresAt DateTime
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([absoluteExpiresAt])
}
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
model Group {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
codes QrCode[]
domainGrants DomainGrant[]
}
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
model Membership {
id String @id @default(cuid())
userId String
groupId String
role Role
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@index([groupId])
}
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
///
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
/// damit lässt sich über einen gefälschten Header keine fremde Domain
/// ansprechen.
model Domain {
id String @id @default(cuid())
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
hostname String @unique
label String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
grants DomainGrant[]
codes QrCode[]
aliases QrCodeAlias[]
}
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
/// `isDefault`.
model DomainGrant {
id String @id @default(cuid())
domainId String
groupId String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([domainId, groupId])
@@index([groupId])
}
/// Freigabe eines QR-Typs durch die Verwaltung.
///
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
/// automatisch sichtbar.
model QrTypeSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String @id
enabled Boolean
updatedAt DateTime @updatedAt
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
///
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode {
id String @id @default(cuid())
slug String @unique
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
mode String @default("dynamic")
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
///
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
slug String?
type String
label String?
data Json
@@ -32,5 +187,141 @@ model QrCode {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
domainId String
domain Domain @relation(fields: [domainId], references: [id])
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
groupId String
group Group @relation(fields: [groupId], references: [id])
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
createdById String?
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
/// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit.
uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug])
@@index([createdAt])
@@index([groupId])
@@index([domainId])
@@index([mode])
}
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
/// einem Bild-Code.
///
/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es
/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer
/// Installation dieser Größenordnung ist das der bessere Tausch als ein
/// zweites Volumen, das beim Sichern vergessen werden kann.
model Upload {
id String @id @default(cuid())
/// Ursprünglicher Dateiname, für den Download.
filename String
mimeType String
/// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden.
size Int
data Bytes
createdAt DateTime @default(now())
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
/// erste Art wird als herrenlos weggeräumt.
purpose String @default("code")
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
/// `src/lib/uploads.ts` nach einer Weile weg.
qrCodeId String?
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
brandingLogo Branding? @relation("BrandingLogo")
brandingFavicon Branding? @relation("BrandingFavicon")
@@index([qrCodeId])
@@index([createdAt])
@@index([purpose])
}
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
model Branding {
id String @id @default("branding")
appName String @default("QR-Code-Generator")
/// Zeile in der Fußzeile, hinter dem Namen.
footerText String @default("statische und dynamische QR-Codes")
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
/// src/lib/branding-colors.ts.
accentColor String @default("#4F46E5")
logoUploadId String? @unique
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
faviconUploadId String? @unique
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
updatedAt DateTime @updatedAt
}
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
/// abgegeben hat.
///
/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen
/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt,
/// soll nicht wiedererkennbar sein.
model Feedback {
id String @id @default(cuid())
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
/// 1 bis 5.
rating Int
comment String?
createdAt DateTime @default(now())
@@index([qrCodeId, createdAt])
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
*
* node scripts/changelog.mjs erzeugen
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
*
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
const ziel = join(wurzel, "CHANGELOG.md");
/**
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
* besteht ausschließlich aus Zeichenketten und Listen.
*/
function ladeVerlauf() {
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
const anfang = quelle.indexOf("export const CHANGELOG");
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
const start = quelle.indexOf("[", anfang);
const ende = quelle.indexOf("\n];", start);
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
const literal = quelle.slice(start, ende + 2);
return Function(`"use strict"; return (${literal});`)();
}
function formatiereDatum(iso) {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
function baue(verlauf) {
const zeilen = [
"# Änderungen",
"",
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
"Anwendung unter **Version und Änderungen**, erreichbar über die",
"Versionsnummer in der Fußzeile.",
"",
"> Diese Datei wird erzeugt: `npm run changelog`.",
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
"",
];
for (const eintrag of verlauf) {
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
zeilen.push(eintrag.summary, "");
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
zeilen.push("");
}
return zeilen.join("\n");
}
const inhalt = baue(ladeVerlauf());
if (process.argv.includes("--check")) {
let vorhanden = "";
try {
vorhanden = readFileSync(ziel, "utf8");
} catch {
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
process.exit(1);
}
if (vorhanden !== inhalt) {
console.error(
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
);
process.exit(1);
}
console.log("CHANGELOG.md ist aktuell.");
} else {
writeFileSync(ziel, inhalt, "utf8");
console.log("CHANGELOG.md geschrieben.");
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { LoginForm, SetupForm } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { Notice } from "@/components/ui";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Anmelden" };
export const dynamic = "force-dynamic";
type PageProps = { searchParams: Promise<{ fehler?: string }> };
export default async function SignInPage({ searchParams }: PageProps) {
// Ohne Anmeldung gibt es hier nichts zu tun.
if (!isAuthEnabled()) redirect("/");
const actor = await getActor();
if (actor.kind === "user") redirect("/");
const setup = await isSetupOpen();
const { fehler } = await searchParams;
// Eine unvollständige OIDC-Konfiguration soll die Anmeldeseite nicht
// unbenutzbar machen — dann bleibt wenigstens das Passwortfeld.
let oidc: { label: string; nurOidc: boolean } | null = null;
let oidcFehler: string | null = null;
if (isOidcEnabled()) {
try {
const config = getOidcConfig();
oidc = { label: config.buttonLabel, nurOidc: !config.allowLocalLogin };
} catch (error) {
oidcFehler =
error instanceof Error
? error.message
: "Die Anmeldung über den externen Anbieter ist nicht eingerichtet.";
}
}
// Bei reiner Anbieter-Anmeldung gibt es keine Ersteinrichtung mit Passwort:
// Der erste, der sich über den Anbieter anmeldet, wird Administrator.
const zeigeFormular = !oidc?.nurOidc;
return (
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
<div className="text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
</span>
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
</h1>
<p className="mt-2 text-sm text-slate-600">
{setup && zeigeFormular
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
</p>
</div>
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
{oidc ? (
<a
href="/api/auth/oidc/start"
className="flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700"
>
<Icon name="lock" className="size-4" />
{oidc.label}
</a>
) : null}
{oidc && zeigeFormular ? (
<div className="flex items-center gap-3 text-xs text-slate-400 uppercase">
<span className="h-px flex-1 bg-slate-200" />
oder mit Passwort
<span className="h-px flex-1 bg-slate-200" />
</div>
) : null}
{zeigeFormular ? (
setup ? (
<SetupForm minLength={MIN_PASSWORD_LENGTH} />
) : (
<LoginForm />
)
) : null}
{setup && zeigeFormular ? (
<Notice tone="info">
Der erste Benutzer wird Administrator der Installation und darf
Gruppen, Benutzer und die QR-Typen verwalten.
</Notice>
) : oidc?.nurOidc ? (
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
setup ? (
<p className="text-center text-sm text-slate-500">
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
</p>
) : null
) : (
<p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
Benutzer zurücksetzen.
</p>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
type Props = { params: Promise<{ type: string }> };
// Die Freigabe der Typen kommt aus der Datenbank und ist jederzeit änderbar.
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
const qrType = getQrType(type);
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
}
/** Erklärseite statt 404 — der Typ existiert, ist nur (noch) nicht nutzbar. */
function Hinweis({
icon,
titel,
text,
}: {
icon: Parameters<typeof Icon>[0]["name"];
titel: string;
text: string;
}) {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name={icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">{titel}</h1>
<p className="mt-2 text-slate-600">{text}</p>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Typ-Auswahl
</Link>
</div>
);
}
export default async function CreatePage({ params }: Props) {
const actor = await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
if (!state) {
notFound();
}
if (!state.enabled) {
return (
<Hinweis
icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
/>
);
}
if (!state.implemented) {
return (
<Hinweis
icon={state.qrType.icon}
titel={`„${state.qrType.title}“ ist bald verfügbar`}
text="Dieser QR-Typ folgt in einer späteren Version. Bereits verfügbare Typen findest du in der Typ-Auswahl."
/>
);
}
// 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} />;
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
export const metadata: Metadata = { title: "Kein Zugriff" };
export default function NoAccessPage() {
return (
<div className="mx-auto max-w-md px-4 py-20 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name="lock" className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">Kein Zugriff</h1>
<p className="mt-2 text-slate-600">
Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
dass das ein Versehen ist, wende dich an einen Administrator.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Startseite
</Link>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ChangePasswordForm, SignOutCard } from "@/components/account";
import { Badge, Card, CardTitle, Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePageActor } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Konto" };
export const dynamic = "force-dynamic";
export default async function AccountPage() {
const actor = await requirePageActor();
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
const ausVerzeichnis =
actor.kind === "user"
? ((await prisma.user.findUnique({
where: { id: actor.user.id },
select: { passwordHash: true },
}))?.passwordHash ?? null) === null
: false;
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
// wie sich das umstellen lässt, statt einfach leer zu sein.
if (actor.kind !== "user") {
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
Konto
</h1>
<div className="mt-5">
<Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
Anwendung erreicht, darf alles. Zum Einschalten{" "}
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>{" "}
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d
</code>{" "}
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
an.
</Notice>
</div>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Typ-Auswahl
</Link>
</div>
);
}
const { user } = actor;
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Konto
</h1>
<p className="mt-2 text-slate-600">
Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest.
</p>
</div>
<div className="space-y-4">
<Card>
<CardTitle>Angemeldet als</CardTitle>
<dl className="space-y-2 text-sm">
<div className="flex flex-wrap gap-x-2">
<dt className="text-slate-500">Name:</dt>
<dd className="text-slate-900">{user.name?.trim() || "—"}</dd>
</div>
<div className="flex flex-wrap gap-x-2">
<dt className="text-slate-500">E-Mail:</dt>
<dd className="text-slate-900">{user.email}</dd>
</div>
<div className="flex flex-wrap items-center gap-x-2">
<dt className="text-slate-500">Rolle:</dt>
<dd>
{user.isInstanceAdmin ? (
<Badge tone="accent">Administrator der Installation</Badge>
) : (
<span className="text-slate-900">Benutzer</span>
)}
</dd>
</div>
</dl>
</Card>
<Card>
<CardTitle
hint={
user.isInstanceAdmin
? "Als Administrator der Installation siehst du ohnehin alle Gruppen."
: undefined
}
>
Meine Gruppen
</CardTitle>
{user.memberships.length === 0 ? (
<p className="text-sm text-slate-600">
Du bist in keiner Gruppe. Bitte einen Administrator, dich einer
zuzuordnen — sonst kannst du keine Codes anlegen.
</p>
) : (
<ul className="space-y-2">
{user.memberships.map((membership) => (
<li
key={membership.groupId}
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-3"
>
<span className="font-medium text-slate-900">
{membership.groupName}
</span>
<Badge tone="neutral">{ROLE_LABELS[membership.role]}</Badge>
<span className="w-full text-sm text-slate-500">
{ROLE_DESCRIPTIONS[membership.role]}
</span>
</li>
))}
</ul>
)}
</Card>
{isAuthEnabled() ? (
<>
{ausVerzeichnis ? (
<Card>
<CardTitle>Passwort</CardTitle>
<p className="mt-2 text-sm text-slate-600">
Dieses Konto meldet sich über den externen Anbieter an. Ein
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
</p>
</Card>
) : (
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
)}
<SignOutCard />
</>
) : null}
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import Link from "next/link";
import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden.
export const dynamic = "force-dynamic";
const NAV_LINK =
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
/**
* Rahmen der eigentlichen Anwendung: Kopfzeile mit Navigation, Inhalt,
* Fußzeile. Die öffentlichen Landingpages liegen bewusst außerhalb dieser
* Gruppe und bekommen davon nichts ab.
*/
export default async function AppLayout({ children }: LayoutProps<"/">) {
const actor = await getActor();
const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage");
const branding = await getBranding();
return (
<div className="flex flex-1 flex-col">
<header className="border-b border-slate-200 bg-surface">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
{branding.logoUploadId ? (
/* eslint-disable-next-line @next/next/no-img-element -- aus der
Datenbank ausgeliefert, nicht aus dem Dateisystem */
<img
src={`/api/uploads/${branding.logoUploadId}`}
alt={branding.appName}
className="h-9 w-auto max-w-[12rem] object-contain"
/>
) : (
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
)}
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
dieses schon den Namen. */}
{branding.logoUploadId ? null : (
<span className="hidden sm:inline">{branding.appName}</span>
)}
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
<ThemeToggle />
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
{actorLabel(actor).slice(0, 2)}
</span>
<span className="hidden truncate sm:inline">
{actorLabel(actor)}
</span>
</Link>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
{/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar
gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */}
{!isAuthEnabled() ? (
<>
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
<Link
href="/konto"
title="Die Anmeldung ist ausgeschaltet — hier steht, wie du sie einschaltest"
className="rounded-lg px-3 py-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
Ohne Anmeldung
</Link>
</>
) : null}
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
<span>
{branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</span>
{/* Welche Fassung hier läuft — nach einem Update die schnellste
Antwort auf die Frage, ob das Update auch angekommen ist. */}
<Link
href="/version"
title="Version und Änderungen"
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
>
v{APP_VERSION}
</Link>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import { can } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Rückmeldungen" };
type PageProps = { params: Promise<{ id: string }> };
function formatDate(value: Date): string {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
}
export default async function FeedbackResultsPage({ params }: PageProps) {
const { id } = await params;
const actor = await requirePageActor();
const code = await prisma.qrCode.findUnique({
where: { id },
include: {
feedback: { orderBy: { createdAt: "desc" }, take: 200 },
},
});
if (!code || code.type !== "feedback") notFound();
if (!can(actor, "codes:read", { groupId: code.groupId })) notFound();
const daten = feedbackDataSchema.safeParse(code.data);
const skala = daten.success ? daten.data.scale : "stars";
const titel = daten.success ? daten.data.title : (code.label ?? "Feedback");
const eintraege = code.feedback;
const anzahl = eintraege.length;
const schnitt =
anzahl > 0
? eintraege.reduce((summe, eintrag) => summe + eintrag.rating, 0) / anzahl
: 0;
// Verteilung von 5 nach 1 — so liest man eine Bewertungsübersicht.
const verteilung = [5, 4, 3, 2, 1].map((note) => ({
note,
anzahl: eintraege.filter((eintrag) => eintrag.rating === note).length,
}));
const groesste = Math.max(1, ...verteilung.map((zeile) => zeile.anzahl));
const kommentare = eintraege.filter((eintrag) => eintrag.comment);
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/meine-codes"
className="mb-5 inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zurück zu Meine Codes
</Link>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
{titel}
</h1>
<p className="mt-2 text-slate-600">
{anzahl === 0
? "Es ist noch keine Rückmeldung eingegangen."
: `${anzahl} ${anzahl === 1 ? "Rückmeldung" : "Rückmeldungen"} · ${code.scans} ${code.scans === 1 ? "Scan" : "Scans"}`}
</p>
{anzahl > 0 ? (
<>
<Card className="mt-7">
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
<div>
<p className="text-4xl font-bold text-slate-900 tabular-nums">
{schnitt.toFixed(1).replace(".", ",")}
</p>
<p className="text-sm text-slate-500">von 5 im Schnitt</p>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{verteilung.map((zeile) => (
<div key={zeile.note} className="flex items-center gap-2.5">
<span className="w-10 shrink-0 text-right text-sm text-slate-500 tabular-nums">
{zeile.note}{" "}
<span aria-hidden="true">
{FEEDBACK_SCALES[skala].symbols[zeile.note - 1]}
</span>
</span>
<span className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-100">
<span
className="block h-full rounded-full bg-indigo-500"
style={{
width: `${Math.round((zeile.anzahl / groesste) * 100)}%`,
}}
/>
</span>
<span className="w-8 shrink-0 text-sm text-slate-600 tabular-nums">
{zeile.anzahl}
</span>
</div>
))}
</div>
</div>
</Card>
{kommentare.length > 0 ? (
<div className="mt-7">
<h2 className="mb-3 text-lg font-semibold text-slate-900">
Kommentare ({kommentare.length})
</h2>
<ul className="space-y-3">
{kommentare.map((eintrag) => (
<li
key={eintrag.id}
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
>
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
<span className="font-medium text-slate-900 tabular-nums">
{eintrag.rating}/5
</span>
<span aria-hidden="true">·</span>
<time dateTime={eintrag.createdAt.toISOString()}>
{formatDate(eintrag.createdAt)}
</time>
</div>
<p className="whitespace-pre-wrap text-slate-800">
{eintrag.comment}
</p>
</li>
))}
</ul>
{anzahl >= 200 ? (
<p className="mt-3 text-sm text-slate-500">
Angezeigt werden die 200 jüngsten Rückmeldungen.
</p>
) : null}
</div>
) : (
<p className="mt-7 text-slate-600">
Bisher hat niemand etwas dazugeschrieben.
</p>
)}
</>
) : (
<Card className="mt-7">
<p className="text-slate-600">
Sobald jemand den Code scannt und eine Bewertung abgibt, steht sie
hier. Die Rückmeldungen enthalten keinen Namen und keine
IP-Adresse.
</p>
</Card>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können.
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
// dann mit der Gruppe des betroffenen Codes.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Meine Codes
</h1>
<p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
gespeichert.
</p>
</div>
<CodesList maySetSlug={maySetSlug} />
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability";
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic";
export default async function HomePage() {
await requirePageActor();
const types = await getVisibleQrTypes();
const usableCount = types.filter((entry) => entry.usable).length;
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
Was möchtest du erstellen?
</h1>
<p className="mt-3 text-base text-slate-600">
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
später änderst, ohne den Code neu drucken zu müssen.
</p>
</div>
{usableCount === 0 ? (
<div className="mt-9 max-w-2xl">
<Notice tone="warning">
{types.length === 0
? "Zurzeit ist kein QR-Typ freigegeben."
: "Zurzeit ist kein QR-Typ zum Erstellen freigegeben — die unten gezeigten Typen folgen erst in einer späteren Version."}{" "}
Unter{" "}
<Link href="/verwaltung/typen" className="font-medium underline">
Verwaltung → QR-Typen
</Link>{" "}
lassen sich Typen wieder freigeben.
</Notice>
</div>
) : null}
{types.length === 0 ? null : (
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{types.map(({ qrType, usable }) => {
const tile = (
<>
<span
className={
usable
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
}
>
<Icon name={qrType.icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span
className={
usable
? "font-semibold text-slate-900"
: "font-semibold text-slate-500"
}
>
{qrType.title}
</span>
{/* Kein Abzeichen „Beliebt“ mehr: Es stand an jedem
nutzbaren Typ und sagte damit nichts. Hier steht nur
noch, was die Wahl beeinflusst — nämlich wenn ein Typ
zwingend eine Kurz-URL braucht und deshalb nicht ohne
diesen Server funktioniert. */}
{!usable ? (
<Badge tone="soon">Bald verfügbar</Badge>
) : !qrType.supportsStatic ? (
<Badge tone="neutral">nur dynamisch</Badge>
) : null}
</span>
<span
className={
usable
? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400"
}
>
{qrType.description}
</span>
</span>
</>
);
return (
<li key={qrType.id}>
{usable ? (
<Link
href={`/erstellen/${qrType.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
{tile}
</Link>
) : (
<div
aria-disabled="true"
title={`„${qrType.title}“ folgt in einer späteren Version`}
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
>
{tile}
</div>
)}
</li>
);
})}
</ul>
)}
{usableCount > 0 && usableCount < types.length ? (
<p className="mt-8 text-sm text-slate-500">
Die übrigen Typen folgen in einer späteren Version. Welche Kacheln
hier erscheinen, legst du unter{" "}
<Link
href="/verwaltung/typen"
className="font-medium text-slate-600 underline"
>
Verwaltung → QR-Typen
</Link>{" "}
fest.
</p>
) : null}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import {
APP_VERSION,
CHANGELOG,
formatChangelogDate,
} from "@/lib/changelog";
export const metadata: Metadata = { title: "Version und Änderungen" };
export const dynamic = "force-dynamic";
export default async function VersionPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Version und Änderungen
</h1>
<p className="mt-2 text-slate-600">
Hier läuft Version{" "}
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
Darunter steht, was in welcher Fassung dazugekommen ist.
</p>
<p className="mt-2 text-sm text-slate-500">
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
Nummer, läuft der Container noch mit dem alten Abbild.
</p>
</div>
<ol className="space-y-4">
{CHANGELOG.map((eintrag) => {
const laeuft = eintrag.version === APP_VERSION;
return (
<li key={eintrag.version}>
<Card>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-lg font-semibold text-slate-900">
{eintrag.version}
</h2>
<span className="text-sm text-slate-500 tabular-nums">
{formatChangelogDate(eintrag.date)}
</span>
{laeuft ? (
<Badge tone="accent">läuft hier</Badge>
) : null}
</div>
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
<ul className="space-y-1.5 text-sm text-slate-700">
{eintrag.changes.map((zeile) => (
<li key={zeile} className="flex gap-2.5">
<span
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
/>
<span>{zeile}</span>
</li>
))}
</ul>
</Card>
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { UserAdmin } from "@/components/user-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Benutzer" };
export const dynamic = "force-dynamic";
export default async function UsersPage() {
const actor = await requirePagePermission("instance:manage");
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Benutzer
</h1>
<p className="mt-2 text-slate-600">
Konten anlegen, Passwörter setzen und Zugänge deaktivieren. Wer in
welcher Gruppe mitarbeitet, legst du unter{" "}
<Link href="/verwaltung/gruppen" className="font-medium underline">
Gruppen
</Link>{" "}
fest.
</p>
</div>
<UserAdmin
minPasswordLength={MIN_PASSWORD_LENGTH}
currentUserId={actor.kind === "user" ? actor.user.id : null}
/>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { DomainAdmin } from "@/components/domain-admin";
import { Icon } from "@/components/icons";
import { requirePagePermission } from "@/lib/auth/guard";
import { baseUrlHostname } from "@/lib/domains";
export const metadata: Metadata = { title: "Domains" };
export const dynamic = "force-dynamic";
export default async function DomainsPage() {
await requirePagePermission("instance:manage");
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Domains
</h1>
<p className="mt-2 text-slate-600">
Unter welchen Adressen Kurz-URLs ausgeliefert werden. Eine Domain
kann mehreren Gruppen dienen; der Slug eines Codes ist je Domain
eindeutig.
</p>
</div>
<DomainAdmin baseUrlHostname={baseUrlHostname()} />
</div>
);
}
@@ -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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import Link from "next/link";
import { GroupAdmin } from "@/components/group-admin";
import { Icon } from "@/components/icons";
import { requirePageActor } from "@/lib/auth/guard";
import { can } from "@/lib/auth/policy";
export const metadata: Metadata = { title: "Gruppen" };
export const dynamic = "force-dynamic";
export default async function GroupsPage() {
// Bewusst nur „angemeldet“: ein Gruppen-Administrator darf seine eigene
// Gruppe pflegen, auch ohne Rechte an der Installation.
const actor = await requirePageActor();
const canCreate = can(actor, "instance:manage");
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href={canCreate ? "/verwaltung" : "/"}
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
{canCreate ? "Verwaltung" : "Zur Typ-Auswahl"}
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Gruppen
</h1>
<p className="mt-2 text-slate-600">
Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. Jedes
Mitglied hat darin eine Rolle: Administrator, Bearbeiter oder
Betrachter.
</p>
</div>
<GroupAdmin canCreate={canCreate} />
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
export const dynamic = "force-dynamic";
function Tile({
href,
icon,
title,
description,
hint,
}: {
href: string;
icon: IconName;
title: string;
description: string;
hint: string;
}) {
return (
<Link
href={href}
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
<Icon name={icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="font-semibold text-slate-900">{title}</span>
<span className="mt-1 block text-sm text-slate-600">{description}</span>
<span className="mt-2 block text-xs text-slate-500 tabular-nums">
{hint}
</span>
</span>
</Link>
);
}
export default async function AdminPage() {
await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
getBranding(),
getSettings(),
]);
const eigenes = [
branding.logoUploadId && "Logo",
branding.faviconUploadId && "Favicon",
].filter(Boolean);
const brandingHint =
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Verwaltung
</h1>
<p className="mt-2 text-slate-600">
Benutzer, Gruppen und die verfügbaren QR-Typen.
</p>
</div>
{!isAuthEnabled() ? (
<div className="mb-6">
<Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>
in der <code className="font-mono text-xs">.env</code> setzen und
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d
</code>
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
an. Benutzer und Gruppen lassen sich schon jetzt vorbereiten.
</Notice>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Tile
href="/verwaltung/benutzer"
icon="contact"
title="Benutzer"
description="Konten anlegen, Passwörter zurücksetzen, deaktivieren."
hint={`${userCount} ${userCount === 1 ? "Benutzer" : "Benutzer"} angelegt`}
/>
<Tile
href="/verwaltung/gruppen"
icon="list"
title="Gruppen"
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
/>
<Tile
href="/verwaltung/domains"
icon="globe"
title="Domains"
description="Adressen, unter denen Kurz-URLs ausgeliefert werden."
hint={`${domainCount} ${domainCount === 1 ? "Domain" : "Domains"} registriert`}
/>
<Tile
href="/verwaltung/typen"
icon="qr"
title="QR-Typen"
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
title="Erscheinungsbild"
description="Name, Logo, Favicon und Farbe der Oberfläche."
hint={brandingHint}
/>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard";
import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" };
export const dynamic = "force-dynamic";
export default async function TypeSettingsPage() {
await requirePagePermission("instance:manage");
const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
}),
);
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
QR-Typen
</h1>
<p className="mt-2 text-slate-600">
Lege fest, welche Typen auf der Startseite erscheinen. Abgeschaltete
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen.
</p>
<p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
wird durch das Abschalten seines Typs also nicht ungültig.
</p>
</div>
<TypeSettings initial={initial} />
</div>
);
}
+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);
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { DEFAULT_DOMAIN_ID } from "@/lib/domains";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
label: z.string().trim().max(80).nullable().optional(),
isActive: z.boolean().optional(),
/**
* Vollständige Liste der Gruppen, die diese Domain nutzen dürfen. Wer
* fehlt, verliert die Zuteilung.
*/
grants: z
.array(
z.object({
groupId: z.string().trim().min(1),
isDefault: z.boolean().default(false),
}),
)
.optional(),
});
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
const domain = await prisma.domain.findUnique({ where: { id } });
if (!domain) {
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
}
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (parsed.data.label !== undefined || parsed.data.isActive !== undefined) {
await prisma.domain.update({
where: { id },
data: {
...(parsed.data.label !== undefined
? { label: parsed.data.label || null }
: {}),
...(parsed.data.isActive !== undefined
? { isActive: parsed.data.isActive }
: {}),
},
});
}
if (parsed.data.grants !== undefined) {
const grants = parsed.data.grants;
const known = await prisma.group.findMany({
where: { id: { in: grants.map((g) => g.groupId) } },
select: { id: true },
});
if (known.length !== grants.length) {
throw new QrRequestError(
"Mindestens eine der ausgewählten Gruppen existiert nicht mehr.",
);
}
// Eine Gruppe, die diese Domain noch für Codes verwendet, darf die
// Zuteilung nicht verlieren — sonst zeigten deren Kurz-URLs auf eine
// Domain, die der Gruppe gar nicht mehr gehört.
const stillInUse = await prisma.qrCode.findMany({
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
select: { group: { select: { name: true } } },
distinct: ["groupId"],
});
if (stillInUse.length > 0) {
const names = stillInUse.map((entry) => entry.group.name).join(", ");
throw new QrRequestError(
`Diese Gruppen haben noch Codes unter dieser Domain: ${names}. Solange die Codes bestehen, muss die Zuteilung bleiben.`,
);
}
await prisma.$transaction([
prisma.domainGrant.deleteMany({
where: { domainId: id, groupId: { notIn: grants.map((g) => g.groupId) } },
}),
...grants.map((grant) =>
prisma.domainGrant.upsert({
where: {
domainId_groupId: { domainId: id, groupId: grant.groupId },
},
create: {
domainId: id,
groupId: grant.groupId,
isDefault: grant.isDefault,
},
update: { isDefault: grant.isDefault },
}),
),
// Je Gruppe darf nur eine Domain die Voreinstellung sein.
...grants
.filter((grant) => grant.isDefault)
.map((grant) =>
prisma.domainGrant.updateMany({
where: { groupId: grant.groupId, domainId: { not: id } },
data: { isDefault: false },
}),
),
]);
}
return Response.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
if (id === DEFAULT_DOMAIN_ID) {
throw new QrRequestError(
"Die Standard-Domain lässt sich nicht löschen — sie fängt Aufrufe über nicht registrierte Adressen ab.",
);
}
const domain = await prisma.domain.findUnique({
where: { id },
include: { _count: { select: { codes: true } } },
});
if (!domain) {
throw new QrRequestError("Diese Domain existiert nicht (mehr).", 404);
}
if (domain._count.codes > 0) {
throw new QrRequestError(
`Unter dieser Domain liegen noch ${domain._count.codes} ${domain._count.codes === 1 ? "Code" : "Codes"}. Sie zu löschen würde gedruckte Codes ins Leere laufen lassen — schalte sie stattdessen ab, wenn sie nicht mehr genutzt werden soll.`,
);
}
await prisma.domain.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, normalizeHostname } from "@/lib/domains";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/**
* Ein Hostname, optional mit Port. Bewusst ohne Schema und ohne Pfad — das
* ist der Wert, den ein Browser im `Host`-Header sendet.
*/
const hostnameSchema = z
.string()
.trim()
.min(1, { error: "Bitte gib einen Hostnamen ein." })
.max(253, { error: "Der Hostname ist zu lang." })
.transform(normalizeHostname)
.refine((value) => /^[a-z0-9.-]+(:\d{1,5})?$/.test(value), {
error:
"Das ist kein gültiger Hostname. Erwartet wird z. B. qr.example.com — ohne https:// und ohne Pfad.",
})
.refine((value) => value.includes(".") || value.startsWith("localhost"), {
error: "Bitte gib den vollständigen Hostnamen an, z. B. qr.example.com",
});
const createSchema = z.object({
hostname: hostnameSchema,
label: z.string().trim().max(80).optional(),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
// Sorgt dafür, dass die Standard-Domain den Hostnamen aus BASE_URL trägt.
await ensureDefaultDomain();
const domains = await prisma.domain.findMany({
orderBy: { hostname: "asc" },
include: {
grants: { include: { group: { select: { id: true, name: true } } } },
_count: { select: { codes: true } },
},
});
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return Response.json({
domains: domains.map((domain) => ({
id: domain.id,
hostname: domain.hostname,
label: domain.label,
isActive: domain.isActive,
codeCount: domain._count.codes,
grants: domain.grants.map((grant) => ({
groupId: grant.group.id,
groupName: grant.group.name,
isDefault: grant.isDefault,
})),
})),
groups,
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.domain.findUnique({
where: { hostname: parsed.data.hostname },
select: { id: true },
});
if (existing) {
throw new QrRequestError("Diese Domain ist bereits registriert.", 409);
}
const domain = await prisma.domain.create({
data: {
hostname: parsed.data.hostname,
label: parsed.data.label || null,
},
});
return Response.json(
{ domain: { id: domain.id, hostname: domain.hostname } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
description: z.string().trim().max(300).nullable().optional(),
/**
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
* so bleibt der Zustand nach dem Speichern eindeutig.
*/
members: z
.array(
z.object({
userId: z.string().trim().min(1),
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
error: "Ungültige Rolle.",
}),
}),
)
.optional(),
});
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
const { id } = await context.params;
const group = await prisma.group.findUnique({ where: { id } });
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
// darf auch ein Administrator der Gruppe selbst pflegen.
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
requirePermission(actor, "instance:manage");
}
if (parsed.data.members !== undefined) {
requirePermission(actor, "group:manage", { groupId: id });
}
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
await prisma.group.update({
where: { id },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.description !== undefined
? { description: parsed.data.description || null }
: {}),
},
});
}
if (parsed.data.members !== undefined) {
const members = parsed.data.members;
const known = await prisma.user.findMany({
where: { id: { in: members.map((m) => m.userId) } },
select: { id: true },
});
if (known.length !== members.length) {
throw new QrRequestError(
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
);
}
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
// weil das Setzen der neuen Liste fehlgeschlagen ist.
await prisma.$transaction([
prisma.membership.deleteMany({
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
}),
...members.map((member) =>
prisma.membership.upsert({
where: {
userId_groupId: { userId: member.userId, groupId: id },
},
create: { userId: member.userId, groupId: id, role: member.role },
update: { role: member.role },
}),
),
]);
}
return Response.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
if (id === DEFAULT_GROUP_ID) {
throw new QrRequestError(
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
);
}
const group = await prisma.group.findUnique({
where: { id },
include: { _count: { select: { codes: true } } },
});
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
if (group._count.codes > 0) {
throw new QrRequestError(
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
);
}
await prisma.group.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const createSchema = z.object({
name: z
.string()
.trim()
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
description: z.string().trim().max(300).optional(),
});
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const groups = await prisma.group.findMany({
where: visible === null ? undefined : { id: { in: visible } },
orderBy: { name: "asc" },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true } } },
orderBy: { createdAt: "asc" },
},
_count: { select: { codes: true } },
},
});
return Response.json({
groups: groups.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
codeCount: group._count.codes,
canManage: can(actor, "group:manage", { groupId: group.id }),
members: group.memberships.map((m) => ({
userId: m.user.id,
email: m.user.email,
name: m.user.name,
role: m.role,
})),
})),
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.group.findUnique({
where: { name: parsed.data.name },
select: { id: true },
});
if (existing) {
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
}
const group = await prisma.group.create({
data: {
name: parsed.data.name,
description: parsed.data.description || null,
},
});
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
+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);
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import {
getQrTypeAvailability,
setQrTypeEnabled,
} from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
});
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
usable,
})),
});
} catch (error) {
return errorResponse(error);
}
}
/** Einen Typ freigeben oder abschalten. */
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
} catch (error) {
return errorResponse(error);
}
}
+175
View File
@@ -0,0 +1,175 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { destroyAllSessions } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().max(120).nullable().optional(),
isActive: z.boolean().optional(),
isInstanceAdmin: z.boolean().optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
})
.optional(),
/** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(),
});
/**
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
* mindestens ein aktiver Administrator übrig bleiben.
*/
async function assertNotLastAdmin(userId: string, action: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isInstanceAdmin: true, isActive: true },
});
if (!user?.isInstanceAdmin || !user.isActive) return;
const others = await prisma.user.count({
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
});
if (others === 0) {
throw new QrRequestError(
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
);
}
}
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
const data: {
name?: string | null;
isActive?: boolean;
isInstanceAdmin?: boolean;
passwordHash?: string;
failedLogins?: number;
lockedUntil?: Date | null;
} = {};
if (parsed.data.name !== undefined) {
data.name = parsed.data.name || null;
}
if (parsed.data.isActive === false) {
await assertNotLastAdmin(id, "das Deaktivieren");
data.isActive = false;
} else if (parsed.data.isActive === true) {
data.isActive = true;
}
if (parsed.data.isInstanceAdmin === false) {
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
data.isInstanceAdmin = false;
} else if (parsed.data.isInstanceAdmin === true) {
data.isInstanceAdmin = true;
}
if (parsed.data.password) {
data.passwordHash = await hashPassword(parsed.data.password);
data.failedLogins = 0;
data.lockedUntil = null;
}
if (parsed.data.unlock) {
data.failedLogins = 0;
data.lockedUntil = null;
}
if (Object.keys(data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.user.update({ where: { id }, data });
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
// sonst bliebe der Betroffene angemeldet.
if (data.isActive === false || data.passwordHash) {
await destroyAllSessions(id);
}
return Response.json({
user: {
id: updated.id,
email: updated.email,
isActive: updated.isActive,
isInstanceAdmin: updated.isInstanceAdmin,
},
});
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
if (actor.kind === "user" && actor.user.id === id) {
throw new QrRequestError(
"Du kannst dein eigenes Konto nicht löschen.",
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
await assertNotLastAdmin(id, "das Löschen");
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
// sie einmal angelegt hat.
await prisma.user.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+111
View File
@@ -0,0 +1,111 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const createSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
}),
name: z.string().trim().max(120).optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, { error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, })
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
isInstanceAdmin: z.boolean().default(false),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const users = await prisma.user.findMany({
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
include: {
memberships: { include: { group: { select: { id: true, name: true } } } },
_count: { select: { sessions: true } },
},
});
return Response.json({
users: users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
isInstanceAdmin: user.isInstanceAdmin,
isActive: user.isActive,
isLocked: Boolean(
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
activeSessions: user._count.sessions,
memberships: user.memberships.map((m) => ({
groupId: m.group.id,
groupName: m.group.name,
role: m.role,
})),
})),
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.user.findUnique({
where: { email: parsed.data.email },
select: { id: true },
});
if (existing) {
throw new QrRequestError(
"Diese E-Mail-Adresse ist bereits vergeben.",
409,
);
}
const user = await prisma.user.create({
data: {
email: parsed.data.email,
name: parsed.data.name || null,
passwordHash: await hashPassword(parsed.data.password),
isInstanceAdmin: parsed.data.isInstanceAdmin,
},
});
return Response.json(
{ user: { id: user.id, email: user.email } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
+226
View File
@@ -0,0 +1,226 @@
import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
exchangeCode,
getOidcConfig,
isOidcEnabled,
OIDC_FLOW_COOKIE,
type OidcConfig,
type OidcIdentity,
} from "@/lib/auth/oidc";
import { createSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { getBaseUrl } from "@/lib/env";
export const dynamic = "force-dynamic";
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
function zurueckMitFehler(meldung: string): Response {
const ziel = new URL("/anmelden", getBaseUrl());
ziel.searchParams.set("fehler", meldung);
return Response.redirect(ziel.toString(), 302);
}
type FlowState = { state: string; nonce: string; codeVerifier: string };
/**
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
* mehreren Bündeln und hätten dort verschiedene Identitäten.
*/
function istBenutzerfehler(error: unknown): error is Error {
return (
error instanceof Error &&
(error as Error & { userFacing?: unknown }).userFacing === true
);
}
function readFlow(raw: string | undefined): FlowState | null {
if (!raw) return null;
try {
const wert = JSON.parse(raw) as Partial<FlowState>;
if (
typeof wert.state === "string" &&
typeof wert.nonce === "string" &&
typeof wert.codeVerifier === "string"
) {
return wert as FlowState;
}
} catch {
// Unbrauchbarer Cookie — behandeln wie keiner.
}
return null;
}
/**
* Legt das Konto an oder findet es wieder.
*
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
*/
async function findeOderLegeAn(
identity: OidcIdentity,
config: OidcConfig,
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
const nachSubject = await prisma.user.findUnique({
where: { oidcSubject: identity.subject },
});
const istAdmin = config.adminGroup
? identity.groups.includes(config.adminGroup)
: undefined;
if (nachSubject) {
const aktualisiert = await prisma.user.update({
where: { id: nachSubject.id },
data: {
email: identity.email,
name: identity.name ?? nachSubject.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
}
const nachEmail = await prisma.user.findUnique({
where: { email: identity.email },
});
if (nachEmail) {
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
// übernehmen.
if (!identity.emailVerified) {
return {
fehler:
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
};
}
const verknuepft = await prisma.user.update({
where: { id: nachEmail.id },
data: {
oidcSubject: identity.subject,
name: identity.name ?? nachEmail.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
return { id: verknuepft.id, isActive: verknuepft.isActive };
}
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
const standardgruppe = await prisma.group.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
const istErster = (await prisma.user.count()) === 0;
const angelegt = await prisma.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
},
});
return { id: angelegt.id, isActive: angelegt.isActive };
}
export async function GET(request: NextRequest): Promise<Response> {
const store = await cookies();
try {
if (!isAuthEnabled() || !isOidcEnabled()) {
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
}
const params = request.nextUrl.searchParams;
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
const fehler = params.get("error");
if (fehler) {
const beschreibung = params.get("error_description");
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
return zurueckMitFehler(
fehler === "access_denied"
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
);
}
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
if (!flow) {
return zurueckMitFehler(
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
}
if (state !== flow.state) {
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
return zurueckMitFehler(
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
);
}
const config = getOidcConfig();
const identity = await exchangeCode({
config,
code,
codeVerifier: flow.codeVerifier,
nonce: flow.nonce,
});
const ergebnis = await findeOderLegeAn(identity, config);
if ("fehler" in ergebnis) {
return zurueckMitFehler(ergebnis.fehler);
}
if (!ergebnis.isActive) {
return zurueckMitFehler(
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
);
}
await createSession(ergebnis.id);
const ziel = new URL("/", getBaseUrl());
return Response.redirect(ziel.toString(), 302);
} catch (error) {
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
// alles andere könnte Interna verraten.
const meldung = istBenutzerfehler(error)
? error.message
: "Die Anmeldung ist fehlgeschlagen.";
return zurueckMitFehler(meldung);
} finally {
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
store.delete(OIDC_FLOW_COOKIE);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
assertHostMatchesBaseUrl,
discover,
getOidcConfig,
isOidcEnabled,
OIDC_FLOW_COOKIE,
OIDC_FLOW_MINUTES,
redirectUri,
} from "@/lib/auth/oidc";
import { errorResponse } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
function base64url(buffer: Buffer): string {
return buffer.toString("base64url");
}
/**
* Schickt den Browser zum Anbieter.
*
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
*/
export async function GET(request: NextRequest): Promise<Response> {
try {
if (!isAuthEnabled()) {
throw new QrRequestError(
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
400,
);
}
if (!isOidcEnabled()) {
throw new QrRequestError(
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
400,
);
}
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
assertHostMatchesBaseUrl(request.headers);
const config = getOidcConfig();
const meta = await discover(config.issuer);
const state = base64url(randomBytes(32));
const nonce = base64url(randomBytes(32));
const codeVerifier = base64url(randomBytes(32));
const codeChallenge = base64url(
createHash("sha256").update(codeVerifier).digest(),
);
const store = await cookies();
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
// Cookie mitgeschickt.
sameSite: "lax",
path: "/",
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
maxAge: OIDC_FLOW_MINUTES * 60,
});
const ziel = new URL(meta.authorization_endpoint);
ziel.searchParams.set("response_type", "code");
ziel.searchParams.set("client_id", config.clientId);
ziel.searchParams.set("redirect_uri", redirectUri());
ziel.searchParams.set("scope", config.scopes);
ziel.searchParams.set("state", state);
ziel.searchParams.set("nonce", nonce);
ziel.searchParams.set("code_challenge", codeChallenge);
ziel.searchParams.set("code_challenge_method", "S256");
return Response.redirect(ziel.toString(), 302);
} catch (error) {
return errorResponse(error);
}
}
+339
View File
@@ -0,0 +1,339 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
needsRehash,
verifyPassword,
} from "@/lib/auth/password";
import {
createSession,
destroyAllSessions,
destroySession,
} from "@/lib/auth/session";
import {
clearAttempts,
requestOrigin,
tooManyAttempts,
} from "@/lib/auth/throttle";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** Sperrdauer nach zu vielen Fehlversuchen. */
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
/**
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
* Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen
* denen echter Hashes — sonst wäre die Rechnung zu schnell.
*/
const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA";
const emailSchema = z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
});
const loginSchema = z.object({
action: z.literal("login"),
email: emailSchema,
password: z
.string()
.min(1, { error: "Bitte gib dein Passwort ein." })
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
});
const setupSchema = z.object({
action: z.literal("setup"),
email: emailSchema,
name: z.string().trim().max(120).optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
});
const logoutSchema = z.object({ action: z.literal("logout") });
const changePasswordSchema = z.object({
action: z.literal("change-password"),
currentPassword: z
.string()
.min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
newPassword: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
});
const bodySchema = z.discriminatedUnion("action", [
loginSchema,
setupSchema,
logoutSchema,
changePasswordSchema,
]);
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
return Response.json({
authEnabled: isAuthEnabled(),
setupOpen: await isSetupOpen(),
user:
actor.kind === "user"
? {
id: actor.user.id,
email: actor.user.email,
name: actor.user.name,
isInstanceAdmin: actor.user.isInstanceAdmin,
memberships: actor.user.memberships,
}
: null,
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (parsed.data.action === "logout") {
await destroySession();
return Response.json({ ok: true });
}
if (!isAuthEnabled()) {
throw new QrRequestError(
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
);
}
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
// catch an und würde als nackter 500er beim Aufrufer landen.
if (parsed.data.action === "change-password") {
return await handleChangePassword(parsed.data);
}
if (parsed.data.action === "setup") {
return await handleSetup(parsed.data);
}
return await handleLogin(parsed.data, requestOrigin(request.headers));
} catch (error) {
return errorResponse(error);
}
}
/**
* Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand
* an einem unbeaufsichtigten Rechner das Konto übernehmen.
*/
async function handleChangePassword(
input: z.infer<typeof changePasswordSchema>,
): Promise<Response> {
const actor = await getActor();
if (actor.kind !== "user") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const user = await prisma.user.findUnique({ where: { id: actor.user.id } });
if (!user) {
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
}
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
// wird beim Anbieter gewechselt, nicht hier.
if (!user.passwordHash) {
throw new QrRequestError(
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
400,
);
}
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
}
if (input.currentPassword === input.newPassword) {
throw new QrRequestError(
"Das neue Passwort ist mit dem bisherigen identisch.",
);
}
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await hashPassword(input.newPassword),
failedLogins: 0,
lockedUntil: null,
},
});
// Alle Sitzungen beenden — auch die eigene — und sofort eine neue
// aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu
// anmelden muss.
await destroyAllSessions(user.id);
await createSession(user.id);
return Response.json({ ok: true });
}
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
async function handleSetup(
input: z.infer<typeof setupSchema>,
): Promise<Response> {
if (!(await isSetupOpen())) {
throw new QrRequestError(
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
403,
);
}
// Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
const passwordHash = await hashPassword(input.password);
// Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
// dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
// gäbe es zwei Administratoren, von denen einer ungebeten ist.
const user = await prisma.$transaction(
async (tx) => {
if ((await tx.user.count()) > 0) {
throw new QrRequestError(
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
403,
);
}
return tx.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash,
isInstanceAdmin: true,
},
});
},
{ isolationLevel: "Serializable" },
);
await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 });
}
async function handleLogin(
input: z.infer<typeof loginSchema>,
absender: string,
): Promise<Response> {
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
const wartezeit = tooManyAttempts(`login:${absender}`);
if (wartezeit !== null) {
throw new QrRequestError(
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
429,
);
}
const user = await prisma.user.findUnique({ where: { email: input.email } });
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
// damit herausfinden, welche Konten es gibt.
const invalid = new QrRequestError(
"E-Mail-Adresse oder Passwort stimmt nicht.",
401,
);
if (!user) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await verifyPassword(input.password, DUMMY_HASH);
throw invalid;
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
);
throw new QrRequestError(
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
429,
);
}
// Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe
// Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten,
// dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben.
if (!user.passwordHash) {
await verifyPassword(input.password, DUMMY_HASH);
throw invalid;
}
if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLogins,
lockedUntil:
failedLogins >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
: null,
},
});
throw invalid;
}
// Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto
// nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht.
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
// Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern.
// Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt.
const passwordHash = needsRehash(user.passwordHash)
? await hashPassword(input.password)
: undefined;
await prisma.user.update({
where: { id: user.id },
data: {
failedLogins: 0,
lockedUntil: null,
lastLoginAt: new Date(),
...(passwordHash ? { passwordHash } : {}),
},
});
clearAttempts(`login:${absender}`);
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+17 -2
View File
@@ -1,7 +1,10 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
@@ -27,11 +30,18 @@ export async function GET(
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
const isDownload = request.nextUrl.searchParams.get("download") === "1";
const code = await prisma.qrCode.findUnique({ where: { id } });
const code = await prisma.qrCode.findUnique({
where: { id },
include: { domain: { select: { hostname: true } } },
});
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
// Das Bild verrät die Kurz-URL und damit das Ziel — es gehört hinter
// dieselbe Prüfung wie die Liste.
requirePermission(await getActor(), "codes:read", { groupId: code.groupId });
const design = designSchema.parse({
fgColor: code.fgColor,
bgColor: code.bgColor,
@@ -40,7 +50,12 @@ export async function GET(
margin: code.margin,
});
const content = shortUrl(code.slug);
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
const content =
code.mode === "static" || !code.slug
? requireImplementedQrType(code.type).encode(code.data)
: shortUrl(code.slug, code.domain.hostname);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
+155 -13
View File
@@ -2,17 +2,31 @@ import type { NextRequest } from "next/server";
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } 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 { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const WITH_RELATIONS = {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const;
const updateSchema = z.object({
data: z.record(z.string(), z.unknown()).optional(),
design: designSchema.optional(),
@@ -22,10 +36,24 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable()
.optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
});
async function loadCode(id: string) {
const code = await prisma.qrCode.findUnique({ where: { id } });
const code = await prisma.qrCode.findUnique({
where: { id },
include: WITH_RELATIONS,
});
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
@@ -38,7 +66,10 @@ export async function GET(
): Promise<Response> {
try {
const { id } = await context.params;
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
const actor = await getActor();
const code = await loadCode(id);
requirePermission(actor, "codes:read", { groupId: code.groupId });
return Response.json({ code: toQrCodeDto(code) });
} catch (error) {
return errorResponse(error);
}
@@ -51,26 +82,44 @@ export async function PATCH(
): Promise<Response> {
try {
const { id } = await context.params;
const actor = await getActor();
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await loadCode(id);
const update: Prisma.QrCodeUpdateInput = {};
requirePermission(actor, "codes:update", { groupId: existing.groupId });
if (parsed.data.data !== undefined) {
const qrType = getActiveQrType(existing.type);
if (!qrType) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
const update: Prisma.QrCodeUpdateInput = {};
/** Dateien, die nach der Änderung zu diesem Code gehören. */
let neueDateien: string[] | null = null;
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
if (parsed.data.data !== undefined && existing.mode === "static") {
return jsonError(
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
);
}
if (parsed.data.data !== undefined) {
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
// unbearbeitbar machen — sie sind längst gedruckt.
const qrType = requireImplementedQrType(existing.type);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
// Auch beim Bearbeiten: die neuen Daten müssen ein Weiterleitungsziel
// hergeben. Sonst könnte man einen laufenden Code durch eine Änderung
// stillschweigend unbrauchbar machen.
assertDynamicPossible(qrType, validated.data);
update.data = toJsonInput(validated.data);
neueDateien = qrType.uploadIds(validated.data);
}
if (parsed.data.label !== undefined) {
@@ -84,11 +133,101 @@ export async function PATCH(
Object.assign(update, parsed.data.design);
}
if (Object.keys(update).length === 0) {
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
// selbst. Ein Umbenennen waere gegenstandslos.
if (existing.mode === "static" && neuerSlug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
const umbenannt =
neuerSlug !== undefined &&
existing.slug !== null &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({ where: { id }, data: update });
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
// ist, oder eine freigegebene Adresse bliebe hängen.
const updated = await prisma.$transaction(async (tx) => {
if (freigeben.length > 0) {
await tx.qrCodeAlias.deleteMany({
where: { id: { in: freigeben.map((a) => a.id) } },
});
}
if (umbenannt) {
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
// Codes. Freigeben lässt er sich danach einzeln.
await tx.qrCodeAlias.create({
data: {
domainId: existing.domainId,
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
slug: existing.slug as string,
qrCodeId: existing.id,
},
});
}
return tx.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
});
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt.
if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien);
}
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
@@ -101,7 +240,10 @@ export async function DELETE(
): Promise<Response> {
try {
const { id } = await context.params;
await loadCode(id);
const actor = await getActor();
const existing = await loadCode(id);
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
await prisma.qrCode.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
+135 -19
View File
@@ -1,14 +1,21 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { resolveTargetDomain } from "@/lib/domains";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } from "@/lib/qr/request";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic";
@@ -21,6 +28,21 @@ const createSchema = z.object({
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(),
/**
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
*/
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
.default("dynamic"),
/**
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
*/
slug: z.string().trim().max(120).optional(),
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */
domainId: z.string().trim().min(1).optional(),
});
function isUniqueViolation(error: unknown): boolean {
@@ -32,10 +54,26 @@ function isUniqueViolation(error: unknown): boolean {
);
}
/** Alle dynamischen Codes, neueste zuerst. */
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
export async function GET(): Promise<Response> {
try {
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const codes = await prisma.qrCode.findMany({
where: visible === null ? undefined : { groupId: { in: visible } },
orderBy: { createdAt: "desc" },
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
return Response.json({ codes: codes.map(toQrCodeDto) });
} catch (error) {
return errorResponse(error);
@@ -45,44 +83,122 @@ export async function GET(): Promise<Response> {
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const actor = await getActor();
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { type, design, label } = parsed.data;
const qrType = getActiveQrType(type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
requirePermission(actor, "codes:create", { groupId });
// Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar
// ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
const qrType = await requireAvailableQrType(type);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
const istStatisch = parsed.data.mode === "static";
if (istStatisch) {
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
// können, sonst gäbe es nichts zu kodieren.
if (!qrType.supportsStatic) {
return jsonError(
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
);
}
if (parsed.data.slug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
} else {
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
}
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
const gewuenscht = parsed.data.slug?.trim();
if (gewuenscht) {
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
const problem = describeSlugProblem(gewuenscht);
if (problem) return jsonError(problem);
await assertSlugAvailable(domainId, gewuenscht);
}
if (istStatisch) {
const created = await prisma.qrCode.create({
data: {
mode: "static",
slug: null,
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
domainId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
await attachUploads(created.id, qrType.uploadIds(validated.data));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const created = await prisma.qrCode.create({
data: {
slug: generateSlug(),
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
domainId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} 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;
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
export const dynamic = "force-dynamic";
const feedbackSchema = z.object({
codeId: z.string().trim().min(1),
rating: z
.number()
.int()
.min(1, { error: "Bitte wähle eine Bewertung." })
.max(5, { error: "Bitte wähle eine Bewertung." }),
comment: z
.string()
.trim()
.max(1000, { error: "Der Text ist zu lang (höchstens 1000 Zeichen)." })
.default(""),
});
/**
* Grobe Bremse gegen Fluten.
*
* Im Arbeitsspeicher und damit pro Container — bei einem Dienst dieser Größe
* genügt das. Die Adresse wird nur als Schlüssel benutzt und nirgends
* gespeichert; nach dem Zeitfenster ist sie wieder weg.
*/
const FENSTER_MS = 60_000;
const MAX_PRO_FENSTER = 10;
const zaehler = new Map<string, { anzahl: number; bis: number }>();
function zuSchnell(schluessel: string): boolean {
const jetzt = Date.now();
const eintrag = zaehler.get(schluessel);
if (!eintrag || eintrag.bis < jetzt) {
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
// Gelegentlich ausmisten, damit die Tabelle nicht wächst.
if (zaehler.size > 5000) {
for (const [key, wert] of zaehler) {
if (wert.bis < jetzt) zaehler.delete(key);
}
}
return false;
}
eintrag.anzahl += 1;
return eintrag.anzahl > MAX_PRO_FENSTER;
}
/** Nimmt eine Rückmeldung von einer öffentlichen Landingpage entgegen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = feedbackSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const herkunft =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unbekannt";
if (zuSchnell(herkunft)) {
return jsonError(
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
429,
);
}
// Nur Codes, die tatsächlich Feedback einsammeln — sonst ließen sich
// beliebige Codes mit Einträgen zumüllen.
const code = await prisma.qrCode.findUnique({
where: { id: parsed.data.codeId },
select: { id: true, type: true },
});
if (!code || code.type !== "feedback") {
return jsonError("Zu diesem Code lässt sich keine Rückmeldung abgeben.", 404);
}
await prisma.feedback.create({
data: {
qrCodeId: code.id,
rating: parsed.data.rating,
comment: parsed.data.comment.length > 0 ? parsed.data.comment : null,
},
});
return Response.json({ ok: true }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
+7 -2
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
@@ -17,6 +18,10 @@ const downloadSchema = qrRequestSchema.extend({
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = downloadSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
@@ -31,7 +36,7 @@ export async function POST(request: NextRequest): Promise<Response> {
);
}
const { qrType, data, content } = resolveQrRequest(parsed.data);
const { qrType, data, content } = await resolveQrRequest(parsed.data);
const body =
format === "png"
@@ -41,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
return new Response(body, {
headers: {
"Content-Type": CONTENT_TYPES[format],
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
"Cache-Control": "no-store",
},
});
+6 -1
View File
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -10,12 +11,16 @@ import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = qrRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { content } = resolveQrRequest(parsed.data);
const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design);
return Response.json({
+50
View File
@@ -0,0 +1,50 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Liefert eine hochgeladene Datei aus.
*
* Bewusst ohne Anmeldung: Die Landingpages sind öffentlich, und wer einen
* Code scannt, soll das hinterlegte PDF sehen können. Der Schutz liegt in der
* nicht erratbaren Id — dieselbe Überlegung wie beim Slug einer Kurz-URL.
*/
export async function GET(
request: NextRequest,
context: Context,
): Promise<Response> {
const { id } = await context.params;
const upload = await prisma.upload.findUnique({ where: { id } });
if (!upload) {
return new Response("Datei nicht gefunden", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
// `?download` erzwingt das Speichern statt der Anzeige.
const alsDownload = request.nextUrl.searchParams.has("download");
return new Response(new Uint8Array(upload.data), {
headers: {
"Content-Type": upload.mimeType,
"Content-Length": String(upload.size),
// Der Typ steht fest — der Browser soll nicht raten und daraus
// womöglich etwas Ausführbares machen.
"X-Content-Type-Options": "nosniff",
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
"Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+76
View File
@@ -0,0 +1,76 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
import {
cleanupOrphanedUploads,
isUploadKind,
maxUploadBytes,
storeUpload,
uploadPurposeOf,
} from "@/lib/uploads";
export const dynamic = "force-dynamic";
/**
* Nimmt eine Datei entgegen und gibt ihre Id zurück.
*
* Der Upload passiert vor dem Speichern des Codes — anders geht es nicht, das
* Formular braucht die Id, um sie im Code zu hinterlegen. Dateien ohne Code
* bleiben deshalb erst einmal liegen und werden später weggeräumt.
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
// Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
// die Herkunft deshalb selbst.
assertSameOrigin(request.headers);
const actor = await getActor();
let form: FormData;
try {
form = await request.formData();
} catch {
return jsonError("Die Datei konnte nicht gelesen werden.");
}
const kind = form.get("kind");
if (!isUploadKind(kind)) {
return jsonError("Unbekannte Dateiart.");
}
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
if (uploadPurposeOf(kind) === "branding") {
requirePermission(actor, "instance:manage");
} else {
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
}
const file = form.get("file");
if (!(file instanceof File)) {
return jsonError("Es wurde keine Datei mitgeschickt.");
}
const upload = await storeUpload(file, kind);
// Beiläufig aufräumen. Scheitert das, ist der Upload trotzdem gültig.
try {
await cleanupOrphanedUploads();
} catch (error) {
console.error("Verwaiste Uploads konnten nicht entfernt werden:", error);
}
return Response.json({ upload }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
/** Damit die Oberfläche die Grenze nennen kann, bevor jemand wählt. */
export async function GET(): Promise<Response> {
return Response.json({ maxBytes: maxUploadBytes() });
}
-56
View File
@@ -1,56 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry";
type Props = { params: Promise<{ type: string }> };
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */
export function generateStaticParams() {
return QR_TYPE_IDS.map((type) => ({ type }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
const qrType = getQrType(type);
return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" };
}
export default async function CreatePage({ params }: Props) {
const { type } = await params;
const qrType = getQrType(type);
if (!qrType) {
notFound();
}
// Registrierte, aber noch nicht implementierte Typen bekommen eine
// erklärende Seite statt einer 404.
if (!getActiveQrType(type)) {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name={qrType.icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">
„{qrType.title}“ ist bald verfügbar
</h1>
<p className="mt-2 text-slate-600">
Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“
zur Verfügung.
</p>
<Link
href="/erstellen/website"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Website-QR-Code erstellen
</Link>
</div>
);
}
return <QrGenerator typeId={qrType.id} />;
}
+72 -1
View File
@@ -9,10 +9,81 @@
monospace;
}
/* ---------------------------------------------------------------- Dark Mode
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
entscheidet sich hier.
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
demselben leicht blauen Ton wie die helle Reihe.
*/
@theme {
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
--color-accent-text: light-dark(
var(--color-indigo-600),
var(--color-indigo-400)
);
--color-accent-soft: light-dark(
var(--color-indigo-50),
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
);
--color-accent-soft-fg: light-dark(
var(--color-indigo-700),
var(--color-indigo-300)
);
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
der Grund einer Videovorschau. */
--color-scrim: oklch(20.8% 0.042 265.755);
}
:root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
body {
background-color: var(--color-slate-50);
color: var(--color-slate-900);
@@ -22,7 +93,7 @@ body {
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
:focus-visible {
outline: 2px solid var(--color-indigo-600);
outline: 2px solid var(--color-accent-text);
outline-offset: 2px;
}
+57 -42
View File
@@ -1,57 +1,72 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
import { accentCss } from "@/lib/branding-colors";
import { getBranding } from "@/lib/branding";
import "./globals.css";
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: {
default: "QR-Code-Generator",
template: "%s · QR-Code-Generator",
default: branding.appName,
template: `%s · ${branding.appName}`,
},
description:
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
};
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
...(branding.faviconUploadId
? {
icons: {
icon: {
url: `/api/uploads/${branding.faviconUploadId}`,
type: branding.faviconMimeType ?? undefined,
},
},
}
: {}),
};
}
/**
* Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
*
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
* „Meine Codes“ nichts zu suchen.
*
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
*/
export default async function RootLayout({ children }: LayoutProps<"/">) {
const branding = await getBranding();
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
QR-Code-Generator
</Link>
<nav className="flex items-center gap-1 text-sm font-medium">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Erstellen
</Link>
<Link
href="/meine-codes"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Meine Codes
</Link>
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · Version 1 — QR-Typ „Website/URL“
</div>
</footer>
{/*
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>
);
-24
View File
@@ -1,24 +0,0 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
export const metadata: Metadata = { title: "Meine Codes" };
export default function MyCodesPage() {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Meine Codes
</h1>
<p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
gespeichert.
</p>
</div>
<CodesList />
</div>
);
}
-93
View File
@@ -1,93 +0,0 @@
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge } from "@/components/ui";
import { qrTypeList } from "@/lib/qr/registry";
export default function HomePage() {
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
Was möchtest du erstellen?
</h1>
<p className="mt-3 text-base text-slate-600">
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
später änderst, ohne den Code neu drucken zu müssen.
</p>
</div>
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{qrTypeList.map((type) => {
const tile = (
<>
<span
className={
type.active
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
}
>
<Icon name={type.icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span
className={
type.active
? "font-semibold text-slate-900"
: "font-semibold text-slate-500"
}
>
{type.title}
</span>
{type.active ? (
<Badge tone="popular">Beliebt</Badge>
) : (
<Badge tone="soon">Bald verfügbar</Badge>
)}
</span>
<span
className={
type.active
? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400"
}
>
{type.description}
</span>
</span>
</>
);
return (
<li key={type.id}>
{type.active ? (
<Link
href={`/erstellen/${type.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
{tile}
</Link>
) : (
<div
aria-disabled="true"
title={`„${type.title}“ folgt in Version 2`}
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
>
{tile}
</div>
)}
</li>
);
})}
</ul>
<p className="mt-8 text-sm text-slate-500">
In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in
Version 2.
</p>
</div>
);
}
@@ -1,12 +1,16 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> };
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response {
return new Response(
@@ -34,18 +38,40 @@ function notFound(message: string): Response {
*
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
*
* Der Slug ist nur je Domain eindeutig, deshalb wird zuerst die Domain aus
* dem `Host`-Header bestimmt. Ein unbekannter Hostname endet hier — sonst
* ließe sich über einen gefälschten Header ein fremder Code ansprechen.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
context: Context,
): Promise<Response> {
const { slug } = await context.params;
const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig.");
}
const code = await prisma.qrCode.findUnique({ where: { slug } });
const matched = await resolveRequestDomain(request.headers);
// Aufruf über eine nicht registrierte Adresse — etwa direkt über die IP im
// LAN. Dann gilt die Standard-Domain aus BASE_URL.
const domain = matched ?? {
...(await ensureDefaultDomain()),
isActive: true,
};
if (!domain.isActive) {
return notFound(
"Diese Adresse ist zurzeit abgeschaltet. Bitte wende dich an denjenigen, der den Code erstellt hat.",
);
}
const gefunden = await findCodeBySlug(domain.id, slug);
const code = gefunden?.code;
if (!code) {
return notFound(
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
@@ -58,11 +84,22 @@ export async function GET(
}
let target: string;
if (qrType.hasLandingPage) {
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${code.slug}`;
} else {
try {
target = qrType.redirectTarget(code.data);
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
// wohin die Weiterleitung führt.
target = qrType.redirectTarget(code.data, {
userAgent: request.headers.get("user-agent") ?? undefined,
});
} catch {
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
}
}
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
try {
+113
View File
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/**
* Öffentliche Landingpage eines dynamischen Codes.
*
* Hierher schickt `/r/[slug]` alle Typen, die keine fremde Adresse haben,
* sondern eine Seite: Linksammlungen, hinterlegte Dateien, Feedback.
*
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/
async function loadCode(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const code = await loadCode(slug);
const qrType = code ? getQrType(code.type) : undefined;
if (!code || !qrType?.hasLandingPage) {
return { title: "Nicht gefunden" };
}
let title = qrType.title;
try {
title = qrType.summary(code.data);
} catch {
// Daten passen nicht mehr zum Schema — der Typname tut es auch.
}
// Absolut, damit der Titel nicht mit dem Anwendungsnamen ergänzt wird:
// Wer hier landet, hat mit dem Generator nichts zu tun.
return { title: { absolute: title } };
}
export default async function LandingPage({ params }: PageProps) {
const { slug } = await params;
const code = await loadCode(slug);
if (!code) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an
denjenigen, der ihn erstellt hat.
</LandingNotice>
);
}
const qrType = getQrType(code.type);
const Landing = qrType?.hasLandingPage ? qrLandings[qrType.id] : undefined;
if (!qrType || !Landing) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
// Alte Datensätze können nach einer Schema-Änderung unbrauchbar sein. Das
// ist kein Grund, den Besuchern einen Absturz zu zeigen.
if (!qrType.validate(code.data).ok) {
return (
<LandingNotice title="Diese Seite ist gerade nicht verfügbar">
Die hinterlegten Inhalte sind unvollständig. Bitte wende dich an
denjenigen, der den Code erstellt hat.
</LandingNotice>
);
}
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
if (!code.slug) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
return (
<Landing
data={code.data}
label={code.label}
slug={code.slug}
codeId={code.id}
/>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Icon } from "@/components/icons";
import { PasswordInput } from "@/components/password-input";
import { Button, Card, CardTitle, Field, Notice } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
export function ChangePasswordForm({ minLength }: { minLength: number }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = repeat.length > 0 && next !== repeat;
const tooShort = next.length > 0 && next.length < minLength;
const submit = async () => {
setPending(true);
setError(null);
setDone(false);
try {
await apiJson("/api/auth", {
method: "POST",
json: {
action: "change-password",
currentPassword: current,
newPassword: next,
},
});
setCurrent("");
setNext("");
setRepeat("");
setDone(true);
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Das Passwort konnte nicht geändert werden.",
);
} finally {
setPending(false);
}
};
return (
<Card>
<CardTitle hint="Nach der Änderung wirst du auf allen anderen Geräten abgemeldet.">
Passwort ändern
</CardTitle>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (mismatch || tooShort) return;
void submit();
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
{done ? (
<Notice tone="success">
Das Passwort wurde geändert. Andere Geräte wurden abgemeldet.
</Notice>
) : null}
<Field label="Bisheriges Passwort" htmlFor="current-password">
<PasswordInput
id="current-password"
autoComplete="current-password"
required
value={current}
disabled={pending}
onChange={(event) => setCurrent(event.target.value)}
/>
</Field>
<Field
label="Neues Passwort"
htmlFor="next-password"
error={tooShort ? `Mindestens ${minLength} Zeichen.` : null}
hint={`Mindestens ${minLength} Zeichen.`}
>
<PasswordInput
id="next-password"
autoComplete="new-password"
required
invalid={tooShort}
value={next}
disabled={pending}
onChange={(event) => setNext(event.target.value)}
/>
</Field>
<Field
label="Neues Passwort wiederholen"
htmlFor="repeat-password"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<PasswordInput
id="repeat-password"
autoComplete="new-password"
required
invalid={mismatch}
value={repeat}
disabled={pending}
onChange={(event) => setRepeat(event.target.value)}
/>
</Field>
<div>
<Button type="submit" disabled={pending || mismatch || tooShort}>
{pending ? "Wird geändert …" : "Passwort ändern"}
</Button>
</div>
</form>
</Card>
);
}
export function SignOutCard() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<Card>
<CardTitle hint="Beendet die Sitzung auf diesem Gerät.">Abmelden</CardTitle>
<div>
<Button
type="button"
variant="secondary"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await apiJson("/api/auth", {
method: "POST",
json: { action: "logout" },
});
} finally {
// refresh() verwirft den serverseitig gerenderten Kopfbereich —
// sonst stünde dort noch der abgemeldete Name.
router.replace("/anmelden");
router.refresh();
}
}}
>
<Icon name="lock" className="size-4" />
{pending ? "Wird abgemeldet …" : "Jetzt abmelden"}
</Button>
</div>
</Card>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { PasswordInput } from "@/components/password-input";
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
function useSubmit() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (json: unknown) => {
setPending(true);
setError(null);
try {
await apiJson("/api/auth", { method: "POST", json });
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
router.replace("/");
router.refresh();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Anmeldung ist fehlgeschlagen.",
);
setPending(false);
}
};
return { submit, pending, error, router };
}
export function LoginForm() {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submit({ action: "login", email, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="login-email">
<TextInput
id="login-email"
type="email"
autoComplete="username"
autoFocus
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field label="Passwort" htmlFor="login-password">
<PasswordInput
id="login-password"
autoComplete="current-password"
required
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Wird angemeldet …" : "Anmelden"}
</Button>
</form>
</Card>
);
}
export function SetupForm({ minLength }: { minLength: number }) {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const mismatch = repeat.length > 0 && password !== repeat;
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (mismatch) return;
void submit({ action: "setup", email, name, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="Name (optional)" htmlFor="setup-name">
<TextInput
id="setup-name"
autoComplete="name"
value={name}
disabled={pending}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field label="E-Mail-Adresse" htmlFor="setup-email">
<TextInput
id="setup-email"
type="email"
autoComplete="username"
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field
label="Passwort"
htmlFor="setup-password"
hint={`Mindestens ${minLength} Zeichen.`}
>
<PasswordInput
id="setup-password"
autoComplete="new-password"
required
minLength={minLength}
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Field
label="Passwort wiederholen"
htmlFor="setup-repeat"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<PasswordInput
id="setup-repeat"
autoComplete="new-password"
required
invalid={mismatch}
value={repeat}
disabled={pending}
onChange={(event) => setRepeat(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending || mismatch}>
{pending ? "Wird angelegt …" : "Administrator anlegen"}
</Button>
</form>
</Card>
);
}
export function LogoutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<button
type="button"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await apiJson("/api/auth", {
method: "POST",
json: { action: "logout" },
});
} finally {
router.replace("/anmelden");
router.refresh();
}
}}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
>
{pending ? "…" : "Abmelden"}
</button>
);
}
+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>
);
}
+615 -17
View File
@@ -6,10 +6,20 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { qrForms } from "@/components/qr-forms";
import { Badge, Button, Card, Field, Notice, TextInput } from "@/components/ui";
import {
Badge,
Button,
Card,
Field,
Notice,
Select,
TextInput,
cx,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -17,16 +27,58 @@ const dateFormat = new Intl.DateTimeFormat("de-DE", {
timeStyle: "short",
});
const shortDateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
});
function formatDate(value: string | null): string {
if (!value) return "—";
return dateFormat.format(new Date(value));
}
export function CodesList() {
function formatShortDate(value: string | null): string {
if (!value) return "—";
return shortDateFormat.format(new Date(value));
}
type View = "list" | "cards";
type SortKey = "neueste" | "aelteste" | "name" | "scans";
const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst",
aelteste: "Älteste zuerst",
name: "Name (A–Z)",
scans: "Meiste Scans",
};
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
const VIEW_STORAGE_KEY = "qrgen.codes.view";
function readStoredView(): View {
if (typeof window === "undefined") return "list";
return window.localStorage.getItem(VIEW_STORAGE_KEY) === "cards"
? "cards"
: "list";
}
/** Name, unter dem ein Code in der Liste geführt wird. */
function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary;
}
export function CodesList({
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
maySetSlug = true,
}: {
maySetSlug?: boolean;
}) {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null);
@@ -38,6 +90,23 @@ export function CodesList() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [view, setView] = useState<View>("list");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setView(readStoredView());
}, []);
const changeView = (next: View) => {
setView(next);
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
};
const load = useCallback(async () => {
try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
@@ -64,9 +133,21 @@ export function CodesList() {
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
setEditSlug(code.slug ?? "");
setEditAliases(code.aliases);
setEditError(null);
};
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null &&
editing.slug !== null &&
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
const freigegeben = editing
? editing.aliases.filter((alias) => !editAliases.includes(alias))
: [];
const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -79,12 +160,29 @@ export function CodesList() {
setEditError(editValidation.message);
return;
}
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true);
setEditError(null);
try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null },
json: {
label: editLabel.trim() || null,
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
// gehören nicht in die Anfrage.
...(editing.mode === "static"
? {}
: {
data: editValidation.data,
// Nur mitschicken, wenn es bearbeitet werden durfte.
...(maySetSlug
? { slug: editSlug.trim(), aliases: editAliases }
: {}),
}),
},
});
setEditing(null);
await load();
@@ -119,6 +217,7 @@ export function CodesList() {
};
const copy = async (code: QrCodeDto) => {
if (!code.shortUrl) return;
try {
await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id);
@@ -128,12 +227,154 @@ export function CodesList() {
}
};
/* -------------------------------------------------- Filtern und Sortieren */
// Gruppen kommen aus den geladenen Codes — ein eigener Abruf wäre dafür
// unnötig, und Gruppen ohne Codes gehören hier ohnehin nicht in die Auswahl.
const groups = useMemo(() => {
const seen = new Map<string, string>();
for (const code of codes ?? []) {
if (code.groupName) seen.set(code.groupId, code.groupName);
}
return [...seen].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, "de"),
);
}, [codes]);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (!needle) return true;
return [
displayName(code),
code.target,
code.shortUrl,
code.slug,
qrTypes[code.type]?.title ?? code.type,
]
.join(" ")
.toLowerCase()
.includes(needle);
});
const sorted = [...filtered];
sorted.sort((a, b) => {
switch (sort) {
case "aelteste":
return a.createdAt.localeCompare(b.createdAt);
case "name":
return displayName(a).localeCompare(displayName(b), "de");
case "scans":
return b.scans - a.scans;
default:
return b.createdAt.localeCompare(a.createdAt);
}
});
return sorted;
}, [codes, query, sort, groupFilter]);
/* ---------------------------------------------------------------- View */
if (codes === null) {
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
}
/** Herunterladen und Löschen — in beiden Ansichten dieselben Ziele. */
const pngHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=png&download=1`;
const svgHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=svg&download=1`;
const toolbar = codes.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56">
<Icon
name="search"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400"
/>
<TextInput
type="search"
aria-label="Codes durchsuchen"
placeholder="Suchen nach Name, Ziel oder Kurz-URL …"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{groups.length > 1 ? (
<Select
aria-label="Nach Gruppe filtern"
className="!w-auto"
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
>
<option value="alle">Alle Gruppen</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
) : null}
<Select
aria-label="Sortierung"
className="!w-auto"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
</Select>
<div
role="group"
aria-label="Ansicht"
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
>
{(
[
["list", "viewList", "Als Liste anzeigen"],
["cards", "viewGrid", "Als Karten anzeigen"],
] as const
).map(([value, icon, label]) => (
<button
key={value}
type="button"
aria-pressed={view === value}
aria-label={label}
title={label}
data-view={value}
onClick={() => changeView(value)}
className={cx(
"rounded-md p-2 transition-colors",
view === value
? "bg-accent-soft text-accent-soft-fg"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)}
>
<Icon name={icon} className="size-4" />
</button>
))}
</div>
</div>
);
const resultCount =
codes.length > 0 ? (
<p className="text-sm text-slate-500 tabular-nums">
{visible.length === codes.length
? `${codes.length} ${codes.length === 1 ? "Code" : "Codes"}`
: `${visible.length} von ${codes.length} Codes`}
</p>
) : null;
return (
<div className="space-y-4">
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
@@ -159,11 +400,261 @@ export function CodesList() {
</Card>
) : null}
{codes.map((code) => (
{toolbar}
{resultCount}
{codes.length > 0 && visible.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "}
<button
type="button"
onClick={() => {
setQuery("");
setGroupFilter("alle");
}}
className="font-medium text-accent-text underline"
>
Filter zurücksetzen
</button>
</p>
</Card>
) : null}
{/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? (
<div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
{/* Feste Spaltenbreiten: so bekommt der Name den ganzen Rest und
kürzt selbst, statt die Tabelle breiter zu machen als die Seite. */}
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase">
<th scope="col" className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
Name
</th>
<th scope="col" className="hidden w-44 px-3 py-2.5 md:table-cell lg:w-56">
Kurz-URL
</th>
<th scope="col" className="hidden w-40 px-3 py-2.5 sm:table-cell lg:w-56">
Ziel
</th>
{groups.length > 1 ? (
<th scope="col" className="hidden w-32 px-3 py-2.5 lg:table-cell">
Gruppe
</th>
) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
<th
scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
>
Erstellt
</th>
<th scope="col" className="w-20 py-2.5 pr-4 pl-3 text-right sm:w-36">
<span className="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{visible.map((code) => (
<tr key={code.id} className="hover:bg-slate-50/70">
<td className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
<div className="flex items-center gap-2 sm:gap-3">
{/* Reines Bild zum Wiedererkennen — zum Herunterladen
stehen PNG und SVG am Zeilenende. */}
<span className="qr-canvas size-8 shrink-0 rounded-md border border-slate-200 p-0.5 sm:size-10">
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
<img
src={`/api/codes/${code.id}/qr`}
alt=""
loading="lazy"
className="h-full w-full object-contain"
/>
</span>
<div className="min-w-0 flex-1">
<div
data-code-name
className="truncate font-medium text-slate-900"
>
{displayName(code)}
</div>
{/* Solange die Kurz-URL keine eigene Spalte hat, steht
sie hier — sie ist wichtiger als der Typ. */}
<div className="truncate text-xs text-slate-500">
{/* Nur der Pfad: Der Hostname ist auf dem Handy
gleich für alle Zeilen, der Slug unterscheidet. */}
{code.shortUrl ? (
<span
className="font-mono md:hidden"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
</span>
) : (
<span className="md:hidden">
{qrTypes[code.type]?.title ?? code.type} · statisch
</span>
)}
<span className="hidden md:inline">
{qrTypes[code.type]?.title ?? code.type}
</span>
</div>
</div>
</div>
</td>
<td className="hidden px-3 py-2.5 md:table-cell">
{code.shortUrl ? (
<div className="flex items-center gap-1.5">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-xs text-accent-text hover:underline"
title={code.shortUrl}
>
{code.shortUrl.replace(/^https?:\/\//, "")}
</a>
<button
type="button"
onClick={() => void copy(code)}
title="Kurz-URL kopieren"
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<Icon
name={copiedId === code.id ? "check" : "copy"}
className="size-3.5"
/>
</button>
</div>
) : (
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
// steckt in ihm selbst.
<Badge tone="neutral">statisch</Badge>
)}
</td>
<td className="hidden px-3 py-2.5 sm:table-cell">
{code.target ? (
<a
href={code.target}
target="_blank"
rel="noreferrer"
title={code.target}
className="block truncate text-slate-700 hover:underline"
>
{code.target.replace(/^https?:\/\//, "")}
</a>
) : (
<span
className={cx(
"block truncate",
code.mode === "static" ? "text-slate-600" : "text-red-600",
)}
title={code.summary}
>
{code.summary}
</span>
)}
</td>
{groups.length > 1 ? (
<td className="hidden px-3 py-2.5 text-slate-600 lg:table-cell">
<span className="block truncate" title={code.groupName ?? undefined}>
{code.groupName ?? "—"}
</span>
</td>
) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.mode === "static" ? (
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
—
</span>
) : (
code.scans
)}
</td>
<td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
title={`Erstellt: ${formatDate(code.createdAt)}\nLetzter Scan: ${formatDate(code.lastScanAt)}`}
>
{formatShortDate(code.createdAt)}
</td>
<td className="py-2.5 pr-4 pl-3">
<div className="flex items-center justify-end gap-0.5">
{/* Ein Feedback-Code ohne Weg zu den Antworten waere
nur die halbe Sache. */}
{code.type === "feedback" ? (
<Link
href={`/meine-codes/${code.id}/feedback`}
title="Rückmeldungen ansehen"
aria-label={`Rückmeldungen zu ${displayName(code)} ansehen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="star" className="size-4" />
</Link>
) : null}
<button
type="button"
onClick={() => openEdit(code)}
title="Bearbeiten"
aria-label={`${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="pencil" className="size-4" />
</button>
{/* Auf dem Handy fehlt der Platz; dort führt der Weg zum
Download über die Kartenansicht. */}
<a
href={pngHref(code)}
title="Als PNG herunterladen"
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
>
PNG
</a>
<a
href={svgHref(code)}
title="Als SVG herunterladen"
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
>
SVG
</a>
<button
type="button"
onClick={() => {
setDeleting(code);
setDeleteError(null);
}}
title="Löschen"
aria-label={`${displayName(code)} löschen`}
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{/* ------------------------------------------------------ Kartenansicht */}
{view === "cards" &&
visible.map((code) => (
<Card key={code.id}>
<div className="flex flex-col gap-5 sm:flex-row">
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
href={pngHref(code)}
title="Als PNG herunterladen"
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
>
@@ -171,6 +662,7 @@ export function CodesList() {
<img
src={`/api/codes/${code.id}/qr`}
alt={`QR-Code für ${code.summary}`}
loading="lazy"
className="h-full w-full object-contain"
/>
</a>
@@ -184,6 +676,7 @@ export function CodesList() {
</div>
<dl className="mt-3 space-y-2 text-sm">
{code.shortUrl ? (
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Kurz-URL:</dt>
<dd className="flex items-center gap-2">
@@ -191,7 +684,7 @@ export function CodesList() {
href={code.shortUrl}
target="_blank"
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}
</a>
@@ -208,6 +701,16 @@ export function CodesList() {
</button>
</dd>
</div>
) : (
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Art:</dt>
<dd className="text-slate-800">
statisch — Inhalt steckt im Code, nicht änderbar
</dd>
</div>
)}
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Ziel:</dt>
@@ -241,6 +744,15 @@ export function CodesList() {
</div>
<div className="mt-4 flex flex-wrap gap-2">
{code.type === "feedback" ? (
<Link
href={`/meine-codes/${code.id}/feedback`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="star" className="size-3.5" />
Rückmeldungen
</Link>
) : null}
<Button
type="button"
variant="secondary"
@@ -248,18 +760,18 @@ export function CodesList() {
onClick={() => openEdit(code)}
>
<Icon name="pencil" className="size-3.5" />
Ziel bearbeiten
Bearbeiten
</Button>
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
PNG
</a>
<a
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
SVG
@@ -286,7 +798,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */}
<Modal
open={editing !== null}
title="Ziel bearbeiten"
title="Code bearbeiten"
onClose={() => setEditing(null)}
footer={
<>
@@ -307,15 +819,12 @@ export function CodesList() {
{editing ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
auf{" "}
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
{editing.mode === "static"
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
</p>
{EditForm ? (
{editing.mode === "static" ? null : EditForm ? (
<EditForm
value={editData}
onChange={(value) => {
@@ -331,6 +840,95 @@ export function CodesList() {
</Notice>
)}
{maySetSlug && editing.mode !== "static" ? (
<>
<Field
label="Kurz-Link"
htmlFor="edit-slug"
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="edit-slug"
value={editSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
disabled={saving}
onChange={(event) => {
setEditSlug(event.target.value);
setEditError(null);
}}
/>
</Field>
{slugProblem ? (
<Notice tone="error">{slugProblem}</Notice>
) : slugGeaendert ? (
<Notice tone="info">
Der bisherige Kurz-Link{" "}
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
lässt er sich später freigeben.
</Notice>
) : null}
{editing.aliases.length > 0 ? (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
Frühere Kurz-Links
</span>
<p className="mb-2 text-sm text-slate-500">
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
nichts gedruckt ist — die Adresse ist danach sofort tot.
</p>
<ul className="space-y-1.5">
{editing.aliases.map((alias) => {
const behalten = editAliases.includes(alias);
return (
<li
key={alias}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
>
<code
className={
behalten
? "min-w-0 font-mono text-xs break-all text-slate-700"
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
}
>
/r/{alias}
</code>
<Button
type="button"
variant={behalten ? "ghost" : "secondary"}
size="sm"
disabled={saving}
onClick={() =>
setEditAliases((alt) =>
behalten
? alt.filter((eintrag) => eintrag !== alias)
: [...alt, alias],
)
}
>
{behalten ? "Freigeben" : "Doch behalten"}
</Button>
</li>
);
})}
</ul>
{freigegeben.length > 0 ? (
<Notice tone="warning">
{freigegeben.length === 1
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
</Notice>
) : null}
</div>
) : null}
</>
) : null}
<Field label="Name (optional)" htmlFor="edit-label">
<TextInput
id="edit-label"
+6 -6
View File
@@ -40,7 +40,7 @@ function ColorField({
aria-label={`${label} auswählen`}
value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
/>
<TextInput
id={id}
@@ -95,7 +95,7 @@ export function DesignPanel({
onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
}
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className="size-4 rounded border border-slate-300"
@@ -111,7 +111,7 @@ export function DesignPanel({
onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Farben tauschen
</button>
@@ -158,7 +158,7 @@ export function DesignPanel({
if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
}}
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
/>
</div>
</Field>
@@ -182,8 +182,8 @@ export function DesignPanel({
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
? "border-indigo-600 bg-indigo-50 text-indigo-700"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)}
>
{level}
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Grant = { groupId: string; groupName: string; isDefault: boolean };
type AdminDomain = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
codeCount: number;
grants: Grant[];
};
type GroupOption = { id: string; name: string };
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
const [groups, setGroups] = useState<GroupOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ hostname: "", label: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminDomain | null>(null);
const [grants, setGrants] = useState<Grant[]>([]);
const [grantError, setGrantError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{
domains: AdminDomain[];
groups: GroupOption[];
}>("/api/admin/domains");
setDomains(result.domains);
setGroups(result.groups);
setError(null);
} catch (caught) {
setDomains([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Domains konnten nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/domains", { method: "POST", json: form });
setCreating(false);
setForm({ hostname: "", label: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht angelegt werden.",
);
}
};
const patch = async (id: string, body: unknown, onDone?: () => void) => {
setBusy(id);
setError(null);
try {
await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body });
await load();
onDone?.();
return true;
} catch (caught) {
const message =
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.";
if (onDone) setGrantError(message);
else setError(message);
return false;
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Domain konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<Notice tone="info">
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404.
</Notice>
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
</div>
{domains.map((domain) => (
<Card key={domain.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-mono font-semibold text-slate-900">
{domain.hostname}
</h2>
{domain.hostname === baseUrlHostname ? (
<Badge tone="accent">aus BASE_URL</Badge>
) : null}
{!domain.isActive ? (
<Badge tone="soon">abgeschaltet</Badge>
) : null}
<Badge tone="neutral">
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{domain.label ? (
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
) : null}
<p className="mt-2 text-sm text-slate-600">
{domain.grants.length === 0
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
: domain.grants
.map(
(grant) =>
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => {
setEditing(domain);
setGrants(domain.grants);
setGrantError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Gruppen zuteilen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === domain.id}
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
>
{domain.isActive ? "Abschalten" : "Einschalten"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === domain.id}
onClick={() => setDeleting(domain)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* --------------------------------------------------- Registrieren */}
<Modal
open={creating}
title="Domain registrieren"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Registrieren
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field
label="Hostname"
htmlFor="new-domain-host"
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
>
<TextInput
id="new-domain-host"
className="font-mono"
placeholder="qr.example.com"
value={form.hostname}
onChange={(event) =>
setForm({ ...form, hostname: event.target.value })
}
/>
</Field>
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
<TextInput
id="new-domain-label"
placeholder="Kundenprojekt Nord"
value={form.label}
onChange={(event) => setForm({ ...form, label: event.target.value })}
/>
</Field>
</div>
</Modal>
{/* ------------------------------------------------------- Zuteilung */}
<Modal
open={editing !== null}
title={`Gruppen für ${editing?.hostname ?? ""}`}
onClose={() => setEditing(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={busy === editing?.id}
onClick={() =>
editing &&
void patch(
editing.id,
{
grants: grants.map((grant) => ({
groupId: grant.groupId,
isDefault: grant.isDefault,
})),
},
() => setEditing(null),
)
}
>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
<p className="text-sm text-slate-600">
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
</p>
<ul className="space-y-2">
{groups.map((group) => {
const grant = grants.find((entry) => entry.groupId === group.id);
return (
<li
key={group.id}
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
>
<Checkbox
id={`grant-${group.id}`}
label={group.name}
checked={Boolean(grant)}
onChange={(event) =>
setGrants((current) =>
event.target.checked
? [
...current,
{
groupId: group.id,
groupName: group.name,
isDefault: false,
},
]
: current.filter((entry) => entry.groupId !== group.id),
)
}
/>
{grant ? (
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
className="size-3.5 rounded border-slate-300 accent-indigo-600"
checked={grant.isDefault}
onChange={(event) =>
setGrants((current) =>
current.map((entry) =>
entry.groupId === group.id
? { ...entry, isDefault: event.target.checked }
: entry,
),
)
}
/>
Voreinstellung
</label>
) : null}
</li>
);
})}
</ul>
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Domain löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="font-mono text-slate-900">
{deleting.hostname}
</strong>{" "}
wird entfernt.
{deleting.codeCount > 0
? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.`
: " Es liegen keine Codes darunter."}
</p>
) : null}
</Modal>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Field,
Notice,
Select,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Role = "ADMIN" | "EDITOR" | "VIEWER";
const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Administrator",
EDITOR: "Bearbeiter",
VIEWER: "Betrachter",
};
const ROLE_HINTS: Record<Role, string> = {
ADMIN: "darf auch Mitglieder verwalten",
EDITOR: "darf Codes anlegen und ändern",
VIEWER: "darf nur ansehen",
};
type Member = { userId: string; email: string; name: string | null; role: Role };
type AdminGroup = {
id: string;
name: string;
description: string | null;
codeCount: number;
canManage: boolean;
members: Member[];
};
type PickerUser = { id: string; email: string; name: string | null };
export function GroupAdmin({ canCreate }: { canCreate: boolean }) {
const [groups, setGroups] = useState<AdminGroup[] | null>(null);
const [users, setUsers] = useState<PickerUser[]>([]);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", description: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [memberError, setMemberError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<AdminGroup | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ groups: AdminGroup[] }>("/api/admin/groups");
setGroups(result.groups);
setError(null);
} catch (caught) {
setGroups([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppen konnten nicht geladen werden.",
);
}
if (canCreate) {
try {
const result = await apiJson<{ users: PickerUser[] }>("/api/admin/users");
setUsers(result.users);
} catch {
// Ohne Benutzerliste lässt sich zwar niemand hinzufügen, die
// Gruppenübersicht bleibt aber brauchbar.
}
}
}, [canCreate]);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/groups", { method: "POST", json: form });
setCreating(false);
setForm({ name: "", description: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht angelegt werden.",
);
}
};
const saveMembers = async () => {
if (!editing) return;
setSaving(true);
setMemberError(null);
try {
await apiJson(`/api/admin/groups/${editing.id}`, {
method: "PATCH",
json: { members: members.map((m) => ({ userId: m.userId, role: m.role })) },
});
setEditing(null);
await load();
} catch (caught) {
setMemberError(
caught instanceof ApiError
? caught.message
: "Die Mitglieder konnten nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/groups/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (groups === null) {
return <p className="text-sm text-slate-500">Gruppen werden geladen …</p>;
}
const available = users.filter(
(user) => !members.some((member) => member.userId === user.id),
);
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{canCreate ? (
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Gruppe anlegen
</Button>
</div>
) : null}
{groups.map((group) => (
<Card key={group.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-slate-900">{group.name}</h2>
<Badge tone="neutral">
{group.codeCount} {group.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{group.description ? (
<p className="mt-1 text-sm text-slate-600">{group.description}</p>
) : null}
<ul className="mt-3 space-y-1 text-sm">
{group.members.length === 0 ? (
<li className="text-slate-500">Noch keine Mitglieder.</li>
) : (
group.members.map((member) => (
<li key={member.userId} className="text-slate-700">
{member.name?.trim() || member.email}
<span className="text-slate-500">
{" "}
— {ROLE_LABELS[member.role]}
</span>
</li>
))
)}
</ul>
<div className="mt-4 flex flex-wrap gap-2">
{group.canManage ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setEditing(group);
setMembers(group.members);
setMemberError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Mitglieder verwalten
</Button>
) : null}
{canCreate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => setDeleting(group)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
) : null}
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------- Anlegen */}
<Modal
open={creating}
title="Gruppe anlegen"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Anlegen
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field label="Name" htmlFor="new-group-name">
<TextInput
id="new-group-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</Field>
<Field label="Beschreibung (optional)" htmlFor="new-group-description">
<TextInput
id="new-group-description"
value={form.description}
onChange={(event) =>
setForm({ ...form, description: event.target.value })
}
/>
</Field>
</div>
</Modal>
{/* ---------------------------------------------------- Mitglieder */}
<Modal
open={editing !== null}
title={`Mitglieder von „${editing?.name ?? ""}“`}
onClose={() => setEditing(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setEditing(null)}
disabled={saving}
>
Abbrechen
</Button>
<Button type="button" onClick={() => void saveMembers()} disabled={saving}>
{saving ? "Speichert …" : "Speichern"}
</Button>
</>
}
>
<div className="space-y-4">
{memberError ? <Notice tone="error">{memberError}</Notice> : null}
{members.length === 0 ? (
<p className="text-sm text-slate-500">
Diese Gruppe hat noch keine Mitglieder.
</p>
) : (
<ul className="space-y-2">
{members.map((member) => (
<li
key={member.userId}
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-2.5"
>
<span className="min-w-0 flex-1 truncate text-sm text-slate-800">
{member.name?.trim() || member.email}
</span>
<Select
aria-label={`Rolle von ${member.email}`}
className="!w-auto"
value={member.role}
onChange={(event) =>
setMembers((current) =>
current.map((entry) =>
entry.userId === member.userId
? { ...entry, role: event.target.value as Role }
: entry,
),
)
}
>
{(Object.keys(ROLE_LABELS) as Role[]).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]} — {ROLE_HINTS[role]}
</option>
))}
</Select>
<button
type="button"
aria-label={`${member.email} entfernen`}
onClick={() =>
setMembers((current) =>
current.filter((entry) => entry.userId !== member.userId),
)
}
className="rounded p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Icon name="trash" className="size-4" />
</button>
</li>
))}
</ul>
)}
{available.length > 0 ? (
<Field label="Mitglied hinzufügen" htmlFor="add-member">
<Select
id="add-member"
value=""
onChange={(event) => {
const user = users.find((u) => u.id === event.target.value);
if (!user) return;
setMembers((current) => [
...current,
{
userId: user.id,
email: user.email,
name: user.name,
role: "EDITOR",
},
]);
}}
>
<option value="">Benutzer wählen …</option>
{available.map((user) => (
<option key={user.id} value={user.id}>
{user.name?.trim() || user.email}
</option>
))}
</Select>
</Field>
) : null}
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Gruppe löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="text-slate-900">{deleting.name}</strong> wird
gelöscht und alle Mitgliedschaften entfallen.
{deleting.codeCount > 0
? ` Die Gruppe enthält noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt.`
: " Sie enthält keine Codes."}
</p>
) : null}
</Modal>
</div>
);
}
+61
View File
@@ -115,6 +115,49 @@ const PATHS = {
</>
),
check: <path d="m4.5 12.5 5 5 10-11" />,
search: (
<>
<circle cx="10.8" cy="10.8" r="6.3" />
<path d="m15.5 15.5 5 5" />
</>
),
viewList: (
<>
<rect x="3" y="4.5" width="18" height="4" rx="1.2" />
<rect x="3" y="10" width="18" height="4" rx="1.2" />
<rect x="3" y="15.5" width="18" height="4" rx="1.2" />
</>
),
viewGrid: (
<>
<rect x="3.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.5" />
</>
),
eye: (
<>
<path d="M2.5 12s3.6-6.5 9.5-6.5S21.5 12 21.5 12s-3.6 6.5-9.5 6.5S2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</>
),
eyeOff: (
<>
<path d="M10.8 6.1A8.6 8.6 0 0 1 12 5.5c5.9 0 9.5 6.5 9.5 6.5a16 16 0 0 1-3 3.8" />
<path d="M6.5 6.9A15.7 15.7 0 0 0 2.5 12s3.6 6.5 9.5 6.5a8.8 8.8 0 0 0 4-1" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
<path d="m3 3 18 18" />
</>
),
dice: (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="3" />
<circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none" />
</>
),
copy: (
<>
<rect x="9" y="9" width="12" height="12" rx="2" />
@@ -171,6 +214,24 @@ const PATHS = {
<path d="M4 12h16" />
</>
),
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
</>
),
moon: (
<>
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
</>
),
monitor: (
<>
<rect x="2.5" y="4" width="19" height="13" rx="2" />
<path d="M9 21h6M12 17v4" />
</>
),
lock: (
<>
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
export function FeedbackLanding({ data, codeId }: LandingProps) {
const { title, question, scale, allowComment, commentLabel, thankYou } =
feedbackDataSchema.parse(data);
const [note, setNote] = useState<number | null>(null);
const [kommentar, setKommentar] = useState("");
const [laeuft, setLaeuft] = useState(false);
const [fertig, setFertig] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const symbole = FEEDBACK_SCALES[scale].symbols;
async function absenden() {
if (note === null) return;
setLaeuft(true);
setFehler(null);
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId, rating: note, comment: kommentar }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
setFehler(body?.error ?? "Die Rückmeldung konnte nicht gesendet werden.");
return;
}
setFertig(true);
} catch {
setFehler(
"Die Rückmeldung konnte nicht gesendet werden. Besteht die Verbindung noch?",
);
} finally {
setLaeuft(false);
}
}
if (fertig) {
return (
<LandingShell title={title}>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-10 text-center">
<p className="text-4xl" aria-hidden="true">
✓
</p>
<p className="mt-3 text-lg font-medium text-emerald-900">
{thankYou}
</p>
</div>
</LandingShell>
);
}
return (
<LandingShell title={title}>
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
<fieldset>
<legend className="text-center text-base font-medium text-slate-900">
{question}
</legend>
<div className="mt-5 flex justify-center gap-1.5 sm:gap-2">
{symbole.map((symbol, index) => {
const wert = index + 1;
const gewaehlt = note !== null && note >= wert;
return (
<button
key={wert}
type="button"
aria-label={`${wert} von 5`}
aria-pressed={note === wert}
disabled={laeuft}
onClick={() => setNote(wert)}
className={
scale === "stars"
? `rounded-lg px-1 text-4xl transition-transform hover:scale-110 sm:text-5xl ${
gewaehlt ? "text-amber-400" : "text-slate-200"
}`
: `rounded-lg px-1 text-3xl transition-transform hover:scale-110 sm:text-4xl ${
note === wert
? "scale-110"
: "opacity-40 grayscale"
}`
}
>
{symbol}
</button>
);
})}
</div>
{note !== null ? (
<p className="mt-2 text-center text-sm text-slate-500">
{note} von 5
</p>
) : null}
</fieldset>
{allowComment ? (
<div className="mt-6">
<label
htmlFor="feedback-comment"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
{commentLabel}
</label>
<textarea
id="feedback-comment"
rows={3}
maxLength={1000}
value={kommentar}
disabled={laeuft}
onChange={(event) => setKommentar(event.target.value)}
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
placeholder="Optional"
/>
</div>
) : null}
{fehler ? (
<p role="alert" className="mt-4 text-sm text-red-600">
{fehler}
</p>
) : null}
<button
type="button"
disabled={note === null || laeuft}
onClick={() => void absenden()}
className="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{laeuft ? "Wird gesendet …" : "Absenden"}
</button>
<p className="mt-3 text-center text-xs text-slate-400">
Deine Rückmeldung wird ohne Namen und ohne IP-Adresse gespeichert.
</p>
</div>
</LandingShell>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { imageDataSchema } from "@/lib/qr/types/image";
export function ImageLanding({ data }: LandingProps) {
const { title, description, images } = imageDataSchema.parse(data);
return (
<LandingShell title={title || "Bilder"} subtitle={description || null}>
<div className="space-y-4">
{images.map((image, index) => (
<figure
key={image.fileId}
className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
>
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung ohne bekannte Maße; der Optimierer von Next
brächte hier nichts. */}
<img
src={`/api/uploads/${image.fileId}`}
alt={image.caption || `Bild ${index + 1}`}
// Das erste Bild sieht man sofort, die übrigen erst beim Scrollen.
loading={index === 0 ? "eager" : "lazy"}
className="block w-full"
/>
{image.caption ? (
<figcaption className="border-t border-slate-100 px-4 py-3 text-sm text-slate-600">
{image.caption}
</figcaption>
) : null}
</figure>
))}
</div>
</LandingShell>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import { FeedbackLanding } from "@/components/landing/feedback-landing";
import { ImageLanding } from "@/components/landing/image-landing";
import { LinksLanding } from "@/components/landing/links-landing";
import { PdfLanding } from "@/components/landing/pdf-landing";
import { SocialLanding } from "@/components/landing/social-landing";
import type { LandingProps } from "@/components/landing/types";
import { VideoLanding } from "@/components/landing/video-landing";
import type { QrTypeId } from "@/lib/qr/type-module";
/**
* Zuordnung QR-Typ → öffentliche Landingpage.
*
* Dritter Teil des Musters aus Typ-Registry (`src/lib/qr/registry.ts`) und
* Formular-Registry (`src/components/qr-forms/index.ts`): Ein Typ, der
* `hasLandingPage` meldet, braucht hier genau einen Eintrag.
*/
export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>> =
{
links: LinksLanding,
social: SocialLanding,
pdf: PdfLanding,
image: ImageLanding,
feedback: FeedbackLanding,
video: VideoLanding,
};
export type { LandingProps };
+34
View File
@@ -0,0 +1,34 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { linksDataSchema } from "@/lib/qr/types/links";
export function LinksLanding({ data }: LandingProps) {
const { title, description, links } = linksDataSchema.parse(data);
return (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{links.map((link, index) => (
<li key={`${index}-${link.url}`}>
<a
href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
>
<span className="min-w-0 flex-1 font-medium break-words">
{link.label}
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
))}
</ul>
</LandingShell>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { pdfDataSchema } from "@/lib/qr/types/pdf";
import { formatBytes } from "@/lib/uploads";
export function PdfLanding({ data }: LandingProps) {
const { fileId, filename, size, title, description } =
pdfDataSchema.parse(data);
const href = `/api/uploads/${fileId}`;
return (
<LandingShell
title={title || filename || "PDF-Dokument"}
subtitle={description || null}
>
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
<div className="flex items-center gap-4">
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
<Icon name="document" className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-slate-900">{filename}</p>
<p className="text-sm text-slate-500">
PDF{size > 0 ? ` · ${formatBytes(size)}` : ""}
</p>
</div>
</div>
<div className="mt-5 grid gap-2 sm:grid-cols-2">
<a
href={href}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Icon name="document" className="size-4" />
Dokument öffnen
</a>
<a
href={`${href}?download`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-4" />
Herunterladen
</a>
</div>
</div>
</LandingShell>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
/**
* Rahmen aller öffentlichen Landingpages.
*
* Diese Seiten sehen Leute, die einen Code gescannt haben — meist auf dem
* Handy, oft unterwegs, ohne Bezug zu dieser Anwendung. Deshalb: eine Spalte,
* große Schrift, keine Navigation, kein Hinweis auf die Verwaltung.
*/
export function LandingShell({
title,
subtitle,
children,
footer,
}: {
title: string;
subtitle?: string | null;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col bg-slate-50">
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-10 sm:px-6 sm:py-14">
<header className="mb-7 text-center">
<h1 className="text-2xl font-bold tracking-tight text-balance text-slate-900 sm:text-3xl">
{title}
</h1>
{subtitle ? (
<p className="mt-2.5 text-pretty text-slate-600">{subtitle}</p>
) : null}
</header>
{children}
</main>
{footer ? (
<footer className="px-4 pb-8 text-center text-xs text-slate-400">
{footer}
</footer>
) : null}
</div>
);
}
/**
* Ein Fehlerbild im selben Gewand — etwa wenn ein Code gelöscht wurde.
* Bewusst ohne Schuldzuweisung: wer hier landet, kann nichts dafür.
*/
export function LandingNotice({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className="grid flex-1 place-items-center bg-slate-50 px-4 py-16">
<div className="max-w-md text-center">
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl">
{title}
</h1>
<p className="mt-3 leading-relaxed text-pretty text-slate-600">
{children}
</p>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import {
getSocialNetwork,
socialHandleLabel,
} from "@/lib/qr/social-networks";
import { socialDataSchema } from "@/lib/qr/types/social";
export function SocialLanding({ data }: LandingProps) {
const { title, description, profiles } = socialDataSchema.parse(data);
return (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{profiles.map((profile) => {
const network = getSocialNetwork(profile.network);
return (
<li key={profile.network}>
<a
href={profile.url}
rel="noopener noreferrer"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: network?.color ?? "#475569" }}
>
{network?.monogram ?? "?"}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">
{network?.label ?? profile.network}
</span>
<span className="block truncate text-sm text-slate-500">
{socialHandleLabel(profile.url)}
</span>
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
);
})}
</ul>
</LandingShell>
);
}

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