434 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|