Merge #4: Domains hinzufügen — Kurzhilfe erstellen

This commit was merged in pull request #12.
This commit is contained in:
2026-08-21 08:43:16 +00:00
5 changed files with 243 additions and 3 deletions
+8 -2
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
@@ -134,10 +136,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
weiterleitet. Die Weiterleitung erkennt sie am{" "}
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
gegen diese Liste — ein unbekannter Hostname endet mit 404.
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
einzurichten ist, steht in der Kurzhilfe.
</Notice>
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-between gap-2">
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
<DomainHilfe baseUrlHostname={baseUrlHostname} />
</HelpButton>
<Button type="button" onClick={() => setCreating(true)}>
Domain registrieren
</Button>
+128
View File
@@ -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 (
<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>Von außerhalb des eigenen Netzes:</p>
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
<p>
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.
</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>
);
}
+7
View File
@@ -238,6 +238,13 @@ const PATHS = {
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</>
),
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
<path d="M11.75 16.75h.01" />
</>
),
} as const;
export type IconName = keyof typeof PATHS;
+6 -1
View File
@@ -9,12 +9,15 @@ export function Modal({
onClose,
children,
footer,
size = "md",
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** `lg` für längere Texte wie eine Kurzhilfe. */
size?: "md" | "lg";
}) {
const ref = useRef<HTMLDialogElement>(null);
@@ -39,7 +42,9 @@ export function Modal({
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
if (event.target === ref.current) onClose();
}}
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-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`}
>
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>