Files
qrcode-generator/src/components/domain-admin.tsx
T
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

434 lines
14 KiB
TypeScript

"use client";
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 { 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. Was dafür
einzurichten ist, steht in der Kurzhilfe.
</Notice>
<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>
</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>
);
}