Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
402 lines
13 KiB
TypeScript
402 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,
|
|
Field,
|
|
Notice,
|
|
Select,
|
|
TextInput,
|
|
} from "@/components/ui";
|
|
import { ApiError, apiJson } from "@/lib/api-client";
|
|
|
|
type Role = "ADMIN" | "EDITOR" | "VIEWER";
|
|
|
|
const ROLE_LABELS: Record<Role, string> = {
|
|
ADMIN: "Administrator",
|
|
EDITOR: "Bearbeiter",
|
|
VIEWER: "Betrachter",
|
|
};
|
|
|
|
const ROLE_HINTS: Record<Role, string> = {
|
|
ADMIN: "darf auch Mitglieder verwalten",
|
|
EDITOR: "darf Codes anlegen und ändern",
|
|
VIEWER: "darf nur ansehen",
|
|
};
|
|
|
|
type Member = { userId: string; email: string; name: string | null; role: Role };
|
|
|
|
type AdminGroup = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
codeCount: number;
|
|
canManage: boolean;
|
|
members: Member[];
|
|
};
|
|
|
|
type PickerUser = { id: string; email: string; name: string | null };
|
|
|
|
export function GroupAdmin({ canCreate }: { canCreate: boolean }) {
|
|
const [groups, setGroups] = useState<AdminGroup[] | null>(null);
|
|
const [users, setUsers] = useState<PickerUser[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
const [form, setForm] = useState({ name: "", description: "" });
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
const [editing, setEditing] = useState<AdminGroup | null>(null);
|
|
const [members, setMembers] = useState<Member[]>([]);
|
|
const [memberError, setMemberError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const [deleting, setDeleting] = useState<AdminGroup | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const result = await apiJson<{ groups: AdminGroup[] }>("/api/admin/groups");
|
|
setGroups(result.groups);
|
|
setError(null);
|
|
} catch (caught) {
|
|
setGroups([]);
|
|
setError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Gruppen konnten nicht geladen werden.",
|
|
);
|
|
}
|
|
|
|
if (canCreate) {
|
|
try {
|
|
const result = await apiJson<{ users: PickerUser[] }>("/api/admin/users");
|
|
setUsers(result.users);
|
|
} catch {
|
|
// Ohne Benutzerliste lässt sich zwar niemand hinzufügen, die
|
|
// Gruppenübersicht bleibt aber brauchbar.
|
|
}
|
|
}
|
|
}, [canCreate]);
|
|
|
|
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/groups", { method: "POST", json: form });
|
|
setCreating(false);
|
|
setForm({ name: "", description: "" });
|
|
await load();
|
|
} catch (caught) {
|
|
setFormError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Gruppe konnte nicht angelegt werden.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const saveMembers = async () => {
|
|
if (!editing) return;
|
|
setSaving(true);
|
|
setMemberError(null);
|
|
try {
|
|
await apiJson(`/api/admin/groups/${editing.id}`, {
|
|
method: "PATCH",
|
|
json: { members: members.map((m) => ({ userId: m.userId, role: m.role })) },
|
|
});
|
|
setEditing(null);
|
|
await load();
|
|
} catch (caught) {
|
|
setMemberError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Mitglieder konnten nicht gespeichert werden.",
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!deleting) return;
|
|
try {
|
|
await apiJson(`/api/admin/groups/${deleting.id}`, { method: "DELETE" });
|
|
setDeleting(null);
|
|
await load();
|
|
} catch (caught) {
|
|
setError(
|
|
caught instanceof ApiError
|
|
? caught.message
|
|
: "Die Gruppe konnte nicht gelöscht werden.",
|
|
);
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
if (groups === null) {
|
|
return <p className="text-sm text-slate-500">Gruppen werden geladen …</p>;
|
|
}
|
|
|
|
const available = users.filter(
|
|
(user) => !members.some((member) => member.userId === user.id),
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
|
|
|
{canCreate ? (
|
|
<div className="flex justify-end">
|
|
<Button type="button" onClick={() => setCreating(true)}>
|
|
Gruppe anlegen
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{groups.map((group) => (
|
|
<Card key={group.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-semibold text-slate-900">{group.name}</h2>
|
|
<Badge tone="neutral">
|
|
{group.codeCount} {group.codeCount === 1 ? "Code" : "Codes"}
|
|
</Badge>
|
|
</div>
|
|
{group.description ? (
|
|
<p className="mt-1 text-sm text-slate-600">{group.description}</p>
|
|
) : null}
|
|
|
|
<ul className="mt-3 space-y-1 text-sm">
|
|
{group.members.length === 0 ? (
|
|
<li className="text-slate-500">Noch keine Mitglieder.</li>
|
|
) : (
|
|
group.members.map((member) => (
|
|
<li key={member.userId} className="text-slate-700">
|
|
{member.name?.trim() || member.email}
|
|
<span className="text-slate-500">
|
|
{" "}
|
|
— {ROLE_LABELS[member.role]}
|
|
</span>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{group.canManage ? (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setEditing(group);
|
|
setMembers(group.members);
|
|
setMemberError(null);
|
|
}}
|
|
>
|
|
<Icon name="pencil" className="size-3.5" />
|
|
Mitglieder verwalten
|
|
</Button>
|
|
) : null}
|
|
{canCreate ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-red-600 hover:bg-red-50"
|
|
onClick={() => setDeleting(group)}
|
|
>
|
|
<Icon name="trash" className="size-3.5" />
|
|
Löschen
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
|
|
{/* ------------------------------------------------------- Anlegen */}
|
|
<Modal
|
|
open={creating}
|
|
title="Gruppe anlegen"
|
|
onClose={() => setCreating(false)}
|
|
footer={
|
|
<>
|
|
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" onClick={() => void create()}>
|
|
Anlegen
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
{formError ? <Notice tone="error">{formError}</Notice> : null}
|
|
<Field label="Name" htmlFor="new-group-name">
|
|
<TextInput
|
|
id="new-group-name"
|
|
value={form.name}
|
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Beschreibung (optional)" htmlFor="new-group-description">
|
|
<TextInput
|
|
id="new-group-description"
|
|
value={form.description}
|
|
onChange={(event) =>
|
|
setForm({ ...form, description: event.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* ---------------------------------------------------- Mitglieder */}
|
|
<Modal
|
|
open={editing !== null}
|
|
title={`Mitglieder von „${editing?.name ?? ""}“`}
|
|
onClose={() => setEditing(null)}
|
|
footer={
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setEditing(null)}
|
|
disabled={saving}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" onClick={() => void saveMembers()} disabled={saving}>
|
|
{saving ? "Speichert …" : "Speichern"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
{memberError ? <Notice tone="error">{memberError}</Notice> : null}
|
|
|
|
{members.length === 0 ? (
|
|
<p className="text-sm text-slate-500">
|
|
Diese Gruppe hat noch keine Mitglieder.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{members.map((member) => (
|
|
<li
|
|
key={member.userId}
|
|
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-2.5"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-slate-800">
|
|
{member.name?.trim() || member.email}
|
|
</span>
|
|
<Select
|
|
aria-label={`Rolle von ${member.email}`}
|
|
className="!w-auto"
|
|
value={member.role}
|
|
onChange={(event) =>
|
|
setMembers((current) =>
|
|
current.map((entry) =>
|
|
entry.userId === member.userId
|
|
? { ...entry, role: event.target.value as Role }
|
|
: entry,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
{(Object.keys(ROLE_LABELS) as Role[]).map((role) => (
|
|
<option key={role} value={role}>
|
|
{ROLE_LABELS[role]} — {ROLE_HINTS[role]}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<button
|
|
type="button"
|
|
aria-label={`${member.email} entfernen`}
|
|
onClick={() =>
|
|
setMembers((current) =>
|
|
current.filter((entry) => entry.userId !== member.userId),
|
|
)
|
|
}
|
|
className="rounded p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
<Icon name="trash" className="size-4" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{available.length > 0 ? (
|
|
<Field label="Mitglied hinzufügen" htmlFor="add-member">
|
|
<Select
|
|
id="add-member"
|
|
value=""
|
|
onChange={(event) => {
|
|
const user = users.find((u) => u.id === event.target.value);
|
|
if (!user) return;
|
|
setMembers((current) => [
|
|
...current,
|
|
{
|
|
userId: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: "EDITOR",
|
|
},
|
|
]);
|
|
}}
|
|
>
|
|
<option value="">Benutzer wählen …</option>
|
|
{available.map((user) => (
|
|
<option key={user.id} value={user.id}>
|
|
{user.name?.trim() || user.email}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
) : null}
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* -------------------------------------------------------- Löschen */}
|
|
<Modal
|
|
open={deleting !== null}
|
|
title="Gruppe 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="text-slate-900">{deleting.name}</strong> wird
|
|
gelöscht und alle Mitgliedschaften entfallen.
|
|
{deleting.codeCount > 0
|
|
? ` Die Gruppe enthält noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt.`
|
|
: " Sie enthält keine Codes."}
|
|
</p>
|
|
) : null}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|