RBAC-05: web/rbac-admin next.js-frontend (rollen+gruppen-verwaltung, audit-verlauf) auf shl-01
This commit is contained in:
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function IndexPage() {
|
||||||
|
redirect("/roles");
|
||||||
|
}
|
||||||
@@ -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 & 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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = "user" | "tenant_admin";
|
||||||
|
|
||||||
|
export interface RoleInfo {
|
||||||
|
role: Role;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRoles(): Promise<RoleInfo[]> {
|
||||||
|
return req("/rbac/roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Assignment {
|
||||||
|
UserID: string;
|
||||||
|
Role: Role;
|
||||||
|
GrantedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignRole(userId: string, role: Role): Promise<Assignment> {
|
||||||
|
return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRoleHistory(userId: string): Promise<Assignment[]> {
|
||||||
|
return req(`/rbac/users/${userId}/history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
Role: Role | "";
|
||||||
|
members: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGroups(): Promise<Group[]> {
|
||||||
|
return req("/rbac/groups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup(name: string): Promise<Group> {
|
||||||
|
return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGroupRole(groupId: string, role: Role): Promise<void> {
|
||||||
|
return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addGroupMember(groupId: string, userId: string): Promise<void> {
|
||||||
|
return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGroupMember(groupId: string, userId: string): Promise<void> {
|
||||||
|
return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@nexarch/shl"],
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "nexarch-rbac-admin",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nexarch/shl": "file:../shl",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.14.9",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"typescript": "5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user