36 Commits
Author SHA1 Message Date
florian.wilde 4e2d222e86 Merge: Version 1.7.1 — Bilder zu den neuen Funktionen 2026-08-21 10:23:58 +00:00
Florian Wilde fcc63ff69a Version 1.7.1: Bilder zu den neuen Funktionen
Die Bedienungsanleitung hatte zu jedem alten Abschnitt ein Bild und zu
keinem der sechs neuen. Sechs Aufnahmen kommen dazu: Logo im Code,
Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und der
Bericht einer geprueften Domain.

Das Skript dafuer lief seit einer Umbenennung ins Leere — es wartete auf
einen Dialogtitel "Ziel bearbeiten", der inzwischen "Code bearbeiten"
heisst. Damit sind auch die uebrigen sechzehn Bilder neu; sie stammten
noch aus 1.2.0 und zeigten eine aeltere Oberflaeche.

schuss() kann jetzt einen Ausschnitt aufnehmen statt der ganzen Seite —
fuer Dialoge und einzelne Karten, bei denen die volle Seite mehr verdeckt
als zeigt. Das Beispiellogo entsteht im Skript, damit kein ungepflegtes
Bild im Repo liegt.

In der Kopfdoku steht jetzt, dass BASE_URL der Instanz auf dieselbe
Adresse zeigen muss wie BASE — sonst erreicht die Anwendung sich beim
Domain-Check nicht selbst und das Bild zeigt einen Fehlschlag.
2026-08-21 12:23:15 +02:00
florian.wilde 1f4753b753 Merge: Version 1.7.0 — Dokumentation durchgesehen, Roadmap angeglichen 2026-08-21 09:41:25 +00:00
Florian Wilde cdda7df17a Version 1.7.0: Doku durchgesehen und Roadmap angeglichen
Sechs Erweiterungen sind zusammengekommen — Logo im Code, Freigabe je
Gruppe, Domain-Check, Dublettenpruefung, Filter nach Art und die
Kurzhilfe zu den Domains. Dafuer eine neue Minor-Version.

Zwei Stellen waren schlicht falsch und wiegen schwerer als jede Luecke:
Anleitung und Funktionsuebersicht behaupteten beide noch, statische Codes
wuerden "nicht gespeichert" — ueberholt seit 1.5.0. Ebenso die Aussage,
nur "Website" sei ausprogrammiert; das gilt seit 1.1.0 fuer alle fuenfzehn.

Danach die Luecken: Jedes der sechs Features steht jetzt in allen drei
Dokumenten, die es betrifft. Die Bedienungsanleitung hatte keines davon.

Die Roadmap verliert die zwei gebauten Punkte und bekommt einen neuen aus
der Umsetzung: Die Gestaltung eines gespeicherten Codes laesst sich
ueberhaupt nicht aendern, ein Logo also nur ueber die Schnittstelle
nachruesten.

Die Kurzhilfe verwies unter "Nachpruefen" noch auf curl, obwohl seit #7
der Pruefen-Knopf danebensteht.
2026-08-21 11:27:15 +02:00
florian.wilde c6891f66ff Merge #11: QR-Typen je Gruppe freigeben 2026-08-21 09:00:09 +00:00
Florian Wilde ff28352907 Merge branch 'main' into feat/11-typen-je-gruppe 2026-08-21 10:58:08 +02:00
florian.wilde 830d6d7379 Merge #8: Logo im QR-Code 2026-08-21 08:56:57 +00:00
Florian Wilde a4bf3c77b6 Merge branch 'main' into feat/8-logo-im-code
# Conflicts:
#	docs/funktionen.md
2026-08-21 10:55:54 +02:00
florian.wilde 43a0504f38 Merge #7: Domain-Check für DNS, Webserver und Zertifikat 2026-08-21 08:53:24 +00:00
Florian Wilde 14b9ffc22f Merge branch 'main' into feat/7-domain-check
# Conflicts:
#	src/components/domain-admin.tsx
2026-08-21 10:51:57 +02:00
florian.wilde 87ffdcae04 Merge #6: Dublettenprüfung 2026-08-21 08:46:49 +00:00
florian.wilde a7af9c9290 Merge #5: Filtermöglichkeit der angelegten Codes 2026-08-21 08:45:51 +00:00
florian.wilde 101f664cef Merge #4: Domains hinzufügen — Kurzhilfe erstellen 2026-08-21 08:43:16 +00:00
Florian Wilde 3cb5421a20 QR-Typen je Gruppe freigeben
Bisher galt die Freigabe eines Typs fuer die ganze Installation. Bei
mehreren Gruppen ist das zu grob: Die Marketingabteilung braucht Social
Media, die Werkstatt nur Website und PDF.

Die Freigabe bekommt eine zweite Ebene. Die Grundeinstellung bleibt die
Grundlage; je Gruppe laesst sich davon abweichen. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.

Zur offenen Frage im Issue: Die globale Abschaltung gewinnt immer. Ein
global abgeschalteter Typ laesst sich in keiner Gruppe einschalten — der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum waere
"hier abgeschaltet" keine verlaessliche Aussage mehr.

Eine eigene Tabelle statt eines zusammengesetzten Schluessels in
QrTypeSetting: Der globale Fall braeuchte dort eine leere Gruppe, und
PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
nicht gesichert.

Die Startseite zeigt die Vereinigung ueber die eigenen Gruppen; beim
Anlegen entscheidet die gewaehlte Gruppe, und die Route prueft das noch
einmal.

Refs #11
2026-08-21 03:48:22 +02:00
Florian Wilde c06f87e6d9 Logo in der Mitte des QR-Codes
Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem
Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und
25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche
in der Hintergrundfarbe.

Mit Logo gilt die Fehlerkorrektur H, unabhaengig von der Auswahl — was das
Bild verdeckt, muss sie ausgleichen. Die Auswahl steht dann fest auf H und
sagt auch warum; heimlich geschieht das nicht. Ab 22 Prozent kommt eine
Warnung dazu.

Gerendert wird weiterhin serverseitig, damit Vorschau und Download
uebereinstimmen: das SVG bekommt ein eingebettetes Bild, das PNG wird mit
sharp zusammengesetzt. sharp lag ohnehin als Abhaengigkeit von Next im
Baum und steht jetzt ausdruecklich in der package.json.

Ein eigenes Logo gehoert zur Gestaltung, nicht zum Inhalt — es muss
trotzdem an den Code gehaengt werden. Sonst raeumt die Aufraeumroutine es
nach einem Tag weg und der Code verliert sein Bild.

Refs #8
2026-08-21 03:22:36 +02:00
Florian Wilde 69c34c4f0c Domain-Check: fuehrt die Domain wirklich hierher?
Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS,
Reverse Proxy und Zertifikat liegen ausserhalb dieser Anwendung. Fehlt
dort etwas, merkt man es am gedruckten Code, der nicht aufgeht.

Der Knopf "Pruefen" geht den Weg der Scanner nach: Namen aufloesen,
Zertifikat ansehen, Adresse aufrufen — und sich beweisen lassen, dass am
anderen Ende diese Installation steht. Der Beweis ist ein HMAC ueber eine
frische Zufallszahl; das Geheimnis dafuer entsteht beim ersten Check und
verlaesst den Server nie.

Geprueft wird nur ein Hostname aus der Datenbank, nie einer aus der
Anfrage — sonst waere die Route ein Werkzeug zum Abklopfen fremder
Adressen. Scheitert schon DNS, fallen die drei Folgeschritte aus, statt
dreimal dasselbe zu melden.

Die Grenze der Aussage steht im Bericht selbst: Der Aufruf startet dort,
wo der Container laeuft.

Refs #7
2026-08-21 03:06:54 +02:00
Florian Wilde 60f46bcdb5 Warnung vor doppelt abgelegten statischen Codes
Ein statischer Code traegt seinen Inhalt selbst — zweimal derselbe Inhalt
ergibt zweimal exakt dasselbe Bild. Bisher entstand der zweite Eintrag
stillschweigend.

Das Anlegen antwortet nun mit 409 und nennt den vorhandenen Eintrag samt
Namen und Datum; die Oberflaeche zeigt das als Warnung mit dem Knopf
"Trotzdem ablegen". Eine Rueckfrage, keine Sperre — zwei Fassungen
desselben Codes koennen gewollt sein.

Verglichen wird Typ und Inhalt innerhalb der Gruppe, nicht die Gestaltung.
Dynamische Codes bleiben aussen vor: zwei Kurz-Links auf dasselbe Ziel
sind der Normalfall, sobald getrennt gezaehlt werden soll.

ApiError traegt dafuer jetzt Status und Antwortkoerper — eine gemeldete
Dublette ist keine Sackgasse, sondern eine Rueckfrage.

Refs #6
2026-08-21 02:53:23 +02:00
Florian Wilde 142536140c Meine Codes: Liste nach Art einschraenken
Seit statische Codes ablegbar sind, mischen sich zwei Arten in derselben
Liste. Eine Auswahl schraenkt sie auf dynamische oder statische ein — und
erscheint nur, wenn ueberhaupt beide vorkommen.

Zeigt die Liste nur statische Codes, faellt die Scan-Spalte weg und die
Sortierung nach Scans mit ihr: Statische zaehlen keine Scans, die Spalte
waere eine Reihe aus Strichen. Stand die Sortierung darauf, springt sie
auf "Neueste zuerst".

Die Beschreibung der Seite behauptete noch, statische Codes wuerden nicht
gespeichert. Das stimmt seit #2 nicht mehr.

Refs #5
2026-08-21 02:43:13 +02:00
Florian Wilde 1cf1df54df Kurzhilfe erklaert das Hinzufuegen einer Domain
Eine Domain zu registrieren genuegt nicht — DNS, Reverse Proxy und
Zertifikat muessen dazu passen. Bislang stand das nur im README, also
genau dort nicht, wo man es braucht.

Auf der Domain-Seite oeffnet ein Knopf die Erklaerung in sechs Schritten,
mit den drei noetigen Kopfzeilen und einem Pruefbefehl. Der Knopf selbst
ist als HelpButton allgemein gehalten, damit andere Verwaltungsseiten
spaeter dieselbe Form bekommen koennen.

Refs #4
2026-08-21 02:36:18 +02:00
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
133 changed files with 9019 additions and 439 deletions
+40
View File
@@ -26,6 +26,46 @@ AUTH_ENABLED=false
# grosse Werte machen die Sicherung entsprechend schwer. # grosse Werte machen die Sicherung entsprechend schwer.
MAX_UPLOAD_MB=10 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 ------------------------------------------------------------ # --- PostgreSQL ------------------------------------------------------------
POSTGRES_USER=qrcode POSTGRES_USER=qrcode
POSTGRES_PASSWORD=bitte-aendern POSTGRES_PASSWORD=bitte-aendern
+93
View File
@@ -0,0 +1,93 @@
# Änderungen
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
Anwendung unter **Version und Änderungen**, erreichbar über die
Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.7.1 — 21.08.2026
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
## 1.7.0 — 21.08.2026
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
## 1.6.0 — 21.08.2026
Sichtbare Version mit Änderungsverlauf.
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
## 1.5.0 — 20.08.2026
Statische Codes lassen sich benennen und ablegen.
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
- Der Name bestimmt den Dateinamen beim Herunterladen.
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
## 1.4.0 — 17.08.2026
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
## 1.3.0 — 16.08.2026
Eigenes Erscheinungsbild und ein Dark Mode.
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
## 1.2.0 — 15.08.2026
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
## 1.1.0 — 14.08.2026
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
## 1.0.0 — 13.08.2026
Erste lauffähige Fassung.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
- Download als PNG und SVG, serverseitig gerendert.
- Update-Skript für Linux, macOS und Windows.
+265 -29
View File
@@ -8,6 +8,59 @@ 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.
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
erhoben, ohne Name und IP-Adresse.
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
Administrator, Bearbeiter und Betrachter.
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
für die ganze Installation oder je Gruppe.
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
→ 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 ## Inhalt
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack) - [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
@@ -17,10 +70,11 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- [Konfiguration](#konfiguration) - [Konfiguration](#konfiguration)
- [Bedienung](#bedienung) - [Bedienung](#bedienung)
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen) - [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
- [Technischer Aufbau](#technischer-aufbau) - [Technischer Aufbau](#technischer-aufbau)
- [Entwicklung ohne Docker](#entwicklung-ohne-docker) - [Entwicklung ohne Docker](#entwicklung-ohne-docker)
- [API](#api) - [API](#api)
- [Ausblick Version 2](#ausblick-version-2) - [Einen weiteren Typ ergänzen](#einen-weiteren-typ-ergänzen)
--- ---
@@ -80,9 +134,25 @@ BASE_URL=http://192.168.1.50:3000
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht > **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort > das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy > `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die > läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht > umbiegen, also am besten vor dem ersten produktiven Code festlegen.
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen. >
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
>
> | Wer scannt | Was in `BASE_URL` gehört |
> | --- | --- |
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
>
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
> Hinweis darauf, woran es liegt.
>
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
> über einen externen Anbieter, siehe
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
Servicename aus der `docker-compose.yml`, nicht dein Server. Servicename aus der `docker-compose.yml`, nicht dein Server.
@@ -190,7 +260,7 @@ BASE_URL=https://qr.deine-domain.de
| Scheme | `http` | | Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | | Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` | | Forward Port | `3000` |
| Websockets Support | an | | Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
| Block Common Exploits | an | | Block Common Exploits | an |
| SSL | Zertifikat anfordern, *Force SSL* aktivieren | | SSL | Zertifikat anfordern, *Force SSL* aktivieren |
@@ -205,9 +275,76 @@ Port. Danach:
docker compose up -d docker compose up -d
``` ```
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung > `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue > aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
> dynamische Codes ins interne Netz statt auf die Domain. > `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
> scheitert (siehe unten).
### Was der Proxy weiterreichen muss
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
sie eintragen.
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
| --- | --- | --- |
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
Für ein reines nginx (ohne NPM) heißt das:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
von sich aus.
### Größe der Uploads
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
Oberfläche.
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
**Advanced → Custom Nginx Configuration**:
```nginx
client_max_body_size 20m;
```
### Nachprüfen, ob es stimmt
```bash
# Kommt die Anwendung überhaupt an?
curl -I https://qr.deine-domain.de/
# Kennt der Container dieselbe Adresse wie der Browser?
docker compose exec app env | grep BASE_URL
```
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
- jeder neue dynamische Code zeigt auf die interne Adresse, und
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
### Erreichbar für wen?
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
öffentlich auflösbar und der Proxy von außen erreichbar sein.
### Mehrere Domains ### Mehrere Domains
@@ -220,6 +357,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
404 — über einen gefälschten Header lässt sich also keine fremde Domain 404 — über einen gefälschten Header lässt sich also keine fremde Domain
ansprechen. ansprechen.
### Nachprüfen, ob eine Domain wirklich hierher führt
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
Weg der Scanner nach und meldet vier Dinge:
| Schritt | Was geprüft wird |
| --- | --- |
| DNS | Löst der Name auf? Auf welche Adressen? |
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
| Ziel | Steht dahinter **diese** Installation? |
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
irgendwohin“ von „die Domain zeigt hierher“.
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
> Dafür bleibt der Blick von einem fremden Netz aus.
Zwei Dinge, die daraus folgen: Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
@@ -450,12 +611,32 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
| | Bedeutung | Woher | | | Bedeutung | Woher |
| --- | --- | --- | | --- | --- | --- |
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code | | **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar | | **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“. umgesetzt, erscheint als Kachel „Bald verfügbar“.
#### Freigabe je Gruppe
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
zu wissen, was er getan hat.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
das noch einmal.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab, > **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter > leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also > bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
@@ -485,13 +666,21 @@ Dazu kommen
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl, - eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. - ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
- ein **Filter nach Art**, sobald dynamische und statische Codes
nebeneinander liegen.
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
Kartenansicht. Kartenansicht.
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
@@ -655,6 +844,37 @@ zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
sonst führten gedruckte Codes ins Leere. 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)**
---
## Technischer Aufbau ## Technischer Aufbau
### Stack ### Stack
@@ -800,8 +1020,10 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen | | `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | | `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | | `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen | | `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | | `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
Beispiel: Beispiel:
@@ -819,25 +1041,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 Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er
Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN und aus:
Feedback.
Die Architektur ist darauf ausgelegt — ein neuer Typ besteht aus:
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung), 1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
2. **einer Formular-Komponente** in `src/components/qr-forms/`, 2. **einer Formular-Komponente** in `src/components/qr-forms/`,
3. **je einem Eintrag** in `src/lib/qr/registry.ts` (ersetzt dort den 3. **je einem Eintrag** in `src/lib/qr/registry.ts` und in
`defineComingSoonType`-Platzhalter) und in `src/components/qr-forms/index.ts`,
`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, Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung,
Download, Gestaltung, Speicherung dynamischer Codes und die Weiterleitung Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen
arbeiten für alle Typen gleich. 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 Für einen angekündigten, aber noch nicht gebauten Typ gibt es
„Mehrere Links“ oder „Feedback“), setzen dafür `redirectTarget` auf eine `defineComingSoonType`: Er erscheint als Kachel mit dem Badge „Bald
interne Adresse — auch dafür ist keine Änderung am Datenmodell nötig, weil verfügbar“ und lässt sich nicht öffnen.
`data` als JSONB gespeichert wird.
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.
+12
View File
@@ -39,6 +39,18 @@ services:
# Anmeldung mit Benutzern, Gruppen und Rollen. # Anmeldung mit Benutzern, Gruppen und Rollen.
AUTH_ENABLED: ${AUTH_ENABLED:-false} AUTH_ENABLED: ${AUTH_ENABLED:-false}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10} 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: ports:
# Direktes Port-Mapping, bewusst ohne Reverse Proxy. # Direktes Port-Mapping, bewusst ohne Reverse Proxy.
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
+422
View File
@@ -0,0 +1,422 @@
# 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.
### Ein Logo in die Mitte setzen
![Ein Logo in der Mitte des Codes](bilder/16-logo-im-code.png)
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
dann fragt die Anwendung, woher das Bild kommen soll:
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
überhaupt eines liegt.
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
Auswahl; heimlich geschieht es nicht.
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
> kosten eine halbe Minute und ersparen eine Neuauflage.
---
## Meine Codes
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
![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.
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
![Die Liste, eingeschränkt auf statische Codes](bilder/18-meine-codes-filter.png)
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
Vorschau. Die Wahl merkt sich der Browser.
![Meine Codes als Karten](bilder/05-meine-codes-karten.png)
### Statische Codes ablegen
Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
![Warnung vor einem doppelt abgelegten Code](bilder/17-dublettenwarnung.png)
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
Hausfarben gilt hier als Dublette.
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
### Ziel ändern
![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.
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
![Freigabe der Typen für eine einzelne Gruppe](bilder/19-verwaltung-typen-gruppe.png)
| Auswahl | Wirkung |
| --- | --- |
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
Aussage mehr.
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
Gruppe.
### Gruppen
![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. Was dafür einzurichten ist, steht nicht nur im README: Der
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
Registrieren, Gruppenzuteilung, Nachprüfen.
![Die Kurzhilfe zum Hinzufügen einer Domain](bilder/20-domain-kurzhilfe.png)
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
![Der Bericht einer geprüften Domain](bilder/21-domain-check.png)
| Schritt | Was er sagt |
| --- | --- |
| DNS | ob der Name auflöst, und auf welche Adressen |
| Zertifikat | gültig bis wann, von wem ausgestellt |
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
| Ziel | ob dahinter **diese** Installation steht |
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
Meldung würde die eine wichtige verdecken.
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
---
## 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: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 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: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+656
View File
@@ -0,0 +1,656 @@
# 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.
**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit
identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und
wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung,
keine Sperre: Zwei Fassungen desselben Codes können gewollt sein.
Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in
Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng
genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes
bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall,
sobald zwei Kampagnen getrennt gezählt werden sollen.
### Ein Logo in der Mitte
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
Zur Wahl stehen zwei Quellen:
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
genügt, kein Upload je Code;
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
Fläche in der Hintergrundfarbe, damit es sich absetzt.
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
nach Ausrichtungsmustern.
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
gelegentlich abgetippt.
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
| | |
| --- | --- |
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
dürfen sich nicht nur in der Schreibweise unterscheiden.
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
ließen sich fremde gedruckte Codes umlenken.
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
weiterhin.
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
zwei Domains liegen und dort auf Verschiedenes zeigen.
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
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 die dynamischen Codes und die statischen, die abgelegt wurden. Ein
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
„statisch“ und zählt keine Scans.
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
mit großer Vorschau. Die Wahl merkt sich der Browser.
- **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.
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
Scans, die Spalte wäre eine Reihe aus Strichen.
- **Kurz-URL kopieren** mit einem Klick.
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
- **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.
### Kurzhilfe zur Einrichtung
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
Einrichten und danach nie wieder.
### Prüfen, ob eine Domain hierher führt
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
und meldet vier Dinge:
| Schritt | Was geprüft wird |
| --- | --- |
| DNS | Löst der Name auf? Auf welche Adressen? |
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
| Ziel | Steht dahinter **diese** Installation? |
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
Datenbank und verlässt den Server nie.
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
Meldung würde die eine wichtige verdecken.
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
dass die Domain von außen erreichbar ist.
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
beliebige Adressen abklopfen ließen. Näheres in
[sicherheit.md](sicherheit.md).
---
## Erscheinungsbild
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
| | |
| --- | --- |
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
benutzbar.
### Hell und dunkel
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
Kopfzeile steht ein Umschalter mit drei Zuständen:
| | |
| --- | --- |
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
| **Hell** | immer hell |
| **Dunkel** | immer dunkel |
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
Tab zieht automatisch nach.
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
sogar **ohne JavaScript**.
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
aussehen.
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
als eigener Punkt auf der [Roadmap](roadmap.md).
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
---
## Verwaltung
| Bereich | Was dort geht |
| --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
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.
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
abgeschaltet“ keine verlässliche Aussage mehr.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
beim Anlegen anhand der gewählten Gruppe.
---
## Betrieb
- **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).
+150
View File
@@ -0,0 +1,150 @@
# 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,
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
die Kurzhilfe zu den Domains.
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
---
## Als Nächstes
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
Person, die den Code kennt.
### 1. Statistik über die Zeit · mittel
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
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“.
### 2. 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.
### 3. Ablaufdatum und Zeitsteuerung · mittel
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
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.
### 4. Gestaltung eines gespeicherten Codes ändern · klein
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
Bearbeiten-Dialog.
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
---
## Später denkbar
**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“.
+426
View File
@@ -0,0 +1,426 @@
/**
* 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
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
* selbst unter der eingetragenen Adresse nicht erreicht.
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium
*
* 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";
import sharp from "sharp";
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",
});
/**
* Nimmt eine Seite auf.
*
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
* bei denen die ganze Seite mehr verdeckt als zeigt.
*/
async function schuss(
context,
pfad,
datei,
{ ganzeSeite = true, vorher, bereich } = {},
) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
const ziel = join(ZIEL, datei);
if (bereich) {
await page.locator(bereich).first().screenshot({ path: ziel });
} else {
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
}
await page.close();
console.log(" ✓", datei);
}
/**
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
*
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
* und die Farbe passt zu den übrigen Aufnahmen.
*/
async function beispiellogo() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#312E81"/>
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png");
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: "Code 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");
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
const logo = await beispiellogo();
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
// sind — das eine erklärt das andere.
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
await page.setInputFiles('input[type="file"]', {
name: "logo.png",
mimeType: "image/png",
buffer: logo,
});
await page.waitForTimeout(2500);
},
});
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
const statisch = await api("/api/codes", {
method: "POST",
json: {
type: "website",
data: { url: "https://example.com/aushang" },
design: DESIGN,
mode: "static",
label: "Aushang Eingang",
},
});
if (statisch.status === 201) angelegt.push(statisch.body.code);
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
bereich: "section:has-text('Art des Codes')",
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/aushang");
await page.locator('button[data-mode="static"]').click();
await page.waitForTimeout(900);
await page.getByRole("button", { name: /ablegen/i }).click();
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
timeout: 15000,
});
},
});
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
vorher: async (page) => {
await page.getByLabel("Nach Art filtern").selectOption("static");
await page.waitForTimeout(600);
},
});
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
const gruppe = await api("/api/admin/groups", {
method: "POST",
json: { name: "Marketing" },
});
const gruppeId = gruppe.body?.group?.id ?? null;
if (gruppeId) {
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
// um den es geht.
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: false, groupId: gruppeId },
});
}
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
vorher: async (page) => {
if (gruppeId) {
await page.getByLabel("Freigabe für").selectOption(gruppeId);
await page.waitForTimeout(900);
}
},
});
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
bereich: "dialog[open]",
vorher: async (page) => {
await page.getByRole("button", { name: "Kurzhilfe" }).click();
await page.locator("dialog[open]").waitFor();
await page.waitForTimeout(400);
},
});
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
// Fall, den die Anleitung zeigen soll.
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
vorher: async (page) => {
await page.getByRole("button", { name: "Prüfen" }).first().click();
await page
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
.first()
.waitFor({ timeout: 30000 });
await page.waitForTimeout(400);
},
});
await browser.close();
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
const bestand = await api("/api/codes");
for (const eintrag of bestand.body?.codes ?? []) {
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
angelegt.push(eintrag);
}
}
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
// Instanz so hinterlassen, wie es sie vorgefunden hat.
if (gruppeId) {
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: true, groupId: gruppeId },
});
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
}
/* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) {
await api(`/api/codes/${eintrag.id}`, { method: "DELETE" });
}
console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`);
console.log(`Die Bilder liegen in ${ZIEL}.`);
+308
View File
@@ -0,0 +1,308 @@
# 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.
---
## Der Domain-Check und seine Grenzen
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
Server aus auf. Drei Dinge grenzen das ein:
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
aus beliebige Adressen abklopfen ließen.
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
die auch Domains anlegt.
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Übertragen wird über diese Verbindung nichts.
---
## Offen
Bewusst nicht umgesetzt, mit Begründung.
**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 // qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
// sollen nicht vom Bundler verarbeitet werden. // sollen nicht vom Bundler verarbeitet werden.
serverExternalPackages: ["qrcode", "pg"], 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; export default nextConfig;
+127 -6
View File
@@ -1,21 +1,23 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.0.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.0.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"jose": "^6.2.8",
"next": "16.3.0", "next": "16.3.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -28,6 +30,7 @@
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1", "prisma": "^7.9.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
@@ -564,7 +567,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2063,6 +2065,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4037,7 +4105,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5024,6 +5091,21 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5950,6 +6032,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7085,6 +7176,38 @@
"pathe": "^2.0.3" "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": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -7737,7 +7860,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -7787,7 +7909,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
+5 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.1.0", "version": "1.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "prisma generate", "db:generate": "prisma generate",
@@ -16,12 +17,14 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"jose": "^6.2.8",
"next": "16.3.0", "next": "16.3.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -34,6 +37,7 @@
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1", "prisma": "^7.9.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
@@ -44,4 +48,3 @@
"unrs-resolver": true "unrs-resolver": true
} }
} }
@@ -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;
@@ -0,0 +1,7 @@
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
--
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
@@ -0,0 +1,11 @@
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
--
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
@@ -0,0 +1,25 @@
-- QR-Typen lassen sich je Gruppe abschalten.
--
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
-- aufheben — global aus gewinnt immer.
--
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
-- nebeneinander zu.
CREATE TABLE "QrTypeGroupSetting" (
"type" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
);
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
-- Mit der Gruppe verschwindet auch ihre Abweichung.
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+135 -4
View File
@@ -29,7 +29,12 @@ model User {
email String @unique email String @unique
name String? name String?
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort. /// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
passwordHash String /// 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. /// Darf Gruppen, Benutzer und Domains verwalten.
isInstanceAdmin Boolean @default(false) isInstanceAdmin Boolean @default(false)
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen /// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
@@ -58,12 +63,17 @@ model Session {
tokenHash String @unique tokenHash String @unique
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
expiresAt DateTime 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()) lastSeenAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@index([expiresAt]) @@index([expiresAt])
@@index([absoluteExpiresAt])
} }
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. /// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
@@ -77,6 +87,7 @@ model Group {
memberships Membership[] memberships Membership[]
codes QrCode[] codes QrCode[]
domainGrants DomainGrant[] domainGrants DomainGrant[]
typeSettings QrTypeGroupSetting[]
} }
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer /// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
@@ -112,6 +123,7 @@ model Domain {
grants DomainGrant[] grants DomainGrant[]
codes QrCode[] codes QrCode[]
aliases QrCodeAlias[]
} }
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen /// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -144,6 +156,31 @@ model QrTypeSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
///
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
/// nicht gesichert.
///
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
/// aufheben — global aus gewinnt immer.
model QrTypeGroupSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String
groupId String
enabled Boolean
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@id([type, groupId])
@@index([groupId])
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und /// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird. /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
/// ///
@@ -151,10 +188,18 @@ model QrTypeSetting {
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung. /// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode { model QrCode {
id String @id @default(cuid()) id String @id @default(cuid())
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei /// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt /// 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. /// diese Eindeutigkeit über Gruppengrenzen hinweg.
slug String ///
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
slug String?
type String type String
label String? label String?
data Json data Json
@@ -163,6 +208,14 @@ model QrCode {
size Int @default(512) size Int @default(512)
ecLevel String @default("M") ecLevel String @default("M")
margin Int @default(4) margin Int @default(4)
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
logoSource String?
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
logoUploadId String?
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
logoScale Int @default(20)
scans Int @default(0) scans Int @default(0)
lastScanAt DateTime? lastScanAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -184,11 +237,14 @@ model QrCode {
uploads Upload[] uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind. /// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[] feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug]) @@unique([domainId, slug])
@@index([createdAt]) @@index([createdAt])
@@index([groupId]) @@index([groupId])
@@index([domainId]) @@index([domainId])
@@index([mode])
} }
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter /// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
@@ -209,14 +265,89 @@ model Upload {
data Bytes data Bytes
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
/// erste Art wird als herrenlos weggeräumt.
purpose String @default("code")
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen, /// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt /// der Code aber noch nicht gespeichert wurde; solche Reste räumt
/// `src/lib/uploads.ts` nach einer Weile weg. /// `src/lib/uploads.ts` nach einer Weile weg.
qrCodeId String? qrCodeId String?
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
brandingLogo Branding? @relation("BrandingLogo")
brandingFavicon Branding? @relation("BrandingFavicon")
@@index([qrCodeId]) @@index([qrCodeId])
@@index([createdAt]) @@index([createdAt])
@@index([purpose])
}
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
/// Geheimnis fuer den Domain-Check.
///
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
/// Server nie und entsteht beim ersten Check.
checkSecret String?
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
model Branding {
id String @id @default("branding")
appName String @default("QR-Code-Generator")
/// Zeile in der Fußzeile, hinter dem Namen.
footerText String @default("statische und dynamische QR-Codes")
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
/// src/lib/branding-colors.ts.
accentColor String @default("#4F46E5")
logoUploadId String? @unique
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
faviconUploadId String? @unique
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
updatedAt DateTime @updatedAt
} }
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes /// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
+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.");
}
+68 -6
View File
@@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Notice } from "@/components/ui"; import { Notice } from "@/components/ui";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor"; import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password"; import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Anmelden" }; export const metadata: Metadata = { title: "Anmelden" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SignInPage() { type PageProps = { searchParams: Promise<{ fehler?: string }> };
export default async function SignInPage({ searchParams }: PageProps) {
// Ohne Anmeldung gibt es hier nichts zu tun. // Ohne Anmeldung gibt es hier nichts zu tun.
if (!isAuthEnabled()) redirect("/"); if (!isAuthEnabled()) redirect("/");
@@ -18,30 +21,89 @@ export default async function SignInPage() {
if (actor.kind === "user") redirect("/"); if (actor.kind === "user") redirect("/");
const setup = await isSetupOpen(); 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 ( return (
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6"> <div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
<div className="text-center"> <div className="text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white"> <span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Icon name={setup ? "lock" : "qr"} className="size-6" /> <Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
</span> </span>
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900"> <h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
{setup ? "Ersteinrichtung" : "Anmelden"} {setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
</h1> </h1>
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-slate-600">
{setup {setup && zeigeFormular
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen." ? "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."} : "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
</p> </p>
</div> </div>
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />} {fehler ? <Notice tone="warning">{fehler}</Notice> : null}
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
{setup ? ( {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"> <Notice tone="info">
Der erste Benutzer wird Administrator der Installation und darf Der erste Benutzer wird Administrator der Installation und darf
Gruppen, Benutzer und die QR-Typen verwalten. Gruppen, Benutzer und die QR-Typen verwalten.
</Notice> </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"> <p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung → Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+36 -4
View File
@@ -4,6 +4,10 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator"; import { QrGenerator } from "@/components/qr-generator";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { getBranding } from "@/lib/branding";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability"; import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
@@ -47,9 +51,14 @@ function Hinweis({
} }
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; const { type } = await params;
const state = await getQrTypeState(type);
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
() => undefined,
);
const state = await getQrTypeState(type, zielgruppe);
if (!state) { if (!state) {
notFound(); notFound();
@@ -60,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis <Hinweis
icon="lock" icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`} titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben." text={
state.enabledGlobally
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
}
/> />
); );
} }
@@ -75,5 +88,24 @@ export default async function CreatePage({ params }: Props) {
); );
} }
return <QrGenerator typeId={state.qrType.id} />; // Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", {
groupId: zielgruppe,
editorsMaySetSlug,
});
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
// der eigene Upload.
const { logoUploadId } = await getBranding();
return (
<QrGenerator
typeId={state.qrType.id}
maySetSlug={maySetSlug}
hasBrandingLogo={logoUploadId !== null}
/>
);
} }
+24 -4
View File
@@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password"; import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy"; import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Konto" }; export const metadata: Metadata = { title: "Konto" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,6 +15,15 @@ export const dynamic = "force-dynamic";
export default async function AccountPage() { export default async function AccountPage() {
const actor = await requirePageActor(); 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, // Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
// wie sich das umstellen lässt, statt einfach leer zu sein. // wie sich das umstellen lässt, statt einfach leer zu sein.
if (actor.kind !== "user") { if (actor.kind !== "user") {
@@ -27,11 +37,11 @@ export default async function AccountPage() {
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "} <span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
Anwendung erreicht, darf alles. Zum Einschalten{" "} Anwendung erreicht, darf alles. Zum Einschalten{" "}
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true AUTH_ENABLED=true
</code>{" "} </code>{" "}
in der <code className="font-mono text-xs">.env</code> setzen und{" "} in der <code className="font-mono text-xs">.env</code> setzen und{" "}
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d docker compose up -d
</code>{" "} </code>{" "}
ausführen. Beim nächsten Aufruf legst du den ersten Administrator ausführen. Beim nächsten Aufruf legst du den ersten Administrator
@@ -77,7 +87,7 @@ export default async function AccountPage() {
<dt className="text-slate-500">Rolle:</dt> <dt className="text-slate-500">Rolle:</dt>
<dd> <dd>
{user.isInstanceAdmin ? ( {user.isInstanceAdmin ? (
<Badge tone="popular">Administrator der Installation</Badge> <Badge tone="accent">Administrator der Installation</Badge>
) : ( ) : (
<span className="text-slate-900">Benutzer</span> <span className="text-slate-900">Benutzer</span>
)} )}
@@ -123,7 +133,17 @@ export default async function AccountPage() {
{isAuthEnabled() ? ( {isAuthEnabled() ? (
<> <>
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} /> {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 /> <SignOutCard />
</> </>
) : null} ) : null}
+44 -10
View File
@@ -2,8 +2,11 @@ import Link from "next/link";
import { LogoutButton } from "@/components/auth-forms"; import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy"; import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden. // werden.
@@ -21,24 +24,43 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
const actor = await getActor(); const actor = await getActor();
const signedIn = actor.kind !== "anonymous"; const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage"); const showAdmin = can(actor, "instance:manage");
const branding = await getBranding();
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<header className="border-b border-slate-200 bg-white"> <header className="border-b border-slate-200 bg-surface">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6"> <div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
<Link <Link
href="/" href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900" className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
> >
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white"> {branding.logoUploadId ? (
<Icon name="qr" className="size-5" /> /* eslint-disable-next-line @next/next/no-img-element -- aus der
</span> 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 {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
Navigation nicht mehr in eine Zeile. */} Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
<span className="hidden sm:inline">QR-Code-Generator</span> dieses schon den Namen. */}
{branding.logoUploadId ? null : (
<span className="hidden sm:inline">{branding.appName}</span>
)}
</Link> </Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1"> <nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
<ThemeToggle />
<span
aria-hidden="true"
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
/>
{signedIn ? ( {signedIn ? (
<> <>
<Link href="/" className={NAV_LINK}> <Link href="/" className={NAV_LINK}>
@@ -66,7 +88,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
title={`Konto von ${actor.user.email}`} title={`Konto von ${actor.user.email}`}
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100" className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
> >
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase"> <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
{actorLabel(actor).slice(0, 2)} {actorLabel(actor).slice(0, 2)}
</span> </span>
<span className="hidden truncate sm:inline"> <span className="hidden truncate sm:inline">
@@ -106,9 +128,21 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white"> <footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6"> <div className="mx-auto 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">
QR-Code-Generator · statische und dynamische QR-Codes <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> </div>
</footer> </footer>
</div> </div>
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
{kommentare.map((eintrag) => ( {kommentare.map((eintrag) => (
<li <li
key={eintrag.id} key={eintrag.id}
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm" className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
> >
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500"> <div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
<span className="font-medium text-slate-900 tabular-nums"> <span className="font-medium text-slate-900 tabular-nums">
+13 -5
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list"; import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" }; export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function MyCodesPage() { export default async function MyCodesPage() {
await requirePageActor(); const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte // Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können. // nebeneinander zeigen können.
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
// dann mit der Gruppe des betroffenen Codes.
const { editorsMaySetSlug } = await getSettings();
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -19,13 +27,13 @@ export default async function MyCodesPage() {
Meine Codes Meine Codes
</h1> </h1>
<p className="mt-2 text-slate-600"> <p className="mt-2 text-slate-600">
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
Scans. Statische Codes erscheinen hier nicht — sie werden nicht dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
gespeichert. lässt sich die Liste auf eine der beiden Arten einschränken.
</p> </p>
</div> </div>
<CodesList /> <CodesList maySetSlug={maySetSlug} />
</div> </div>
); );
} }
+15 -9
View File
@@ -3,15 +3,16 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui"; import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability"; import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit // Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
// änderbar — die Startseite darf deshalb nicht vorgerendert werden. // änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
await requirePageActor(); const actor = await requirePageActor();
const types = await getVisibleQrTypes(); // Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length; const usableCount = types.filter((entry) => entry.usable).length;
return ( return (
@@ -50,7 +51,7 @@ export default async function HomePage() {
<span <span
className={ className={
usable usable
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600" ? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400" : "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
} }
> >
@@ -67,11 +68,16 @@ export default async function HomePage() {
> >
{qrType.title} {qrType.title}
</span> </span>
{usable ? ( {/* Kein Abzeichen „Beliebt“ mehr: Es stand an jedem
<Badge tone="popular">Beliebt</Badge> 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> <Badge tone="soon">Bald verfügbar</Badge>
)} ) : !qrType.supportsStatic ? (
<Badge tone="neutral">nur dynamisch</Badge>
) : null}
</span> </span>
<span <span
className={ className={
@@ -91,7 +97,7 @@ export default async function HomePage() {
{usable ? ( {usable ? (
<Link <Link
href={`/erstellen/${qrType.id}`} href={`/erstellen/${qrType.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md" className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
> >
{tile} {tile}
</Link> </Link>
+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,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>
);
}
+40 -10
View File
@@ -5,6 +5,8 @@ import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui"; import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" }; export const metadata: Metadata = { title: "Verwaltung" };
@@ -26,9 +28,9 @@ function Tile({
return ( return (
<Link <Link
href={href} href={href}
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md" className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
> >
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"> <span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
<Icon name={icon} className="size-6" /> <Icon name={icon} className="size-6" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -45,12 +47,22 @@ function Tile({
export default async function AdminPage() { export default async function AdminPage() {
await requirePagePermission("instance:manage"); await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ const [userCount, groupCount, codeCount, domainCount, branding, settings] =
prisma.user.count(), await Promise.all([
prisma.group.count(), prisma.user.count(),
prisma.qrCode.count(), prisma.group.count(),
prisma.domain.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 ( return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
@@ -68,11 +80,11 @@ export default async function AdminPage() {
<Notice tone="info"> <Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "} <span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs"> <code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true AUTH_ENABLED=true
</code> </code>
in der <code className="font-mono text-xs">.env</code> setzen und in der <code className="font-mono text-xs">.env</code> setzen und
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs"> <code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d docker compose up -d
</code> </code>
ausführen. Beim nächsten Aufruf legst du den ersten Administrator ausführen. Beim nächsten Aufruf legst du den ersten Administrator
@@ -110,6 +122,24 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen." description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt" hint="15 Typen insgesamt"
/> />
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
title="Erscheinungsbild"
description="Name, Logo, Favicon und Farbe der Oberfläche."
hint={brandingHint}
/>
</div> </div>
</div> </div>
); );
+15 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings"; import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { getQrTypeAvailability } from "@/lib/qr/availability"; import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" }; export const metadata: Metadata = { title: "QR-Typen" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability(); const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map( const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({ ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id, id: qrType.id,
title: qrType.title, title: qrType.title,
description: qrType.description, description: qrType.description,
icon: qrType.icon, icon: qrType.icon,
implemented, implemented,
enabled, enabled,
enabledGlobally,
disabledInGroup,
}), }),
); );
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link <Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen. anlegen.
</p> </p>
<p className="mt-2 text-slate-600">
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
abgeschaltet.
</p>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p> </p>
</div> </div>
<TypeSettings initial={initial} /> <TypeSettings initial={initial} groups={groups} />
</div> </div>
); );
} }
+96
View File
@@ -0,0 +1,96 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { isHexColor } from "@/lib/branding-colors";
import {
getBranding,
MAX_APP_NAME,
MAX_FOOTER_TEXT,
saveBranding,
} from "@/lib/branding";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */
const bildSchema = z.string().trim().min(1).max(64).nullable().optional();
const updateSchema = z.object({
appName: z
.string()
.trim()
.min(1, { error: "Bitte gib einen Namen an." })
.max(MAX_APP_NAME, {
error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`,
})
.optional(),
footerText: z
.string()
.trim()
.max(MAX_FOOTER_TEXT, {
error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`,
})
.optional(),
accentColor: z
.string()
.trim()
.refine(isHexColor, {
error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
})
.optional(),
logoUploadId: bildSchema,
faviconUploadId: bildSchema,
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ branding: await getBranding() });
} catch (error) {
return errorResponse(error);
}
}
/**
* Prüft, dass eine angegebene Datei existiert und wirklich zum
* Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden
* QR-Codes einhängen und damit dessen Anzeige zerstören.
*/
async function assertBrandingUpload(
id: string | null | undefined,
feld: string,
): Promise<void> {
if (!id) return;
const upload = await prisma.upload.findUnique({
where: { id },
select: { purpose: true },
});
if (!upload || upload.purpose !== "branding") {
throw new QrRequestError(
`${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`,
404,
);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo");
await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon");
return Response.json({ branding: await saveBranding(parsed.data) });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,43 @@
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { pruefeDomain } from "@/lib/domain-check";
import { errorResponse } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
* dahinter wirklich diese Installation steht.
*
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
*
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
* Server aus beliebige Adressen abklopfen lassen.
*/
export async function POST(
_request: Request,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
const domain = await prisma.domain.findUnique({
where: { id },
select: { hostname: true },
});
if (!domain) {
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
}
return Response.json({ report: await pruefeDomain(domain.hostname) });
} catch (error) {
return errorResponse(error);
}
}
+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);
}
}
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor"; import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import { import {
getQrTypeAvailability, getQrTypeAvailability,
setQrTypeEnabled, setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability"; } from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module"; import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
const updateSchema = z.object({ const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }), enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
/**
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
* für die ganze Installation.
*/
groupId: z.string().trim().min(1).optional(),
}); });
/** Zustand aller QR-Typen für die Verwaltung. */ /**
export async function GET(): Promise<Response> { * Zustand aller QR-Typen für die Verwaltung.
*
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
* Grundeinstellung.
*/
export async function GET(request: NextRequest): Promise<Response> {
try { try {
requirePermission(await getActor(), "instance:manage"); requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
if (groupId !== undefined) {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
}
const types = await getQrTypeAvailability(groupId);
return Response.json({ return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({ groupId: groupId ?? null,
id: qrType.id, types: types.map(
title: qrType.title, ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
description: qrType.description, id: qrType.id,
icon: qrType.icon, title: qrType.title,
implemented, description: qrType.description,
enabled, icon: qrType.icon,
usable, implemented,
})), enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
}); });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error)); return jsonError(firstZodMessage(parsed.error));
} }
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled); const { type, enabled, groupId } = parsed.data;
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
if (groupId === undefined) {
await setQrTypeEnabled(type, enabled);
} else {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
await setQrTypeEnabledForGroup(type, groupId, enabled);
}
return Response.json({ type, enabled, groupId: groupId ?? null });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }
+4
View File
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
import { import {
describePasswordRules, describePasswordRules,
hashPassword, hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password"; } from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
@@ -26,6 +27,9 @@ const updateSchema = z.object({
.min(MIN_PASSWORD_LENGTH, { .min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}) })
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
})
.optional(), .optional(),
/** Sperre nach Fehlversuchen aufheben. */ /** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(), unlock: z.boolean().optional(),
+6 -2
View File
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
import { import {
describePasswordRules, describePasswordRules,
hashPassword, hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password"; } from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
@@ -25,8 +26,11 @@ const createSchema = z.object({
error: "Das ist keine gültige E-Mail-Adresse.", error: "Das ist keine gültige E-Mail-Adresse.",
}), }),
name: z.string().trim().max(120).optional(), name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, { password: z
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, .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), isInstanceAdmin: z.boolean().default(false),
}); });
+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);
}
}
+114 -27
View File
@@ -5,7 +5,9 @@ import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import { import {
describePasswordRules, describePasswordRules,
hashPassword, hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
needsRehash,
verifyPassword, verifyPassword,
} from "@/lib/auth/password"; } from "@/lib/auth/password";
import { import {
@@ -13,6 +15,11 @@ import {
destroyAllSessions, destroyAllSessions,
destroySession, destroySession,
} from "@/lib/auth/session"; } from "@/lib/auth/session";
import {
clearAttempts,
requestOrigin,
tooManyAttempts,
} from "@/lib/auth/throttle";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
@@ -23,6 +30,13 @@ export const dynamic = "force-dynamic";
const MAX_ATTEMPTS = 8; const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15; 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 const emailSchema = z
.string() .string()
.trim() .trim()
@@ -36,28 +50,42 @@ const emailSchema = z
const loginSchema = z.object({ const loginSchema = z.object({
action: z.literal("login"), action: z.literal("login"),
email: emailSchema, email: emailSchema,
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }), 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({ const setupSchema = z.object({
action: z.literal("setup"), action: z.literal("setup"),
email: emailSchema, email: emailSchema,
name: z.string().trim().max(120).optional(), name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, { password: z
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, .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 logoutSchema = z.object({ action: z.literal("logout") });
const changePasswordSchema = z.object({ const changePasswordSchema = z.object({
action: z.literal("change-password"), action: z.literal("change-password"),
currentPassword: z.string().min(1, { currentPassword: z
error: "Bitte gib dein bisheriges Passwort ein.", .string()
}), .min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
newPassword: z.string().min(MIN_PASSWORD_LENGTH, { .max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`, 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", [ const bodySchema = z.discriminatedUnion("action", [
@@ -117,7 +145,7 @@ export async function POST(request: NextRequest): Promise<Response> {
if (parsed.data.action === "setup") { if (parsed.data.action === "setup") {
return await handleSetup(parsed.data); return await handleSetup(parsed.data);
} }
return await handleLogin(parsed.data); return await handleLogin(parsed.data, requestOrigin(request.headers));
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }
@@ -140,6 +168,15 @@ async function handleChangePassword(
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404); 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))) { if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403); throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
} }
@@ -179,14 +216,31 @@ async function handleSetup(
); );
} }
const user = await prisma.user.create({ // Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
data: { const passwordHash = await hashPassword(input.password);
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null, // Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
passwordHash: await hashPassword(input.password), // dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
isInstanceAdmin: true, // 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); await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 }); return Response.json({ ok: true, email: user.email }, { status: 201 });
@@ -194,7 +248,18 @@ async function handleSetup(
async function handleLogin( async function handleLogin(
input: z.infer<typeof loginSchema>, input: z.infer<typeof loginSchema>,
absender: string,
): Promise<Response> { ): 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 } }); const user = await prisma.user.findUnique({ where: { email: input.email } });
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich // Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
@@ -206,17 +271,10 @@ async function handleLogin(
if (!user) { if (!user) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät. // Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA"); await verifyPassword(input.password, DUMMY_HASH);
throw invalid; throw invalid;
} }
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil( const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000, (user.lockedUntil.getTime() - Date.now()) / 60000,
@@ -227,6 +285,14 @@ async function handleLogin(
); );
} }
// 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))) { if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1; const failedLogins = user.failedLogins + 1;
await prisma.user.update({ await prisma.user.update({
@@ -242,11 +308,32 @@ async function handleLogin(
throw invalid; 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({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() }, data: {
failedLogins: 0,
lockedUntil: null,
lastLoginAt: new Date(),
...(passwordHash ? { passwordHash } : {}),
},
}); });
clearAttempts(`login:${absender}`);
await createSession(user.id); await createSession(user.id);
return Response.json({ ok: true, email: user.email }); return Response.json({ ok: true, email: user.email });
} }
+14 -3
View File
@@ -4,9 +4,11 @@ import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env"; import { shortUrl } from "@/lib/env";
import { requireImplementedQrType } from "@/lib/qr/availability";
import { errorResponse } from "@/lib/http"; import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request"; import { downloadFilename, QrRequestError } from "@/lib/qr/request";
@@ -47,13 +49,22 @@ export async function GET(
size: code.size, size: code.size,
ecLevel: code.ecLevel, ecLevel: code.ecLevel,
margin: code.margin, margin: code.margin,
logoSource: code.logoSource,
logoUploadId: code.logoUploadId,
logoScale: code.logoScale,
}); });
const logo = await ladeLogo(design);
const content = shortUrl(code.slug, code.domain.hostname); // Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
const content =
code.mode === "static" || !code.slug
? requireImplementedQrType(code.type).encode(code.data)
: shortUrl(code.slug, code.domain.hostname);
const body = const body =
format === "png" format === "png"
? new Uint8Array(await renderPng(content, design)) ? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design); : await renderSvg(content, design, logo);
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format], "Content-Type": CONTENT_TYPES[format],
+133 -7
View File
@@ -10,7 +10,10 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability"; import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { attachUploads } from "@/lib/uploads"; import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const; } as const;
const updateSchema = z.object({ const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable() .nullable()
.optional(), .optional(),
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
slug: z.string().trim().max(120).optional(),
/**
* Welche früheren Adressen weiterhin gelten sollen.
*
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
* freigegeben — und ist damit sofort tot.
*/
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
}); });
async function loadCode(id: string) { async function loadCode(id: string) {
@@ -81,6 +96,15 @@ export async function PATCH(
/** Dateien, die nach der Änderung zu diesem Code gehören. */ /** Dateien, die nach der Änderung zu diesem Code gehören. */
let neueDateien: string[] | null = null; 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) { if (parsed.data.data !== undefined) {
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht // Verwaltung abgeschalteter Typ soll bestehende Codes nicht
@@ -109,20 +133,122 @@ export async function PATCH(
Object.assign(update, parsed.data.design); Object.assign(update, parsed.data.design);
} }
if (Object.keys(update).length === 0) { // Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
const logoVorher =
existing.logoSource === "upload" ? existing.logoUploadId : null;
const logoNachher =
parsed.data.design === undefined
? logoVorher
: parsed.data.design.logoSource === "upload"
? parsed.data.design.logoUploadId
: null;
/* ------------------------------------------------------- Kurz-Link */
const neuerSlug = parsed.data.slug?.trim();
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
// selbst. Ein Umbenennen waere gegenstandslos.
if (existing.mode === "static" && neuerSlug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
const umbenannt =
neuerSlug !== undefined &&
existing.slug !== null &&
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
/**
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
* es ist; steht es da, beschreibt es den gewünschten Zustand.
*/
const behalten =
parsed.data.aliases === undefined
? null
: new Set(parsed.data.aliases.map(normalizeSlug));
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
where: { qrCodeId: existing.id },
select: { id: true, slug: true },
});
const freigeben = behalten
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
: [];
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
// mit — unverändert soll das kein zusätzliches Recht verlangen.
if (umbenannt || freigeben.length > 0) {
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", {
groupId: existing.groupId,
editorsMaySetSlug,
});
}
if (umbenannt) {
const problem = describeSlugProblem(neuerSlug);
if (problem) return jsonError(problem);
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
update.slug = normalizeSlug(neuerSlug);
}
if (
Object.keys(update).length === 0 &&
!umbenannt &&
freigeben.length === 0
) {
return jsonError("Es wurden keine Änderungen übermittelt."); return jsonError("Es wurden keine Änderungen übermittelt.");
} }
const updated = await prisma.qrCode.update({ // Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
where: { id }, // gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
data: update, // ist, oder eine freigegebene Adresse bliebe hängen.
include: WITH_RELATIONS, 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 // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
// später als verwaist weggeräumt. // später als verwaist weggeräumt.
if (neueDateien !== null) { if (neueDateien !== null) {
await attachUploads(updated.id, neueDateien); await attachUploads(
updated.id,
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
);
} else if (logoNachher && logoNachher !== logoVorher) {
await attachUpload(updated.id, logoNachher);
}
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
// später weggeräumt.
if (logoVorher && logoVorher !== logoNachher) {
await detachUpload(updated.id, logoVorher);
} }
return Response.json({ code: toQrCodeDto(updated) }); return Response.json({ code: toQrCodeDto(updated) });
+127 -6
View File
@@ -7,12 +7,15 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { resolveTargetDomain } from "@/lib/domains"; import { resolveTargetDomain } from "@/lib/domains";
import { findStaticDuplicate } from "@/lib/duplicates";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability"; import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug"; import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
import { assertSlugAvailable } from "@/lib/slug-resolve";
import { getSettings } from "@/lib/settings";
import { attachUploads } from "@/lib/uploads"; import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,12 +29,47 @@ const createSchema = z.object({
.trim() .trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(), .optional(),
/**
* 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. */ /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(), groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */ /** Unter welcher Domain die Kurz-URL entsteht. */
domainId: z.string().trim().min(1).optional(), domainId: z.string().trim().min(1).optional(),
/**
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
* statischer Code in der Gruppe liegt.
*/
allowDuplicate: z.boolean().default(false),
}); });
/**
* Alle Dateien, die zu einem Code gehören.
*
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
*/
function dateienDesCodes(
qrType: { uploadIds: (data: never) => string[] },
data: unknown,
design: { logoSource: string | null; logoUploadId: string | null },
): string[] {
const ids = qrType.uploadIds(data as never);
return design.logoSource === "upload" && design.logoUploadId
? [...ids, design.logoUploadId]
: ids;
}
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
return ( return (
typeof error === "object" && typeof error === "object" &&
@@ -57,6 +95,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
@@ -86,22 +125,95 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig. // ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
const qrType = await requireAvailableQrType(type); // Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
const qrType = await requireAvailableQrType(type, groupId);
const validated = qrType.validate(parsed.data.data); const validated = qrType.validate(parsed.data.data);
if (!validated.ok) { if (!validated.ok) {
return jsonError(validated.message); return jsonError(validated.message);
} }
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. const istStatisch = parsed.data.mode === "static";
assertDynamicPossible(qrType, validated.data);
if (istStatisch) {
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
// können, sonst gäbe es nichts zu kodieren.
if (!qrType.supportsStatic) {
return jsonError(
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
);
}
if (parsed.data.slug) {
return jsonError(
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
);
}
} else {
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data);
}
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
const gewuenscht = parsed.data.slug?.trim();
if (gewuenscht) {
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
const { editorsMaySetSlug } = await getSettings();
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
const problem = describeSlugProblem(gewuenscht);
if (problem) return jsonError(problem);
await assertSlugAvailable(domainId, gewuenscht);
}
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
// eines stillen zweiten Eintrags.
if (istStatisch && !parsed.data.allowDuplicate) {
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
if (dublette) {
return Response.json(
{
error: dublette.label
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
: "Diesen Inhalt hast du schon abgelegt.",
duplicate: dublette,
},
{ status: 409 },
);
}
}
if (istStatisch) {
const created = await prisma.qrCode.create({
data: {
mode: "static",
slug: null,
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
domainId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const created = await prisma.qrCode.create({ const created = await prisma.qrCode.create({
data: { data: {
slug: generateSlug(), slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id, type: qrType.id,
label: label && label.length > 0 ? label : null, label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data), data: toJsonInput(validated.data),
@@ -114,14 +226,23 @@ export async function POST(request: NextRequest): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum. // vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data)); await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) { } catch (error) {
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
// käme immer wieder auf dieselbe Adresse.
if (gewuenscht && isUniqueViolation(error)) {
return jsonError(
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
409,
);
}
if (!isUniqueViolation(error)) throw error; if (!isUniqueViolation(error)) throw error;
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
export const dynamic = "force-dynamic";
/**
* Beweis-Route für den Domain-Check.
*
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
* nichts über das Geheimnis.
*
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
*/
export async function GET(request: NextRequest): Promise<Response> {
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
return Response.json(
{ error: "Es fehlt eine gültige Zufallszahl." },
{ status: 400 },
);
}
try {
const proof = bildeBeweis(await getCheckSecret(), nonce);
return Response.json(
{ app: "qrcode-generator", proof },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
console.error("Domain-Check konnte keinen Beweis bilden:", error);
return Response.json({ error: "Nicht möglich." }, { status: 500 });
}
}
+5 -3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { import {
downloadFilename, downloadFilename,
@@ -38,15 +39,16 @@ export async function POST(request: NextRequest): Promise<Response> {
const { qrType, data, content } = await resolveQrRequest(parsed.data); const { qrType, data, content } = await resolveQrRequest(parsed.data);
const logo = await ladeLogo(design);
const body = const body =
format === "png" format === "png"
? new Uint8Array(await renderPng(content, design)) ? new Uint8Array(await renderPng(content, design, logo))
: await renderSvg(content, design); : await renderSvg(content, design, logo);
return new Response(body, { return new Response(body, {
headers: { headers: {
"Content-Type": CONTENT_TYPES[format], "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", "Cache-Control": "no-store",
}, },
}); });
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { ladeLogo } from "@/lib/qr/logo";
import { renderSvg } from "@/lib/qr/render"; import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request"; import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
} }
const { content } = await resolveQrRequest(parsed.data); const { content } = await resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design); const logo = await ladeLogo(parsed.data.design);
const svg = await renderSvg(content, parsed.data.design, logo);
return Response.json({ return Response.json({
content, content,
+2 -1
View File
@@ -40,7 +40,8 @@ export async function GET(
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse // Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
// wie die Anwendung könnte es damit auf angemeldete Sitzungen // wie die Anwendung könnte es damit auf angemeldete Sitzungen
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit. // zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
"Content-Security-Policy": "sandbox; default-src 'none'", "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)}`, "Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei. // Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
"Cache-Control": "public, max-age=31536000, immutable", "Cache-Control": "public, max-age=31536000, immutable",
+15 -5
View File
@@ -3,12 +3,13 @@ import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor"; import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups"; import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, jsonError } from "@/lib/http"; import { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
import { import {
cleanupOrphanedUploads, cleanupOrphanedUploads,
isUploadKind, isUploadKind,
maxUploadBytes, maxUploadBytes,
storeUpload, storeUpload,
uploadPurposeOf,
} from "@/lib/uploads"; } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,11 +23,11 @@ export const dynamic = "force-dynamic";
*/ */
export async function POST(request: NextRequest): Promise<Response> { export async function POST(request: NextRequest): Promise<Response> {
try { try {
const actor = await getActor(); // Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
// die Herkunft deshalb selbst.
assertSameOrigin(request.headers);
// Wer keinen Code anlegen darf, soll auch nichts ablegen können. const actor = await getActor();
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
let form: FormData; let form: FormData;
try { try {
@@ -40,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
return jsonError("Unbekannte Dateiart."); return jsonError("Unbekannte Dateiart.");
} }
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
if (uploadPurposeOf(kind) === "branding") {
requirePermission(actor, "instance:manage");
} else {
const groupId = await resolveTargetGroup(actor, undefined);
requirePermission(actor, "codes:create", { groupId });
}
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
return jsonError("Es wurde keine Datei mitgeschickt."); return jsonError("Es wurde keine Datei mitgeschickt.");
+72 -1
View File
@@ -9,10 +9,81 @@
monospace; monospace;
} }
/* ---------------------------------------------------------------- Dark Mode
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
entscheidet sich hier.
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
demselben leicht blauen Ton wie die helle Reihe.
*/
@theme {
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
--color-accent-text: light-dark(
var(--color-indigo-600),
var(--color-indigo-400)
);
--color-accent-soft: light-dark(
var(--color-indigo-50),
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
);
--color-accent-soft-fg: light-dark(
var(--color-indigo-700),
var(--color-indigo-300)
);
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
der Grund einer Videovorschau. */
--color-scrim: oklch(20.8% 0.042 265.755);
}
:root { :root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light; color-scheme: light;
} }
:root[data-theme="dark"] {
color-scheme: dark;
}
body { body {
background-color: var(--color-slate-50); background-color: var(--color-slate-50);
color: var(--color-slate-900); color: var(--color-slate-900);
@@ -22,7 +93,7 @@ body {
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */ /* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
:focus-visible { :focus-visible {
outline: 2px solid var(--color-indigo-600); outline: 2px solid var(--color-accent-text);
outline-offset: 2px; outline-offset: 2px;
} }
+56 -11
View File
@@ -1,28 +1,73 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
import { accentCss } from "@/lib/branding-colors";
import { getBranding } from "@/lib/branding";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { // Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
title: { // vorgerendert werden.
default: "QR-Code-Generator", export const dynamic = "force-dynamic";
template: "%s · QR-Code-Generator",
}, export async function generateMetadata(): Promise<Metadata> {
description: const branding = await getBranding();
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
}; return {
title: {
default: branding.appName,
template: `%s · ${branding.appName}`,
},
description:
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
...(branding.faviconUploadId
? {
icons: {
icon: {
url: `/api/uploads/${branding.faviconUploadId}`,
type: branding.faviconMimeType ?? undefined,
},
},
}
: {}),
};
}
/** /**
* Wurzel-Layout — bewusst nur `html` und `body`. * Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
* *
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
* „Meine Codes“ nichts zu suchen. * „Meine Codes“ nichts zu suchen.
*
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
*/ */
export default function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const branding = await getBranding();
return ( return (
<html lang="de" className="h-full"> <html lang="de" className="h-full">
<body className="flex min-h-full flex-col">{children}</body> <body className="flex min-h-full flex-col">
{/*
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
`light-dark()` im CSS von allein.
*/}
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
{/*
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
die Voreinstellung zurück.
*/}
<style
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
/>
{children}
</body>
</html> </html>
); );
} }
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> }; // Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
// Sammelroute: Next liefert die Abschnitte dann als Liste.
type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response { function notFound(message: string): Response {
return new Response( return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest, request: NextRequest,
context: Context, context: Context,
): Promise<Response> { ): Promise<Response> {
const { slug } = await context.params; const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) { if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig."); return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
); );
} }
const code = await prisma.qrCode.findUnique({ const gefunden = await findCodeBySlug(domain.id, slug);
where: { domainId_slug: { domainId: domain.id, slug } }, const code = gefunden?.code;
});
if (!code) { if (!code) {
return notFound( return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ, // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN). // wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`; target = `/s/${code.slug}`;
} else { } else {
try { try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing"; import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell"; import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> }; // Sammelroute, weil ein Kurz-Link „/" enthalten darf.
type PageProps = { params: Promise<{ slug: string[] }> };
/** /**
* Öffentliche Landingpage eines dynamischen Codes. * Öffentliche Landingpage eines dynamischen Codes.
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`. * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. * Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/ */
async function loadCode(slug: string) { async function loadCode(abschnitte: string[] | undefined) {
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null; if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers()); const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null; if (!domain.isActive) return null;
return prisma.qrCode.findUnique({ return (await findCodeBySlug(domain.id, slug))?.code ?? null;
where: { domainId_slug: { domainId: domain.id, slug } },
});
} }
export async function generateMetadata({ export async function generateMetadata({
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
); );
} }
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
if (!code.slug) {
return (
<LandingNotice title="Diese Seite gibt es nicht">
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
der ihn erstellt hat.
</LandingNotice>
);
}
return ( return (
<Landing <Landing
data={code.data} data={code.data}
+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>
);
}
+308 -83
View File
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module"; import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", { const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -42,6 +43,8 @@ function formatShortDate(value: string | null): string {
type View = "list" | "cards"; type View = "list" | "cards";
type SortKey = "neueste" | "aelteste" | "name" | "scans"; type SortKey = "neueste" | "aelteste" | "name" | "scans";
/** Betriebsart, auf die die Liste eingeschränkt ist. */
type ModeFilter = "alle" | "dynamic" | "static";
const SORT_LABELS: Record<SortKey, string> = { const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst", neueste: "Neueste zuerst",
@@ -50,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
scans: "Meiste Scans", scans: "Meiste Scans",
}; };
const MODE_LABELS: Record<ModeFilter, string> = {
alle: "Alle Arten",
dynamic: "Nur dynamische",
static: "Nur statische",
};
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */ /** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
const VIEW_STORAGE_KEY = "qrgen.codes.view"; const VIEW_STORAGE_KEY = "qrgen.codes.view";
@@ -65,11 +74,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary; return code.label?.trim() || code.summary;
} }
export function CodesList() { export function CodesList({
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
maySetSlug = true,
}: {
maySetSlug?: boolean;
}) {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null); const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null); const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({}); const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
@@ -85,6 +102,7 @@ export function CodesList() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste"); const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle"); const [groupFilter, setGroupFilter] = useState("alle");
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen // Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern. // localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
@@ -98,6 +116,14 @@ export function CodesList() {
window.localStorage.setItem(VIEW_STORAGE_KEY, next); window.localStorage.setItem(VIEW_STORAGE_KEY, next);
}; };
const changeModeFilter = (next: ModeFilter) => {
setModeFilter(next);
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
// eine Reihenfolge an, die es dort gar nicht gibt.
if (next === "static" && sort === "scans") setSort("neueste");
};
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes"); const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
@@ -124,9 +150,21 @@ export function CodesList() {
setEditing(code); setEditing(code);
setEditData(code.data); setEditData(code.data);
setEditLabel(code.label ?? ""); setEditLabel(code.label ?? "");
setEditSlug(code.slug ?? "");
setEditAliases(code.aliases);
setEditError(null); setEditError(null);
}; };
const slugProblem =
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
const slugGeaendert =
editing !== null &&
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 EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo( const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null), () => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +177,29 @@ export function CodesList() {
setEditError(editValidation.message); setEditError(editValidation.message);
return; return;
} }
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true); setSaving(true);
setEditError(null); setEditError(null);
try { try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, { await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH", method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null }, json: {
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); setEditing(null);
await load(); await load();
@@ -179,6 +234,7 @@ export function CodesList() {
}; };
const copy = async (code: QrCodeDto) => { const copy = async (code: QrCodeDto) => {
if (!code.shortUrl) return;
try { try {
await navigator.clipboard.writeText(code.shortUrl); await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id); setCopiedId(code.id);
@@ -202,11 +258,25 @@ export function CodesList() {
); );
}, [codes]); }, [codes]);
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
// Arten in der Liste stehen.
const kinds = useMemo(() => {
let dynamisch = false;
let statisch = false;
for (const code of codes ?? []) {
if (code.mode === "static") statisch = true;
else dynamisch = true;
}
return { dynamisch, statisch, beide: dynamisch && statisch };
}, [codes]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => { const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false; if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
if (!needle) return true; if (!needle) return true;
return [ return [
displayName(code), displayName(code),
@@ -234,7 +304,7 @@ export function CodesList() {
} }
}); });
return sorted; return sorted;
}, [codes, query, sort, groupFilter]); }, [codes, query, sort, groupFilter, modeFilter]);
/* ---------------------------------------------------------------- View */ /* ---------------------------------------------------------------- View */
@@ -248,6 +318,10 @@ export function CodesList() {
const svgHref = (code: QrCodeDto) => const svgHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=svg&download=1`; `/api/codes/${code.id}/qr?format=svg&download=1`;
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
const toolbar = codes.length > 0 && ( const toolbar = codes.length > 0 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56"> <div className="relative min-w-0 flex-1 basis-56">
@@ -281,23 +355,42 @@ export function CodesList() {
</Select> </Select>
) : null} ) : null}
{kinds.beide ? (
<Select
aria-label="Nach Art filtern"
className="!w-auto"
value={modeFilter}
onChange={(event) =>
changeModeFilter(event.target.value as ModeFilter)
}
>
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
<option key={key} value={key}>
{MODE_LABELS[key]}
</option>
))}
</Select>
) : null}
<Select <Select
aria-label="Sortierung" aria-label="Sortierung"
className="!w-auto" className="!w-auto"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
> >
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => ( {(Object.keys(SORT_LABELS) as SortKey[])
<option key={key} value={key}> .filter((key) => key !== "scans" || !nurStatische)
{SORT_LABELS[key]} .map((key) => (
</option> <option key={key} value={key}>
))} {SORT_LABELS[key]}
</option>
))}
</Select> </Select>
<div <div
role="group" role="group"
aria-label="Ansicht" aria-label="Ansicht"
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5" className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
> >
{( {(
[ [
@@ -316,7 +409,7 @@ export function CodesList() {
className={cx( className={cx(
"rounded-md p-2 transition-colors", "rounded-md p-2 transition-colors",
view === value view === value
? "bg-indigo-50 text-indigo-700" ? "bg-accent-soft text-accent-soft-fg"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700", : "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)} )}
> >
@@ -367,14 +460,15 @@ export function CodesList() {
{codes.length > 0 && visible.length === 0 ? ( {codes.length > 0 && visible.length === 0 ? (
<Card className="text-center"> <Card className="text-center">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "} Kein Code passt zu Suche und Filtern.{" "}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setQuery(""); setQuery("");
setGroupFilter("alle"); setGroupFilter("alle");
setModeFilter("alle");
}} }}
className="font-medium text-indigo-600 underline" className="font-medium text-accent-text underline"
> >
Filter zurücksetzen Filter zurücksetzen
</button> </button>
@@ -384,7 +478,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Listenansicht */} {/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? ( {view === "list" && visible.length > 0 ? (
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links {/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt, aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */} steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
@@ -407,9 +501,11 @@ export function CodesList() {
Gruppe Gruppe
</th> </th>
) : null} ) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20"> {nurStatische ? null : (
Scans <th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
</th> Scans
</th>
)}
<th <th
scope="col" scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell" className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
@@ -449,9 +545,18 @@ export function CodesList() {
<div className="truncate text-xs text-slate-500"> <div className="truncate text-xs text-slate-500">
{/* Nur der Pfad: Der Hostname ist auf dem Handy {/* Nur der Pfad: Der Hostname ist auf dem Handy
gleich für alle Zeilen, der Slug unterscheidet. */} gleich für alle Zeilen, der Slug unterscheidet. */}
<span className="font-mono md:hidden" title={code.shortUrl}> {code.shortUrl ? (
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")} <span
</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"> <span className="hidden md:inline">
{qrTypes[code.type]?.title ?? code.type} {qrTypes[code.type]?.title ?? code.type}
</span> </span>
@@ -461,28 +566,34 @@ export function CodesList() {
</td> </td>
<td className="hidden px-3 py-2.5 md:table-cell"> <td className="hidden px-3 py-2.5 md:table-cell">
<div className="flex items-center gap-1.5"> {code.shortUrl ? (
<a <div className="flex items-center gap-1.5">
href={code.shortUrl} <a
target="_blank" href={code.shortUrl}
rel="noreferrer" target="_blank"
className="truncate font-mono text-xs text-indigo-600 hover:underline" rel="noreferrer"
title={code.shortUrl} className="truncate font-mono text-xs text-accent-text hover:underline"
> title={code.shortUrl}
{code.shortUrl.replace(/^https?:\/\//, "")} >
</a> {code.shortUrl.replace(/^https?:\/\//, "")}
<button </a>
type="button" <button
onClick={() => void copy(code)} type="button"
title="Kurz-URL kopieren" onClick={() => void copy(code)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" 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"} <Icon
className="size-3.5" name={copiedId === code.id ? "check" : "copy"}
/> className="size-3.5"
</button> />
</div> </button>
</div>
) : (
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
// steckt in ihm selbst.
<Badge tone="neutral">statisch</Badge>
)}
</td> </td>
<td className="hidden px-3 py-2.5 sm:table-cell"> <td className="hidden px-3 py-2.5 sm:table-cell">
@@ -497,7 +608,13 @@ export function CodesList() {
{code.target.replace(/^https?:\/\//, "")} {code.target.replace(/^https?:\/\//, "")}
</a> </a>
) : ( ) : (
<span className="block truncate text-red-600" title={code.summary}> <span
className={cx(
"block truncate",
code.mode === "static" ? "text-slate-600" : "text-red-600",
)}
title={code.summary}
>
{code.summary} {code.summary}
</span> </span>
)} )}
@@ -511,9 +628,20 @@ export function CodesList() {
</td> </td>
) : null} ) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums"> {nurStatische ? null : (
{code.scans} <td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
</td> {code.mode === "static" ? (
<span
className="font-normal text-slate-400"
title="Statische Codes werden nicht gezählt"
>
—
</span>
) : (
code.scans
)}
</td>
)}
<td <td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell" className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
@@ -539,8 +667,8 @@ export function CodesList() {
<button <button
type="button" type="button"
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
title="Ziel bearbeiten" title="Bearbeiten"
aria-label={`Ziel von ${displayName(code)} bearbeiten`} aria-label={`${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
> >
<Icon name="pencil" className="size-4" /> <Icon name="pencil" className="size-4" />
@@ -610,30 +738,41 @@ export function CodesList() {
</div> </div>
<dl className="mt-3 space-y-2 text-sm"> <dl className="mt-3 space-y-2 text-sm">
<div className="flex flex-wrap items-baseline gap-x-2"> {code.shortUrl ? (
<dt className="text-slate-500">Kurz-URL:</dt> <div className="flex flex-wrap items-baseline gap-x-2">
<dd className="flex items-center gap-2"> <dt className="text-slate-500">Kurz-URL:</dt>
<a <dd className="flex items-center gap-2">
href={code.shortUrl} <a
target="_blank" href={code.shortUrl}
rel="noreferrer" target="_blank"
className="font-mono text-xs break-all text-indigo-600 hover:underline" rel="noreferrer"
> className="font-mono text-xs break-all text-accent-text hover:underline"
{code.shortUrl} >
</a> {code.shortUrl}
<button </a>
type="button" <button
onClick={() => void copy(code)} type="button"
title="Kurz-URL kopieren" onClick={() => void copy(code)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700" 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"} <Icon
className="size-4" name={copiedId === code.id ? "check" : "copy"}
/> className="size-4"
</button> />
</dd> </button>
</div> </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"> <div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Ziel:</dt> <dt className="text-slate-500">Ziel:</dt>
@@ -670,7 +809,7 @@ export function CodesList() {
{code.type === "feedback" ? ( {code.type === "feedback" ? (
<Link <Link
href={`/meine-codes/${code.id}/feedback`} href={`/meine-codes/${code.id}/feedback`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="star" className="size-3.5" /> <Icon name="star" className="size-3.5" />
Rückmeldungen Rückmeldungen
@@ -683,18 +822,18 @@ export function CodesList() {
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
> >
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Ziel bearbeiten Bearbeiten
</Button> </Button>
<a <a
href={`/api/codes/${code.id}/qr?format=png&download=1`} href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="download" className="size-3.5" /> <Icon name="download" className="size-3.5" />
PNG PNG
</a> </a>
<a <a
href={`/api/codes/${code.id}/qr?format=svg&download=1`} href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="download" className="size-3.5" /> <Icon name="download" className="size-3.5" />
SVG SVG
@@ -721,7 +860,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */} {/* ------------------------------------------------------ Bearbeiten */}
<Modal <Modal
open={editing !== null} open={editing !== null}
title="Ziel bearbeiten" title="Code bearbeiten"
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
footer={ footer={
<> <>
@@ -742,15 +881,12 @@ export function CodesList() {
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin {editing.mode === "static"
auf{" "} ? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
<code className="font-mono text-xs break-all"> : "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
{editing.shortUrl}
</code>
.
</p> </p>
{EditForm ? ( {editing.mode === "static" ? null : EditForm ? (
<EditForm <EditForm
value={editData} value={editData}
onChange={(value) => { onChange={(value) => {
@@ -766,6 +902,95 @@ export function CodesList() {
</Notice> </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"> <Field label="Name (optional)" htmlFor="edit-label">
<TextInput <TextInput
id="edit-label" id="edit-label"
+166 -10
View File
@@ -1,15 +1,20 @@
"use client"; "use client";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import { Field, Notice, TextInput, cx } from "@/components/ui"; import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast"; import { evaluateContrast } from "@/lib/qr/contrast";
import { import {
COLOR_PRESETS, COLOR_PRESETS,
EC_LEVELS, EC_LEVELS,
EC_LEVEL_LABELS, EC_LEVEL_LABELS,
LOGO_SCALE_WARN,
MAX_LOGO_SCALE,
MAX_SIZE, MAX_SIZE,
MIN_LOGO_SCALE,
MIN_SIZE, MIN_SIZE,
type EcLevel, type EcLevel,
type LogoSource,
type QrDesign, type QrDesign,
} from "@/lib/qr/design"; } from "@/lib/qr/design";
@@ -40,7 +45,7 @@ function ColorField({
aria-label={`${label} auswählen`} aria-label={`${label} auswählen`}
value={valid ? value : "#000000"} value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())} onChange={(event) => onChange(event.target.value.toUpperCase())}
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1" className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
/> />
<TextInput <TextInput
id={id} id={id}
@@ -62,9 +67,12 @@ function ColorField({
export function DesignPanel({ export function DesignPanel({
design, design,
onChange, onChange,
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
hasBrandingLogo = false,
}: { }: {
design: QrDesign; design: QrDesign;
onChange: (design: QrDesign) => void; onChange: (design: QrDesign) => void;
hasBrandingLogo?: boolean;
}) { }) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes }); const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor); const contrast = evaluateContrast(design.fgColor, design.bgColor);
@@ -95,7 +103,7 @@ export function DesignPanel({
onClick={() => onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor }) patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
} }
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50" className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<span <span
className="size-4 rounded border border-slate-300" className="size-4 rounded border border-slate-300"
@@ -111,7 +119,7 @@ export function DesignPanel({
onClick={() => onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor }) patch({ fgColor: design.bgColor, bgColor: design.fgColor })
} }
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50" className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
Farben tauschen Farben tauschen
</button> </button>
@@ -158,14 +166,18 @@ export function DesignPanel({
if (Number.isNaN(raw)) return; if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) }); patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
}} }}
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm" className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
/> />
</div> </div>
</Field> </Field>
<Field <Field
label="Fehlerkorrektur" label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]} hint={
design.logoSource === null
? EC_LEVEL_LABELS[design.ecLevel]
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
}
> >
<div <div
role="radiogroup" role="radiogroup"
@@ -177,13 +189,16 @@ export function DesignPanel({
key={level} key={level}
type="button" type="button"
role="radio" role="radio"
aria-checked={design.ecLevel === level} aria-checked={
design.logoSource === null ? design.ecLevel === level : level === "H"
}
disabled={design.logoSource !== null}
onClick={() => patch({ ecLevel: level })} onClick={() => patch({ ecLevel: level })}
className={cx( className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors", "rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
design.ecLevel === level (design.logoSource === null ? design.ecLevel : "H") === level
? "border-indigo-600 bg-indigo-50 text-indigo-700" ? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50", : "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
)} )}
> >
{level} {level}
@@ -208,6 +223,147 @@ export function DesignPanel({
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600" className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/> />
</Field> </Field>
<LogoAuswahl
design={design}
patch={patch}
hasBrandingLogo={hasBrandingLogo}
/>
</div> </div>
); );
} }
/* -------------------------------------------------------------------- Logo */
/**
* Das Bild in der Mitte des Codes.
*
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
*/
function LogoAuswahl({
design,
patch,
hasBrandingLogo,
}: {
design: QrDesign;
patch: (changes: Partial<QrDesign>) => void;
hasBrandingLogo: boolean;
}) {
const an = design.logoSource !== null;
const waehle = (quelle: LogoSource | null) => {
if (quelle === null) {
patch({ logoSource: null, logoUploadId: null });
} else if (quelle === "branding") {
patch({ logoSource: "branding", logoUploadId: null });
} else {
patch({ logoSource: "upload" });
}
};
return (
<fieldset className="border-t border-slate-200 pt-4">
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Logo in der Mitte
</legend>
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={an}
onChange={(event) =>
waehle(
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
)
}
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
/>
<span className="text-sm text-slate-700">
Ein Logo in die Mitte des Codes setzen
</span>
</label>
{an ? (
<div className="mt-4 space-y-4">
{hasBrandingLogo ? (
<div
role="radiogroup"
aria-label="Herkunft des Logos"
className="space-y-2"
>
{(
[
["branding", "Logo dieser Installation"],
["upload", "Eigenes Logo hochladen"],
] as [LogoSource, string][]
).map(([quelle, text]) => (
<label
key={quelle}
className="flex cursor-pointer items-center gap-2.5"
>
<input
type="radio"
name="logo-quelle"
checked={design.logoSource === quelle}
onChange={() => waehle(quelle)}
className="size-4 shrink-0 accent-indigo-600"
/>
<span className="text-sm text-slate-700">{text}</span>
</label>
))}
</div>
) : null}
{design.logoSource === "upload" ? (
<div className="space-y-2">
<FileField
kind="qrlogo"
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
/>
{design.logoUploadId ? null : (
<p className="text-sm text-amber-700">
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
</p>
)}
</div>
) : null}
<Field
label={`Größe: ${design.logoScale} % der Kantenlänge`}
htmlFor="qr-logo-scale"
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
>
<input
id="qr-logo-scale"
type="range"
min={MIN_LOGO_SCALE}
max={MAX_LOGO_SCALE}
step={1}
value={design.logoScale}
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
{design.logoScale > LOGO_SCALE_WARN ? (
<Notice tone="warning">
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
zwar, aber manche Scanner-Apps suchen in der Mitte nach
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
bevor er in den Druck geht.
</Notice>
) : null}
<p className="text-sm text-slate-500">
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
</p>
</div>
) : null}
</fieldset>
);
}
+49 -3
View File
@@ -2,6 +2,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainBericht } from "@/components/domain-bericht";
import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal"; import { Modal } from "@/components/modal";
import { import {
@@ -14,6 +17,7 @@ import {
TextInput, TextInput,
} from "@/components/ui"; } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { Pruefbericht } from "@/lib/domain-check";
type Grant = { groupId: string; groupName: string; isDefault: boolean }; type Grant = { groupId: string; groupName: string; isDefault: boolean };
@@ -44,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [deleting, setDeleting] = useState<AdminDomain | null>(null); const [deleting, setDeleting] = useState<AdminDomain | null>(null);
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
const [checking, setChecking] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const result = await apiJson<{ const result = await apiJson<{
@@ -122,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
} }
}; };
const check = async (domain: AdminDomain) => {
setChecking(domain.id);
setError(null);
try {
const { report } = await apiJson<{ report: Pruefbericht }>(
`/api/admin/domains/${domain.id}/check`,
{ method: "POST" },
);
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Prüfung ließ sich nicht ausführen.",
);
} finally {
setChecking(null);
}
};
if (domains === null) { if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>; return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
} }
@@ -134,10 +162,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "} weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn <code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404. gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
einzurichten ist, steht in der Kurzhilfe.
</Notice> </Notice>
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-between gap-2">
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
<DomainHilfe baseUrlHostname={baseUrlHostname} />
</HelpButton>
<Button type="button" onClick={() => setCreating(true)}> <Button type="button" onClick={() => setCreating(true)}>
Domain registrieren Domain registrieren
</Button> </Button>
@@ -152,7 +184,7 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
{domain.hostname} {domain.hostname}
</h2> </h2>
{domain.hostname === baseUrlHostname ? ( {domain.hostname === baseUrlHostname ? (
<Badge tone="popular">aus BASE_URL</Badge> <Badge tone="accent">aus BASE_URL</Badge>
) : null} ) : null}
{!domain.isActive ? ( {!domain.isActive ? (
<Badge tone="soon">abgeschaltet</Badge> <Badge tone="soon">abgeschaltet</Badge>
@@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Gruppen zuteilen Gruppen zuteilen
</Button> </Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={checking !== null}
onClick={() => void check(domain)}
>
<Icon name="scan" className="size-3.5" />
{checking === domain.id ? "Prüft …" : "Prüfen"}
</Button>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -213,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Löschen Löschen
</Button> </Button>
</div> </div>
{reports[domain.id] ? (
<DomainBericht bericht={reports[domain.id]} />
) : null}
</div> </div>
</div> </div>
</Card> </Card>
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { Icon } from "@/components/icons";
import { cx } from "@/components/ui";
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
/**
* Das Ergebnis eines Domain-Checks.
*
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
*/
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
};
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
ok: "border-emerald-300 bg-emerald-50/60",
warnung: "border-amber-300 bg-amber-50/60",
fehler: "border-red-300 bg-red-50/60",
};
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
ok: "Die Domain führt hierher.",
warnung: "Es läuft, aber etwas will beachtet werden.",
fehler: "So kommt niemand hier an.",
};
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
return (
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
<p className="text-sm font-semibold text-slate-900">
{FAZIT[bericht.gesamt]}
</p>
<ul className="mt-3 space-y-1.5">
{bericht.schritte.map((schritt) => {
const zeichen = SYMBOLE[schritt.status];
return (
<li key={schritt.name} className="flex items-start gap-2 text-sm">
<Icon
name={zeichen.icon}
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
/>
<span className="sr-only">{zeichen.wort}: </span>
<span className="w-24 shrink-0 font-medium text-slate-700">
{schritt.titel}
</span>
<span className="min-w-0 flex-1 text-slate-600">
{schritt.detail}
</span>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-slate-500">
Aufgerufen wurde{" "}
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
Prüfung scheitern, obwohl von außen alles stimmt.
</p>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { HelpCode, HelpStep } from "@/components/help-button";
/**
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
*
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
* einen Eintrag anlegt, steht in dessen Handbuch besser.
*/
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
const beispiel = baseUrlHostname || "qr.example.com";
return (
<div className="space-y-5">
<p>
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
Zertifikat.
</p>
<ol className="space-y-4">
<HelpStep n={1} title="DNS-Eintrag anlegen">
<p>
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
auch.
</p>
<p>
Steht der Name nur im internen DNS oder ist er nur über VPN
erreichbar, funktionieren gedruckte Codes{" "}
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
Verpackungen muss er öffentlich auflösbar sein.
</p>
</HelpStep>
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
<p>
Für die Domain einen eigenen Eintrag anlegen, der auf diese
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
<code className="font-mono text-xs">3000</code>, oder im selben
Docker-Netz der Servicename{" "}
<code className="font-mono text-xs">app</code>.
</p>
<p>
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
braucht sie im Serverblock:
</p>
<HelpCode>{`proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
<p>
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
Zweitdomain werden nicht gefunden.
</p>
</HelpStep>
<HelpStep n={3} title="Zertifikat anfordern">
<p>
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
Zertifikat selbst, sobald der DNS-Eintrag steht.
</p>
<p>
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
aus.
</p>
</HelpStep>
<HelpStep n={4} title="Domain hier registrieren">
<p>
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
genau so, wie er im Browser steht, ohne{" "}
<code className="font-mono text-xs">https://</code> und ohne Pfad:
</p>
<HelpCode>{beispiel}</HelpCode>
<p>
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
lässt sich also keine fremde Domain ansprechen.
</p>
</HelpStep>
<HelpStep n={5} title="Einer Gruppe zuteilen">
<p>
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
Domain ist die, die beim Anlegen vorausgewählt ist.
</p>
</HelpStep>
<HelpStep n={6} title="Nachprüfen">
<p>
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
hinter der Domain wirklich diese Installation steht.
</p>
<p>
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
stimmt. Von einem fremden Netz aus hilft dann:
</p>
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
<p>
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
auswählen und den fertigen Code einmal mit dem Handy scannen — das
ist die einzige Prüfung, die wirklich zählt.
</p>
</HelpStep>
</ol>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h3 className="font-semibold text-slate-900">
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
</h3>
<p className="mt-1 text-slate-600">
Die Umgebungsvariable{" "}
<code className="font-mono text-xs">BASE_URL</code> ist die
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
<code className="font-mono text-xs">http</code> oder{" "}
<code className="font-mono text-xs">https</code> gebildet werden.
Weitere Domains werden hier registriert, nicht dort eingetragen.
</p>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState, type ReactNode } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { Button } from "@/components/ui";
/**
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
* aufklappt.
*
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
* würde die Seite für alle anderen Aufrufe zumüllen.
*/
export function HelpButton({
title,
label = "Hilfe",
children,
}: {
/** Überschrift des Dialogs. */
title: string;
/** Beschriftung des Knopfes. */
label?: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
>
<Icon name="help" className="size-4" />
{label}
</Button>
<Modal
open={open}
size="lg"
title={title}
onClose={() => setOpen(false)}
footer={
<Button type="button" onClick={() => setOpen(false)}>
Verstanden
</Button>
}
>
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
{children}
</div>
</Modal>
</>
);
}
/* ------------------------------------------------- Bausteine für den Inhalt */
/** Ein nummerierter Schritt mit Überschrift. */
export function HelpStep({
n,
title,
children,
}: {
n: number;
title: string;
children: ReactNode;
}) {
return (
<li className="flex gap-3">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
{n}
</span>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">{title}</h3>
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
</div>
</li>
);
}
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
export function HelpCode({ children }: { children: ReactNode }) {
return (
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
{children}
</pre>
);
}
+25
View File
@@ -214,12 +214,37 @@ const PATHS = {
<path d="M4 12h16" /> <path d="M4 12h16" />
</> </>
), ),
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
</>
),
moon: (
<>
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
</>
),
monitor: (
<>
<rect x="2.5" y="4" width="19" height="13" rx="2" />
<path d="M9 21h6M12 17v4" />
</>
),
lock: ( lock: (
<> <>
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" /> <rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" /> <path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</> </>
), ),
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
<path d="M11.75 16.75h.01" />
</>
),
} as const; } as const;
export type IconName = keyof typeof PATHS; export type IconName = keyof typeof PATHS;
+2 -2
View File
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
return ( return (
<LandingShell title={title}> <LandingShell title={title}>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6"> <div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
<fieldset> <fieldset>
<legend className="text-center text-base font-medium text-slate-900"> <legend className="text-center text-base font-medium text-slate-900">
{question} {question}
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
value={kommentar} value={kommentar}
disabled={laeuft} disabled={laeuft}
onChange={(event) => setKommentar(event.target.value)} onChange={(event) => setKommentar(event.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400" className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
{images.map((image, index) => ( {images.map((image, index) => (
<figure <figure
key={image.fileId} key={image.fileId}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm" className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
> >
{/* eslint-disable-next-line @next/next/no-img-element -- eigene {/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung ohne bekannte Maße; der Optimierer von Next Auslieferung ohne bekannte Maße; der Optimierer von Next
+1 -1
View File
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
href={link.url} href={link.url}
// Fremde Ziele: kein Zugriff auf dieses Fenster. // Fremde Ziele: kein Zugriff auf dieses Fenster.
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60" className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
> >
<span className="min-w-0 flex-1 font-medium break-words"> <span className="min-w-0 flex-1 font-medium break-words">
{link.label} {link.label}
+2 -2
View File
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
title={title || filename || "PDF-Dokument"} title={title || filename || "PDF-Dokument"}
subtitle={description || null} subtitle={description || null}
> >
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600"> <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
<Icon name="document" className="size-6" /> <Icon name="document" className="size-6" />
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
</a> </a>
<a <a
href={`${href}?download`} href={`${href}?download`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50" className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
<Icon name="download" className="size-4" /> <Icon name="download" className="size-4" />
Herunterladen Herunterladen
+1 -1
View File
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
<a <a
href={profile.url} href={profile.url}
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50" className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
> >
<span <span
aria-hidden="true" aria-hidden="true"
+1 -1
View File
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
<a <a
href={url} href={url}
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60" className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
> >
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
<Icon name="video" className="size-5" /> <Icon name="video" className="size-5" />
+2 -2
View File
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
<button <button
type="button" type="button"
onClick={() => setGestartet(true)} onClick={() => setGestartet(true)}
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800" className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-scrim px-6 text-center shadow-sm transition-colors hover:brightness-125"
> >
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20"> <span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
<Icon name="video" className="size-8" /> <Icon name="video" className="size-8" />
</span> </span>
<span className="font-medium text-white">Video abspielen</span> <span className="font-medium text-white">Video abspielen</span>
<span className="max-w-xs text-xs leading-relaxed text-slate-300"> <span className="max-w-xs text-xs leading-relaxed text-white/75">
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
werden Daten an den Anbieter übertragen. werden Daten an den Anbieter übertragen.
</span> </span>
+6 -1
View File
@@ -9,12 +9,15 @@ export function Modal({
onClose, onClose,
children, children,
footer, footer,
size = "md",
}: { }: {
open: boolean; open: boolean;
title: string; title: string;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
/** `lg` für längere Texte wie eine Kurzhilfe. */
size?: "md" | "lg";
}) { }) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
@@ -39,7 +42,9 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn. // Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose(); if (event.target === ref.current) onClose();
}} }}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40" className={`m-auto w-[calc(100%-2rem)] ${
size === "lg" ? "max-w-2xl" : "max-w-lg"
} rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50`}
> >
<div className="p-5 sm:p-6"> <div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
+4 -4
View File
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
<img <img
src={`/api/uploads/${image.fileId}`} src={`/api/uploads/${image.fileId}`}
alt="" alt=""
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover" className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
/> />
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-slate-600"> <p className="truncate text-sm text-slate-600">
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0} disabled={disabled || index === 0}
onClick={() => move(index, -1)} onClick={() => move(index, -1)}
aria-label={`Bild ${index + 1} nach oben`} aria-label={`Bild ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="arrowLeft" className="size-4 rotate-90" /> <Icon name="arrowLeft" className="size-4 rotate-90" />
</button> </button>
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === images.length - 1} disabled={disabled || index === images.length - 1}
onClick={() => move(index, 1)} onClick={() => move(index, 1)}
aria-label={`Bild ${index + 1} nach unten`} aria-label={`Bild ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="arrowLeft" className="size-4 -rotate-90" /> <Icon name="arrowLeft" className="size-4 -rotate-90" />
</button> </button>
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
setImages(images.filter((_, i) => i !== index)) setImages(images.filter((_, i) => i !== index))
} }
aria-label={`Bild ${index + 1} entfernen`} aria-label={`Bild ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
> >
<Icon name="trash" className="size-4" /> <Icon name="trash" className="size-4" />
</button> </button>
+3 -3
View File
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === 0} disabled={disabled || index === 0}
onClick={() => move(index, -1)} onClick={() => move(index, -1)}
aria-label={`Link ${index + 1} nach oben`} aria-label={`Link ${index + 1} nach oben`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="arrowLeft" className="size-4 rotate-90" /> <Icon name="arrowLeft" className="size-4 rotate-90" />
</button> </button>
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || index === links.length - 1} disabled={disabled || index === links.length - 1}
onClick={() => move(index, 1)} onClick={() => move(index, 1)}
aria-label={`Link ${index + 1} nach unten`} aria-label={`Link ${index + 1} nach unten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="arrowLeft" className="size-4 -rotate-90" /> <Icon name="arrowLeft" className="size-4 -rotate-90" />
</button> </button>
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
disabled={disabled || links.length === 1} disabled={disabled || links.length === 1}
onClick={() => setLinks(links.filter((_, i) => i !== index))} onClick={() => setLinks(links.filter((_, i) => i !== index))}
aria-label={`Link ${index + 1} entfernen`} aria-label={`Link ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="trash" className="size-4" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
onChange({ ...value, fileId: "", filename: "", size: 0 }) onChange({ ...value, fileId: "", filename: "", size: 0 })
} }
aria-label="Datei entfernen" aria-label="Datei entfernen"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
> >
<Icon name="trash" className="size-4" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
setProfiles(profiles.filter((_, i) => i !== index)) setProfiles(profiles.filter((_, i) => i !== index))
} }
aria-label={`Profil ${index + 1} entfernen`} aria-label={`Profil ${index + 1} entfernen`}
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent" className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
> >
<Icon name="trash" className="size-4" /> <Icon name="trash" className="size-4" />
</button> </button>
+1 -1
View File
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
onClick={() => onClick={() =>
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` }) onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
} }
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50" className="mt-2 rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
> >
https:// voranstellen https:// voranstellen
</button> </button>

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