RBAC-05: web/rbac-admin next.js-frontend (rollen+gruppen-verwaltung, audit-verlauf) auf shl-01

This commit is contained in:
sysops
2026-08-28 23:43:09 +02:00
parent bd80f0cb16
commit fbcc9db8f1
8 changed files with 476 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useEffect, useState } from "react";
import { TextField, SelectField, useToast } from "@nexarch/shl";
import {
ApiError,
Group,
Role,
addGroupMember,
createGroup,
fetchGroups,
removeGroupMember,
setGroupRole,
} from "../../lib/api";
export default function GroupsPage() {
const { push } = useToast();
const [groups, setGroups] = useState<Group[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const reload = () =>
fetchGroups()
.then(setGroups)
.catch(() => setLoadError("Gruppen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
useEffect(() => {
reload();
}, []);
const [newGroupName, setNewGroupName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
async function onCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setCreateError(null);
try {
await createGroup(newGroupName);
setNewGroupName("");
push("Gruppe angelegt.", "success");
await reload();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Gruppe konnte nicht angelegt werden.");
}
}
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Gruppen &amp; Abteilungen</h1>
<section aria-labelledby="neue-gruppe-heading">
<h2 id="neue-gruppe-heading">Neue Gruppe anlegen</h2>
<form onSubmit={onCreate} noValidate>
<TextField
label="Gruppenname"
required
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
{createError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{createError}
</p>
)}
<button type="submit">Anlegen</button>
</form>
</section>
<section aria-labelledby="gruppenliste-heading">
<h2 id="gruppenliste-heading">Bestehende Gruppen</h2>
{groups.length === 0 ? (
<p>Keine Gruppen vorhanden.</p>
) : (
groups.map((g) => <GroupCard key={g.ID} group={g} onChanged={reload} onNotice={(t) => push(t, "success")} />)
)}
</section>
</main>
);
}
function GroupCard({
group,
onChanged,
onNotice,
}: {
group: Group;
onChanged: () => void;
onNotice: (text: string) => void;
}) {
const [role, setRole] = useState<Role>(group.Role || "user");
const [newMemberId, setNewMemberId] = useState("");
const [error, setError] = useState<string | null>(null);
async function onSetRole() {
setError(null);
try {
await setGroupRole(group.ID, role);
onNotice("Gruppenrolle gesetzt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Gruppenrolle konnte nicht gesetzt werden.");
}
}
async function onAddMember(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
try {
await addGroupMember(group.ID, newMemberId);
setNewMemberId("");
onNotice("Mitglied hinzugefügt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht hinzugefügt werden.");
}
}
async function onRemoveMember(userId: string) {
setError(null);
try {
await removeGroupMember(group.ID, userId);
onNotice("Mitglied entfernt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht entfernt werden.");
}
}
return (
<div style={{ border: "1px solid var(--shl-color-border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h3>{group.Name}</h3>
<div style={{ display: "flex", alignItems: "flex-end", gap: 8 }}>
<SelectField label="Gruppenrolle" value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
<button type="button" onClick={onSetRole}>
Setzen
</button>
</div>
<h4>Mitglieder</h4>
{group.members.length === 0 ? (
<p>Keine Mitglieder.</p>
) : (
<ul>
{group.members.map((userId) => (
<li key={userId}>
{userId}{" "}
<button type="button" onClick={() => onRemoveMember(userId)}>
Entfernen
</button>
</li>
))}
</ul>
)}
<form onSubmit={onAddMember} noValidate>
<TextField
label="Benutzer-ID hinzufügen"
required
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
/>
<button type="submit">Hinzufügen</button>
</form>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Rechteverwaltung",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/roles");
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { Table, TextField, SelectField, useToast } from "@nexarch/shl";
import type { TableColumn } from "@nexarch/shl";
import {
ApiError,
Assignment,
Role,
RoleInfo,
assignRole,
fetchRoleHistory,
fetchRoles,
} from "../../lib/api";
export default function RolesPage() {
const { push } = useToast();
const [roles, setRoles] = useState<RoleInfo[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [targetUserId, setTargetUserId] = useState("");
const [targetRole, setTargetRole] = useState<Role>("user");
const [assignError, setAssignError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [historyUserId, setHistoryUserId] = useState("");
const [history, setHistory] = useState<Assignment[] | null>(null);
useEffect(() => {
fetchRoles()
.then(setRoles)
.catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
}, []);
async function onAssign(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setAssignError(null);
setSubmitting(true);
try {
await assignRole(targetUserId, targetRole);
push("Rolle zugewiesen.", "success");
} catch (err) {
// Akzeptanzkriterium 2: die Backend-Meldung bei Selbst-Eskalation wird
// unveraendert angezeigt, keine eigene Interpretation im Frontend.
setAssignError(err instanceof ApiError ? err.message : "Rolle konnte nicht zugewiesen werden.");
} finally {
setSubmitting(false);
}
}
async function onLoadHistory(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
setHistory(await fetchRoleHistory(historyUserId));
} catch {
setHistory([]);
}
}
const historyColumns: TableColumn<Assignment>[] = [
{ key: "role", header: "Rolle", render: (a) => a.Role },
{ key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy },
];
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Rollen &amp; Rechte</h1>
<section aria-labelledby="rollen-heading">
<h2 id="rollen-heading">Rollen und Grundrechte</h2>
{roles?.map((r) => (
<div key={r.role} style={{ marginBottom: 12 }}>
<strong>{roleLabel(r.role)}</strong>
<ul>
{r.permissions.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
))}
</section>
<section aria-labelledby="zuweisen-heading">
<h2 id="zuweisen-heading">Rolle zuweisen</h2>
<form onSubmit={onAssign} noValidate>
<TextField
label="Benutzer-ID"
required
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
/>
<SelectField label="Rolle" value={targetRole} onChange={(e) => setTargetRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
{assignError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{assignError}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Wird zugewiesen…" : "Zuweisen"}
</button>
</form>
</section>
<section aria-labelledby="verlauf-heading">
<h2 id="verlauf-heading">Nachvollziehbarkeit (Audit-Verlauf)</h2>
<p>Zeigt jede Rollenzuweisung eines Benutzers chronologisch wer wann welche Rolle vergeben hat.</p>
<form onSubmit={onLoadHistory} noValidate>
<TextField
label="Benutzer-ID"
required
value={historyUserId}
onChange={(e) => setHistoryUserId(e.target.value)}
/>
<button type="submit">Verlauf anzeigen</button>
</form>
{history && (
<Table
columns={historyColumns}
rows={history}
rowKey={(a) => `${a.UserID}-${a.Role}-${a.GrantedBy}`}
caption="Rollenzuweisungs-Verlauf"
/>
)}
</section>
</main>
);
}
function roleLabel(role: Role): string {
return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer";
}