diff --git a/README.md b/README.md index 43d5ddd..5097548 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit 404 — über einen gefälschten Header lässt sich also keine fremde Domain ansprechen. +### Nachprüfen, ob eine Domain wirklich hierher führt + +Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den +Weg der Scanner nach und meldet vier Dinge: + +| Schritt | Was geprüft wird | +| --- | --- | +| DNS | Löst der Name auf? Auf welche Adressen? | +| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? | +| Erreichbar | Antwortet dort ein Webserver, und wie schnell? | +| Ziel | Steht dahinter **diese** Installation? | + +Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der +Domain eine eigene Route auf und lässt sich einen HMAC über eine frische +Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und +verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt +irgendwohin“ von „die Domain zeigt hierher“. + +> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft. +> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler, +> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen +> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist. +> Dafür bleibt der Blick von einem fremden Netz aus. + Zwei Dinge, die daraus folgen: - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende @@ -620,13 +644,18 @@ Dazu kommen - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - eine **Sortierung** nach Datum, Name oder Scan-Anzahl, -- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. +- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen, +- ein **Filter nach Art**, sobald dynamische und statische Codes + nebeneinander liegen. Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kartenansicht. -Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. +Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein +Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der +Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach +Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts. Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. diff --git a/docs/funktionen.md b/docs/funktionen.md index c135543..6293aef 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,17 @@ 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. diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 62aa2b5..366ab25 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -191,6 +191,29 @@ 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. diff --git a/prisma/migrations/20260821010000_domain_check/migration.sql b/prisma/migrations/20260821010000_domain_check/migration.sql new file mode 100644 index 0000000..9645111 --- /dev/null +++ b/prisma/migrations/20260821010000_domain_check/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 182c735..4e002d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -270,6 +270,14 @@ model InstanceSetting { /// 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 } diff --git a/src/app/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx index 26bcf7d..12357a9 100644 --- a/src/app/(app)/meine-codes/page.tsx +++ b/src/app/(app)/meine-codes/page.tsx @@ -27,9 +27,9 @@ export default async function MyCodesPage() { Meine Codes

- Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der - Scans. Statische Codes erscheinen hier nicht — sie werden nicht - gespeichert. + Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans, + dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“ + lässt sich die Liste auf eine der beiden Arten einschränken.

diff --git a/src/app/api/admin/domains/[id]/check/route.ts b/src/app/api/admin/domains/[id]/check/route.ts new file mode 100644 index 0000000..950af1e --- /dev/null +++ b/src/app/api/admin/domains/[id]/check/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 4f5ba8d..72c4b4a 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { resolveTargetDomain } from "@/lib/domains"; +import { findStaticDuplicate } from "@/lib/duplicates"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; @@ -43,6 +44,12 @@ const createSchema = z.object({ groupId: z.string().trim().min(1).optional(), /** Unter welcher Domain die Kurz-URL entsteht. */ domainId: z.string().trim().min(1).optional(), + /** + * Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen + * antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als + * statischer Code in der Gruppe liegt. + */ + allowDuplicate: z.boolean().default(false), }); /** @@ -158,6 +165,24 @@ export async function POST(request: NextRequest): Promise { 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: { diff --git a/src/app/api/domain-check/route.ts b/src/app/api/domain-check/route.ts new file mode 100644 index 0000000..70c5fcb --- /dev/null +++ b/src/app/api/domain-check/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index 5ab8b16..b7e94b9 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string { type View = "list" | "cards"; type SortKey = "neueste" | "aelteste" | "name" | "scans"; +/** Betriebsart, auf die die Liste eingeschränkt ist. */ +type ModeFilter = "alle" | "dynamic" | "static"; const SORT_LABELS: Record = { neueste: "Neueste zuerst", @@ -51,6 +53,12 @@ const SORT_LABELS: Record = { scans: "Meiste Scans", }; +const MODE_LABELS: Record = { + alle: "Alle Arten", + dynamic: "Nur dynamische", + static: "Nur statische", +}; + /** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */ const VIEW_STORAGE_KEY = "qrgen.codes.view"; @@ -94,6 +102,7 @@ export function CodesList({ const [query, setQuery] = useState(""); const [sort, setSort] = useState("neueste"); const [groupFilter, setGroupFilter] = useState("alle"); + const [modeFilter, setModeFilter] = useState("alle"); // Erst nach dem Einhängen lesen — auf dem Server gibt es keinen // localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern. @@ -107,6 +116,14 @@ export function CodesList({ window.localStorage.setItem(VIEW_STORAGE_KEY, next); }; + const changeModeFilter = (next: ModeFilter) => { + setModeFilter(next); + // Statische Codes zählen keine Scans. Bleibt die Sortierung danach + // stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet + // eine Reihenfolge an, die es dort gar nicht gibt. + if (next === "static" && sort === "scans") setSort("neueste"); + }; + const load = useCallback(async () => { try { const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes"); @@ -241,11 +258,25 @@ export function CodesList({ ); }, [codes]); + // Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu + // filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide + // Arten in der Liste stehen. + const kinds = useMemo(() => { + let dynamisch = false; + let statisch = false; + for (const code of codes ?? []) { + if (code.mode === "static") statisch = true; + else dynamisch = true; + } + return { dynamisch, statisch, beide: dynamisch && statisch }; + }, [codes]); + const visible = useMemo(() => { const needle = query.trim().toLowerCase(); const filtered = (codes ?? []).filter((code) => { if (groupFilter !== "alle" && code.groupId !== groupFilter) return false; + if (modeFilter !== "alle" && code.mode !== modeFilter) return false; if (!needle) return true; return [ displayName(code), @@ -273,7 +304,7 @@ export function CodesList({ } }); return sorted; - }, [codes, query, sort, groupFilter]); + }, [codes, query, sort, groupFilter, modeFilter]); /* ---------------------------------------------------------------- View */ @@ -287,6 +318,10 @@ export function CodesList({ const svgHref = (code: QrCodeDto) => `/api/codes/${code.id}/qr?format=svg&download=1`; + // Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte + // nichts zu sagen — dann fällt sie weg, samt der Sortierung danach. + const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch); + const toolbar = codes.length > 0 && (
@@ -320,17 +355,36 @@ export function CodesList({ ) : null} + {kinds.beide ? ( + + ) : null} +
0 && visible.length === 0 ? (

- Kein Code passt zur Suche.{" "} + Kein Code passt zu Suche und Filtern.{" "} @@ -192,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { Gruppen zuteilen +

+ + {reports[domain.id] ? ( + + ) : null}
diff --git a/src/components/domain-bericht.tsx b/src/components/domain-bericht.tsx new file mode 100644 index 0000000..9a92d50 --- /dev/null +++ b/src/components/domain-bericht.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+

+ {FAZIT[bericht.gesamt]} +

+ +
    + {bericht.schritte.map((schritt) => { + const zeichen = SYMBOLE[schritt.status]; + return ( +
  • + + {zeichen.wort}: + + {schritt.titel} + + + {schritt.detail} + +
  • + ); + })} +
+ +

+ Aufgerufen wurde{" "} + {bericht.geprueft} — von dort, wo + dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die + Prüfung scheitern, obwohl von außen alles stimmt. +

+
+ ); +} diff --git a/src/components/domain-hilfe.tsx b/src/components/domain-hilfe.tsx new file mode 100644 index 0000000..11c19e7 --- /dev/null +++ b/src/components/domain-hilfe.tsx @@ -0,0 +1,128 @@ +"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 ( +
+

+ 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. +

+ +
    + +

    + Beim Anbieter der Domain einen A-Record (bei IPv6 + zusätzlich AAAA) auf die öffentliche Adresse des + Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es + auch. +

    +

    + Steht der Name nur im internen DNS oder ist er nur über VPN + erreichbar, funktionieren gedruckte Codes{" "} + auch nur dort. Für Aushänge, Flyer und + Verpackungen muss er öffentlich auflösbar sein. +

    +
    + + +

    + Für die Domain einen eigenen Eintrag anlegen, der auf diese + Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "} + 3000, oder im selben + Docker-Netz der Servicename{" "} + app. +

    +

    + 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: +

    + {`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;`} +

    + Ohne X-Forwarded-Host{" "} + landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der + Zweitdomain werden nicht gefunden. +

    +
    + + +

    + HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "} + SSL mit Force SSL; Traefik und Caddy holen das + Zertifikat selbst, sobald der DNS-Eintrag steht. +

    +

    + 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. +

    +
    + + +

    + Über Domain registrieren den Hostnamen eintragen — + genau so, wie er im Browser steht, ohne{" "} + https:// und ohne Pfad: +

    + {beispiel} +

    + 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. +

    +
    + + +

    + Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "} + Gruppen zuteilen können Mitglieder dieser Gruppe + dort Codes anlegen. Die als Voreinstellung markierte + Domain ist die, die beim Anlegen vorausgewählt ist. +

    +
    + + +

    Von außerhalb des eigenen Netzes:

    + {`curl -I https://${beispiel}/`} +

    + Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und + Zertifikat. Danach 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. +

    +
    +
+ +
+

+ Was BASE_URL damit zu tun hat +

+

+ Die Umgebungsvariable{" "} + BASE_URL ist die + Standard-Adresse: Sie greift bei Aufrufen über eine nicht + registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "} + http oder{" "} + https gebildet werden. + Weitere Domains werden hier registriert, nicht dort eingetragen. +

+
+
+ ); +} diff --git a/src/components/help-button.tsx b/src/components/help-button.tsx new file mode 100644 index 0000000..fd9cdb6 --- /dev/null +++ b/src/components/help-button.tsx @@ -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 ( + <> + + + setOpen(false)} + footer={ + + } + > +
+ {children} +
+
+ + ); +} + +/* ------------------------------------------------- Bausteine für den Inhalt */ + +/** Ein nummerierter Schritt mit Überschrift. */ +export function HelpStep({ + n, + title, + children, +}: { + n: number; + title: string; + children: ReactNode; +}) { + return ( +
  • + + {n} + +
    +

    {title}

    +
    {children}
    +
    +
  • + ); +} + +/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */ +export function HelpCode({ children }: { children: ReactNode }) { + return ( +
    +      {children}
    +    
    + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 7ab0f02..91d02c8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -238,6 +238,13 @@ const PATHS = { ), + help: ( + <> + + + + + ), } as const; export type IconName = keyof typeof PATHS; diff --git a/src/components/modal.tsx b/src/components/modal.tsx index d72150e..63caed7 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -9,12 +9,15 @@ export function Modal({ onClose, children, footer, + size = "md", }: { open: boolean; title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; + /** `lg` für längere Texte wie eine Kurzhilfe. */ + size?: "md" | "lg"; }) { const ref = useRef(null); @@ -39,7 +42,9 @@ export function Modal({ // Klick auf die Hintergrundfläche des Dialogs schließt ihn. if (event.target === ref.current) onClose(); }} - className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50" + 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`} >

    {title}

    diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx index 0b0548e..34ea221 100644 --- a/src/components/qr-generator.tsx +++ b/src/components/qr-generator.tsx @@ -43,6 +43,21 @@ function sameData(a: unknown, b: unknown): boolean { return stableStringify(a) === stableStringify(b); } +/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */ +type Dublette = { id: string; label: string | null; createdAt: string }; + +/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */ +function leseDublette(details: unknown): Dublette | null { + if (details === null || typeof details !== "object") return null; + const feld = (details as { duplicate?: unknown }).duplicate; + if (feld === null || typeof feld !== "object") return null; + const { id, label, createdAt } = feld as Record; + if (typeof id !== "string" || typeof createdAt !== "string") return null; + return { id, label: typeof label === "string" ? label : null, createdAt }; +} + +const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); + export function QrGenerator({ typeId, /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ @@ -68,6 +83,14 @@ export function QrGenerator({ const [saved, setSaved] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + /** + * Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der + * Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne + * einen Effekt, der sie hinterherräumt. + */ + const [duplicate, setDuplicate] = useState< + { treffer: Dublette; inhalt: string } | null + >(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState(null); @@ -190,10 +213,18 @@ export function QrGenerator({ !sameData(saved.design, design) || (saved.label ?? "") !== label.trim()); - const handleSave = useCallback(async () => { + // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam. + const dublette = + duplicate !== null && + duplicate.inhalt === stableStringify(validation.ok ? validation.data : data) + ? duplicate.treffer + : null; + + const handleSave = useCallback(async (trotzDublette = false) => { if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); + setDuplicate(null); try { const body = { type: typeId, @@ -203,6 +234,8 @@ export function QrGenerator({ label: label.trim() || undefined, // Ein statischer Code hat keinen Kurz-Link. slug: isDynamic ? wunschSlug.trim() || undefined : undefined, + // Nur beim zweiten Anlauf, nachdem die Warnung dastand. + allowDuplicate: trotzDublette, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { @@ -215,11 +248,20 @@ export function QrGenerator({ }); setSaved(result.code); } catch (error) { - setSaveError( - error instanceof ApiError - ? error.message - : "Der Code konnte nicht gespeichert werden.", - ); + // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage. + const gemeldet = + error instanceof ApiError && error.status === 409 + ? leseDublette(error.details) + : null; + if (gemeldet) { + setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) }); + } else { + setSaveError( + error instanceof ApiError + ? error.message + : "Der Code konnte nicht gespeichert werden.", + ); + } } finally { setSaving(false); } @@ -235,6 +277,7 @@ export function QrGenerator({ setIsDynamic(dynamisch); setSaved(null); setSaveError(null); + setDuplicate(null); }, []); const handleCopy = useCallback(async () => { @@ -426,14 +469,43 @@ export function QrGenerator({
    ) : ( - +
    + {dublette ? ( + + + Diesen Inhalt hast du schon abgelegt + {" "} + — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "} + vom {dublettenDatum.format(new Date(dublette.createdAt))}. + Zweimal derselbe Inhalt ergibt zweimal denselben Code. + + Verglichen wird nur der Inhalt, nicht die Gestaltung — + derselbe Code in anderen Farben gilt hier als Dublette. + + + + Den vorhandenen ansehen + + + + + ) : null} + +
    ) ) : saved ? (
    @@ -463,7 +535,7 @@ export function QrGenerator({ {hasUnsavedChanges ? (