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
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import { DomainBericht } from "@/components/domain-bericht";
|
|
import { Icon } from "@/components/icons";
|
|
import { Modal } from "@/components/modal";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Field,
|
|
Notice,
|
|
TextInput,
|
|
} from "@/components/ui";
|
|
import { ApiError, apiJson } from "@/lib/api-client";
|
|
import type { Pruefbericht } from "@/lib/domain-check";
|
|
|
|
type Grant = { groupId: string; groupName: string; isDefault: boolean };
|
|
|
|
type AdminDomain = {
|
|
id: string;
|
|
hostname: string;
|
|
label: string | null;
|
|
isActive: boolean;
|
|
codeCount: number;
|
|
grants: Grant[];
|
|
};
|
|
|
|
type GroupOption = { id: string; name: string };
|
|
|
|
export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|
const [domains, setDomains] = useState<AdminDomain[] | null>(null);
|
|
const [groups, setGroups] = useState<GroupOption[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
const [form, setForm] = useState({ hostname: "", label: "" });
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
const [editing, setEditing] = useState<AdminDomain | null>(null);
|
|
const [grants, setGrants] = useState<Grant[]>([]);
|
|
const [grantError, setGrantError] = useState<string | null>(null);
|
|
|
|
const [deleting, setDeleting] = useState<AdminDomain | null>(null);
|
|
|
|
/** 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 () => {
|
|
try {
|
|
const result = await apiJson<{
|
|
domains: AdminDomain[];
|
|
groups: GroupOption[];
|
|
}>("/api/admin/domains");
|
|
setDomains(result.domains);
|
|
setGroups(result.groups);
|
|
setError(null);
|
|
} catch (caught) {
|
|
setDomains([]);
|
|
setError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Domains konnten nicht geladen werden.",
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// setState folgt erst nach dem await der Netzwerkantwort.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
void load();
|
|
}, [load]);
|
|
|
|
const create = async () => {
|
|
setFormError(null);
|
|
try {
|
|
await apiJson("/api/admin/domains", { method: "POST", json: form });
|
|
setCreating(false);
|
|
setForm({ hostname: "", label: "" });
|
|
await load();
|
|
} catch (caught) {
|
|
setFormError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Domain konnte nicht angelegt werden.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const patch = async (id: string, body: unknown, onDone?: () => void) => {
|
|
setBusy(id);
|
|
setError(null);
|
|
try {
|
|
await apiJson(`/api/admin/domains/${id}`, { method: "PATCH", json: body });
|
|
await load();
|
|
onDone?.();
|
|
return true;
|
|
} catch (caught) {
|
|
const message =
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Änderung konnte nicht gespeichert werden.";
|
|
if (onDone) setGrantError(message);
|
|
else setError(message);
|
|
return false;
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!deleting) return;
|
|
try {
|
|
await apiJson(`/api/admin/domains/${deleting.id}`, { method: "DELETE" });
|
|
setDeleting(null);
|
|
await load();
|
|
} catch (caught) {
|
|
setError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Domain konnte nicht gelöscht werden.",
|
|
);
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
|
|
|
<Notice tone="info">
|
|
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
|
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
|
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
|
gegen diese Liste — ein unbekannter Hostname endet mit 404.
|
|
</Notice>
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="button" onClick={() => setCreating(true)}>
|
|
Domain registrieren
|
|
</Button>
|
|
</div>
|
|
|
|
{domains.map((domain) => (
|
|
<Card key={domain.id}>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h2 className="font-mono font-semibold text-slate-900">
|
|
{domain.hostname}
|
|
</h2>
|
|
{domain.hostname === baseUrlHostname ? (
|
|
<Badge tone="accent">aus BASE_URL</Badge>
|
|
) : null}
|
|
{!domain.isActive ? (
|
|
<Badge tone="soon">abgeschaltet</Badge>
|
|
) : null}
|
|
<Badge tone="neutral">
|
|
{domain.codeCount} {domain.codeCount === 1 ? "Code" : "Codes"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{domain.label ? (
|
|
<p className="mt-1 text-sm text-slate-600">{domain.label}</p>
|
|
) : null}
|
|
|
|
<p className="mt-2 text-sm text-slate-600">
|
|
{domain.grants.length === 0
|
|
? "Keiner Gruppe zugeteilt — niemand kann hier Codes anlegen."
|
|
: domain.grants
|
|
.map(
|
|
(grant) =>
|
|
`${grant.groupName}${grant.isDefault ? " (Voreinstellung)" : ""}`,
|
|
)
|
|
.join(" · ")}
|
|
</p>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={busy === domain.id}
|
|
onClick={() => {
|
|
setEditing(domain);
|
|
setGrants(domain.grants);
|
|
setGrantError(null);
|
|
}}
|
|
>
|
|
<Icon name="pencil" className="size-3.5" />
|
|
Gruppen zuteilen
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={checking !== null}
|
|
onClick={() => void check(domain)}
|
|
>
|
|
<Icon name="scan" className="size-3.5" />
|
|
{checking === domain.id ? "Prüft …" : "Prüfen"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={busy === domain.id}
|
|
onClick={() => void patch(domain.id, { isActive: !domain.isActive })}
|
|
>
|
|
{domain.isActive ? "Abschalten" : "Einschalten"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-red-600 hover:bg-red-50"
|
|
disabled={busy === domain.id}
|
|
onClick={() => setDeleting(domain)}
|
|
>
|
|
<Icon name="trash" className="size-3.5" />
|
|
Löschen
|
|
</Button>
|
|
</div>
|
|
|
|
{reports[domain.id] ? (
|
|
<DomainBericht bericht={reports[domain.id]} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
|
|
{/* --------------------------------------------------- Registrieren */}
|
|
<Modal
|
|
open={creating}
|
|
title="Domain registrieren"
|
|
onClose={() => setCreating(false)}
|
|
footer={
|
|
<>
|
|
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" onClick={() => void create()}>
|
|
Registrieren
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
{formError ? <Notice tone="error">{formError}</Notice> : null}
|
|
<Field
|
|
label="Hostname"
|
|
htmlFor="new-domain-host"
|
|
hint="Ohne https:// und ohne Pfad — genau so, wie er im Browser steht."
|
|
>
|
|
<TextInput
|
|
id="new-domain-host"
|
|
className="font-mono"
|
|
placeholder="qr.example.com"
|
|
value={form.hostname}
|
|
onChange={(event) =>
|
|
setForm({ ...form, hostname: event.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Bezeichnung (optional)" htmlFor="new-domain-label">
|
|
<TextInput
|
|
id="new-domain-label"
|
|
placeholder="Kundenprojekt Nord"
|
|
value={form.label}
|
|
onChange={(event) => setForm({ ...form, label: event.target.value })}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* ------------------------------------------------------- Zuteilung */}
|
|
<Modal
|
|
open={editing !== null}
|
|
title={`Gruppen für ${editing?.hostname ?? ""}`}
|
|
onClose={() => setEditing(null)}
|
|
footer={
|
|
<>
|
|
<Button type="button" variant="secondary" onClick={() => setEditing(null)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={busy === editing?.id}
|
|
onClick={() =>
|
|
editing &&
|
|
void patch(
|
|
editing.id,
|
|
{
|
|
grants: grants.map((grant) => ({
|
|
groupId: grant.groupId,
|
|
isDefault: grant.isDefault,
|
|
})),
|
|
},
|
|
() => setEditing(null),
|
|
)
|
|
}
|
|
>
|
|
Speichern
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
{grantError ? <Notice tone="error">{grantError}</Notice> : null}
|
|
<p className="text-sm text-slate-600">
|
|
Mehrere Gruppen dürfen dieselbe Domain nutzen. Die Voreinstellung
|
|
bestimmt, welche Domain beim Anlegen eines Codes vorausgewählt ist.
|
|
</p>
|
|
|
|
<ul className="space-y-2">
|
|
{groups.map((group) => {
|
|
const grant = grants.find((entry) => entry.groupId === group.id);
|
|
return (
|
|
<li
|
|
key={group.id}
|
|
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 p-2.5"
|
|
>
|
|
<Checkbox
|
|
id={`grant-${group.id}`}
|
|
label={group.name}
|
|
checked={Boolean(grant)}
|
|
onChange={(event) =>
|
|
setGrants((current) =>
|
|
event.target.checked
|
|
? [
|
|
...current,
|
|
{
|
|
groupId: group.id,
|
|
groupName: group.name,
|
|
isDefault: false,
|
|
},
|
|
]
|
|
: current.filter((entry) => entry.groupId !== group.id),
|
|
)
|
|
}
|
|
/>
|
|
{grant ? (
|
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-slate-600">
|
|
<input
|
|
type="checkbox"
|
|
className="size-3.5 rounded border-slate-300 accent-indigo-600"
|
|
checked={grant.isDefault}
|
|
onChange={(event) =>
|
|
setGrants((current) =>
|
|
current.map((entry) =>
|
|
entry.groupId === group.id
|
|
? { ...entry, isDefault: event.target.checked }
|
|
: entry,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
Voreinstellung
|
|
</label>
|
|
) : null}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* -------------------------------------------------------- Löschen */}
|
|
<Modal
|
|
open={deleting !== null}
|
|
title="Domain löschen?"
|
|
onClose={() => setDeleting(null)}
|
|
footer={
|
|
<>
|
|
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" variant="danger" onClick={() => void remove()}>
|
|
Endgültig löschen
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{deleting ? (
|
|
<p className="text-sm text-slate-600">
|
|
<strong className="font-mono text-slate-900">
|
|
{deleting.hostname}
|
|
</strong>{" "}
|
|
wird entfernt.
|
|
{deleting.codeCount > 0
|
|
? ` Unter dieser Domain liegen noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt. Schalte sie stattdessen ab.`
|
|
: " Es liegen keine Codes darunter."}
|
|
</p>
|
|
) : null}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|