Merge #4: Domains hinzufügen — Kurzhilfe erstellen
This commit was merged in pull request #12.
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -134,10 +136,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -238,6 +238,13 @@ const PATHS = {
|
|||||||
<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;
|
||||||
|
|||||||
@@ -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-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">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user