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.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
|
||||
function useSubmit() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (json: unknown) => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiJson("/api/auth", { method: "POST", json });
|
||||
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
|
||||
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
|
||||
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Anmeldung ist fehlgeschlagen.",
|
||||
);
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { submit, pending, error, router };
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const { submit, pending, error } = useSubmit();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit({ action: "login", email, password });
|
||||
}}
|
||||
>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
|
||||
<Field label="E-Mail-Adresse" htmlFor="login-email">
|
||||
<TextInput
|
||||
id="login-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={email}
|
||||
disabled={pending}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Passwort" htmlFor="login-password">
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird angemeldet …" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupForm({ minLength }: { minLength: number }) {
|
||||
const { submit, pending, error } = useSubmit();
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
|
||||
const mismatch = repeat.length > 0 && password !== repeat;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (mismatch) return;
|
||||
void submit({ action: "setup", email, name, password });
|
||||
}}
|
||||
>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
|
||||
<Field label="Name (optional)" htmlFor="setup-name">
|
||||
<TextInput
|
||||
id="setup-name"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
disabled={pending}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="E-Mail-Adresse" htmlFor="setup-email">
|
||||
<TextInput
|
||||
id="setup-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
disabled={pending}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Passwort"
|
||||
htmlFor="setup-password"
|
||||
hint={`Mindestens ${minLength} Zeichen.`}
|
||||
>
|
||||
<TextInput
|
||||
id="setup-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={minLength}
|
||||
value={password}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Passwort wiederholen"
|
||||
htmlFor="setup-repeat"
|
||||
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||
>
|
||||
<TextInput
|
||||
id="setup-repeat"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
invalid={mismatch}
|
||||
value={repeat}
|
||||
disabled={pending}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" disabled={pending || mismatch}>
|
||||
{pending ? "Wird angelegt …" : "Administrator anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await apiJson("/api/auth", {
|
||||
method: "POST",
|
||||
json: { action: "logout" },
|
||||
});
|
||||
} finally {
|
||||
router.replace("/anmelden");
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "…" : "Abmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
"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 AdminUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
isActive: boolean;
|
||||
isLocked: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
activeSessions: number;
|
||||
memberships: { groupId: string; groupName: string; role: string }[];
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value ? dateFormat.format(new Date(value)) : "—";
|
||||
|
||||
export function UserAdmin({
|
||||
minPasswordLength,
|
||||
currentUserId,
|
||||
}: {
|
||||
minPasswordLength: number;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
const [users, setUsers] = useState<AdminUser[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
name: "",
|
||||
password: "",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [resetting, setResetting] = useState<AdminUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [deleting, setDeleting] = useState<AdminUser | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiJson<{ users: AdminUser[] }>("/api/admin/users");
|
||||
setUsers(result.users);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setUsers([]);
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Benutzer 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 act = async (id: string, body: unknown, onDone?: () => void) => {
|
||||
setBusy(id);
|
||||
setError(null);
|
||||
try {
|
||||
await apiJson(`/api/admin/users/${id}`, { method: "PATCH", json: body });
|
||||
await load();
|
||||
onDone?.();
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Änderung konnte nicht gespeichert werden.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setFormError(null);
|
||||
try {
|
||||
await apiJson("/api/admin/users", { method: "POST", json: form });
|
||||
setCreating(false);
|
||||
setForm({ email: "", name: "", password: "", isInstanceAdmin: false });
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setFormError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Der Benutzer konnte nicht angelegt werden.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
setBusy(deleting.id);
|
||||
try {
|
||||
await apiJson(`/api/admin/users/${deleting.id}`, { method: "DELETE" });
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Der Benutzer konnte nicht gelöscht werden.",
|
||||
);
|
||||
setDeleting(null);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (users === null) {
|
||||
return <p className="text-sm text-slate-500">Benutzer werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => setCreating(true)}>
|
||||
Benutzer anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Es gibt noch keine Benutzer. Solange die Anmeldung ausgeschaltet
|
||||
ist, wird auch keiner gebraucht.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{user.name?.trim() || user.email}
|
||||
</span>
|
||||
{user.isInstanceAdmin ? (
|
||||
<Badge tone="popular">Administrator</Badge>
|
||||
) : null}
|
||||
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
|
||||
{user.isLocked ? (
|
||||
<Badge tone="neutral">vorübergehend gesperrt</Badge>
|
||||
) : null}
|
||||
{user.id === currentUserId ? (
|
||||
<Badge tone="success">das bist du</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{user.name?.trim() ? (
|
||||
<p className="mt-0.5 text-sm text-slate-500">{user.email}</p>
|
||||
) : null}
|
||||
|
||||
<dl className="mt-2.5 flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
|
||||
<span>Letzte Anmeldung: {formatDate(user.lastLoginAt)}</span>
|
||||
<span>Angelegt: {formatDate(user.createdAt)}</span>
|
||||
{user.activeSessions > 0 ? (
|
||||
<span>
|
||||
{user.activeSessions} offene{" "}
|
||||
{user.activeSessions === 1 ? "Sitzung" : "Sitzungen"}
|
||||
</span>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{user.memberships.length === 0
|
||||
? "In keiner Gruppe."
|
||||
: user.memberships
|
||||
.map((m) => `${m.groupName} (${m.role})`)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy === user.id}
|
||||
onClick={() => {
|
||||
setResetting(user);
|
||||
setNewPassword("");
|
||||
}}
|
||||
>
|
||||
<Icon name="lock" className="size-3.5" />
|
||||
Passwort setzen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy === user.id}
|
||||
onClick={() =>
|
||||
void act(user.id, { isActive: !user.isActive })
|
||||
}
|
||||
>
|
||||
{user.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy === user.id}
|
||||
onClick={() =>
|
||||
void act(user.id, { isInstanceAdmin: !user.isInstanceAdmin })
|
||||
}
|
||||
>
|
||||
{user.isInstanceAdmin
|
||||
? "Adminrechte entziehen"
|
||||
: "Zum Administrator machen"}
|
||||
</Button>
|
||||
{user.isLocked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy === user.id}
|
||||
onClick={() => void act(user.id, { unlock: true })}
|
||||
>
|
||||
Sperre aufheben
|
||||
</Button>
|
||||
) : null}
|
||||
{user.id !== currentUserId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
disabled={busy === user.id}
|
||||
onClick={() => setDeleting(user)}
|
||||
>
|
||||
<Icon name="trash" className="size-3.5" />
|
||||
Löschen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* ------------------------------------------------------- Anlegen */}
|
||||
<Modal
|
||||
open={creating}
|
||||
title="Benutzer 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="E-Mail-Adresse" htmlFor="new-user-email">
|
||||
<TextInput
|
||||
id="new-user-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, email: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Name (optional)" htmlFor="new-user-name">
|
||||
<TextInput
|
||||
id="new-user-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Passwort"
|
||||
htmlFor="new-user-password"
|
||||
hint={`Mindestens ${minPasswordLength} Zeichen. Gib es der Person weiter — sie kann es später nicht selbst ändern, aber ein Administrator kann es neu setzen.`}
|
||||
>
|
||||
<TextInput
|
||||
id="new-user-password"
|
||||
type="text"
|
||||
value={form.password}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, password: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Checkbox
|
||||
id="new-user-admin"
|
||||
label="Administrator der Installation"
|
||||
hint="Darf Benutzer, Gruppen und QR-Typen verwalten."
|
||||
checked={form.isInstanceAdmin}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, isInstanceAdmin: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ---------------------------------------------- Passwort setzen */}
|
||||
<Modal
|
||||
open={resetting !== null}
|
||||
title="Passwort setzen"
|
||||
onClose={() => setResetting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => setResetting(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={newPassword.length < minPasswordLength}
|
||||
onClick={() =>
|
||||
resetting &&
|
||||
void act(resetting.id, { password: newPassword }, () =>
|
||||
setResetting(null),
|
||||
)
|
||||
}
|
||||
>
|
||||
Passwort setzen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{resetting ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Neues Passwort für{" "}
|
||||
<strong className="text-slate-900">{resetting.email}</strong>.
|
||||
Alle offenen Sitzungen dieses Benutzers werden dabei beendet.
|
||||
</p>
|
||||
<Field
|
||||
label="Neues Passwort"
|
||||
htmlFor="reset-password"
|
||||
hint={`Mindestens ${minPasswordLength} Zeichen.`}
|
||||
>
|
||||
<TextInput
|
||||
id="reset-password"
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* -------------------------------------------------------- Löschen */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title="Benutzer 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.email}</strong> wird
|
||||
gelöscht und aus allen Gruppen entfernt. Die von dieser Person
|
||||
angelegten QR-Codes bleiben bestehen — sie verlieren nur den
|
||||
Vermerk, wer sie erstellt hat.
|
||||
</p>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user