Phase B2: mehrere Domains fuer Kurz-URLs
Eine Domain kann mehreren Gruppen dienen (n:m ueber DomainGrant), und der Slug eines Codes ist nur noch je Domain eindeutig - derselbe sprechende Slug darf also auf zwei Domains liegen. Sicherheitsrelevanter Kern: die Weiterleitung liest den Hostnamen aus X-Forwarded-Host bzw. Host und prueft ihn gegen die Domain-Tabelle. Ein unbekannter Hostname endet mit 404, faellt also auf die Standard-Domain zurueck und findet dort nichts. Ueber einen gefaelschten Header laesst sich damit keine fremde Domain ansprechen. Auch der Hostname fuer die Vorschau kommt aus der Datenbank, nicht aus der Anfrage. - Migration legt die Spalte erst nullable an, fuellt sie mit der Standard-Domain und macht sie danach verpflichtend; der globale Slug-Index weicht dem zusammengesetzten aus (domainId, slug). - Verwaltung -> Domains: registrieren, Gruppen zuteilen, Voreinstellung je Gruppe, abschalten. Domains mit Codes lassen sich weder loeschen noch einer Gruppe entziehen, die noch Codes darunter hat - beides wuerde gedruckte Codes ins Leere laufen lassen. Ein Fehler dabei gefunden und behoben: die Migration kennt BASE_URL nicht und legt die Standard-Domain mit einem Platzhalter-Hostnamen an. Korrigiert wurde er bisher nur beim Oeffnen der Domain-Verwaltung - auf einer frischen Installation waeren also Kurz-URLs auf 'standard.invalid' entstanden. ensureDefaultDomain() laeuft jetzt vor jeder Verwendung. Geprueft: 34 neue Pruefungen fuer Host-Aufloesung, Spoofing, Slug je Domain und Zuteilung; dazu 280 bestehende bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"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="popular">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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user