Files
qrcode-generator/src/components/group-admin.tsx
T
Florian Wilde f1cdb3da13 Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
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.
2026-08-14 12:31:16 +02:00

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>
);
}