Das Abzeichen stammte aus Version 1, als genau ein Typ nutzbar war. Seit alle 15 gebaut sind, stand es an jeder Kachel und sagte damit nichts. An seiner Stelle steht jetzt nur noch dort etwas, wo es die Wahl beeinflusst: sechs Typen brauchen zwingend eine Kurz-URL und funktionieren deshalb nicht ohne diesen Server. Die uebrigen neun Kacheln bleiben unbeschriftet. Die Badge-Variante hiess "popular" und wird an drei weiteren Stellen als Hervorhebung benutzt — sie heisst jetzt "accent", weil sie mit Beliebtheit nichts zu tun hat. Dazu die Roadmap neu geordnet: Typen je Gruppe, Statistik, Papierkorb, Logo im Code, Ablaufdatum — in dieser Reihenfolge, jeweils mit dem, was dabei zu entscheiden ist.
388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
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";
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
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={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>
|
|
</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>
|
|
);
|
|
}
|