diff --git a/web/rbac-admin/app/groups/page.tsx b/web/rbac-admin/app/groups/page.tsx new file mode 100644 index 0000000..229206d --- /dev/null +++ b/web/rbac-admin/app/groups/page.tsx @@ -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([]); + const [loadError, setLoadError] = useState(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(null); + + async function onCreate(event: React.FormEvent) { + 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 ( +
+

{loadError}

+
+ ); + } + + return ( +
+

Gruppen & Abteilungen

+ +
+

Neue Gruppe anlegen

+
+ setNewGroupName(e.target.value)} + /> + {createError && ( +

+ {createError} +

+ )} + + +
+ +
+

Bestehende Gruppen

+ {groups.length === 0 ? ( +

Keine Gruppen vorhanden.

+ ) : ( + groups.map((g) => push(t, "success")} />) + )} +
+
+ ); +} + +function GroupCard({ + group, + onChanged, + onNotice, +}: { + group: Group; + onChanged: () => void; + onNotice: (text: string) => void; +}) { + const [role, setRole] = useState(group.Role || "user"); + const [newMemberId, setNewMemberId] = useState(""); + const [error, setError] = useState(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) { + 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 ( +
+

{group.Name}

+ +
+ setRole(e.target.value as Role)}> + + + + +
+ +

Mitglieder

+ {group.members.length === 0 ? ( +

Keine Mitglieder.

+ ) : ( +
    + {group.members.map((userId) => ( +
  • + {userId}{" "} + +
  • + ))} +
+ )} + +
+ setNewMemberId(e.target.value)} + /> + + + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/web/rbac-admin/app/layout.tsx b/web/rbac-admin/app/layout.tsx new file mode 100644 index 0000000..7390a13 --- /dev/null +++ b/web/rbac-admin/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/web/rbac-admin/app/page.tsx b/web/rbac-admin/app/page.tsx new file mode 100644 index 0000000..36a566d --- /dev/null +++ b/web/rbac-admin/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function IndexPage() { + redirect("/roles"); +} diff --git a/web/rbac-admin/app/roles/page.tsx b/web/rbac-admin/app/roles/page.tsx new file mode 100644 index 0000000..2757f8c --- /dev/null +++ b/web/rbac-admin/app/roles/page.tsx @@ -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(null); + const [loadError, setLoadError] = useState(null); + + const [targetUserId, setTargetUserId] = useState(""); + const [targetRole, setTargetRole] = useState("user"); + const [assignError, setAssignError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const [historyUserId, setHistoryUserId] = useState(""); + const [history, setHistory] = useState(null); + + useEffect(() => { + fetchRoles() + .then(setRoles) + .catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?")); + }, []); + + async function onAssign(event: React.FormEvent) { + 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) { + event.preventDefault(); + try { + setHistory(await fetchRoleHistory(historyUserId)); + } catch { + setHistory([]); + } + } + + const historyColumns: TableColumn[] = [ + { key: "role", header: "Rolle", render: (a) => a.Role }, + { key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy }, + ]; + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + return ( +
+

Rollen & Rechte

+ +
+

Rollen und Grundrechte

+ {roles?.map((r) => ( +
+ {roleLabel(r.role)} +
    + {r.permissions.map((p) => ( +
  • {p}
  • + ))} +
+
+ ))} +
+ +
+

Rolle zuweisen

+
+ setTargetUserId(e.target.value)} + /> + setTargetRole(e.target.value as Role)}> + + + + {assignError && ( +

+ {assignError} +

+ )} + + +
+ +
+

Nachvollziehbarkeit (Audit-Verlauf)

+

Zeigt jede Rollenzuweisung eines Benutzers chronologisch — wer wann welche Rolle vergeben hat.

+
+ setHistoryUserId(e.target.value)} + /> + + + {history && ( + `${a.UserID}-${a.Role}-${a.GrantedBy}`} + caption="Rollenzuweisungs-Verlauf" + /> + )} + + + ); +} + +function roleLabel(role: Role): string { + return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer"; +} diff --git a/web/rbac-admin/lib/api.ts b/web/rbac-admin/lib/api.ts new file mode 100644 index 0000000..8ab70dd --- /dev/null +++ b/web/rbac-admin/lib/api.ts @@ -0,0 +1,68 @@ +const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? ""; + +export class ApiError extends Error {} + +async function req(path: string, init?: RequestInit): Promise { + 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 { + return req("/rbac/roles"); +} + +export interface Assignment { + UserID: string; + Role: Role; + GrantedBy: string; +} + +export function assignRole(userId: string, role: Role): Promise { + return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) }); +} + +export function fetchRoleHistory(userId: string): Promise { + return req(`/rbac/users/${userId}/history`); +} + +export interface Group { + ID: string; + Name: string; + Role: Role | ""; + members: string[]; +} + +export function fetchGroups(): Promise { + return req("/rbac/groups"); +} + +export function createGroup(name: string): Promise { + return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) }); +} + +export function setGroupRole(groupId: string, role: Role): Promise { + return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) }); +} + +export function addGroupMember(groupId: string, userId: string): Promise { + return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) }); +} + +export function removeGroupMember(groupId: string, userId: string): Promise { + return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) }); +} diff --git a/web/rbac-admin/next.config.mjs b/web/rbac-admin/next.config.mjs new file mode 100644 index 0000000..aa96725 --- /dev/null +++ b/web/rbac-admin/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/rbac-admin/package.json b/web/rbac-admin/package.json new file mode 100644 index 0000000..922d9a7 --- /dev/null +++ b/web/rbac-admin/package.json @@ -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" + } +} diff --git a/web/rbac-admin/tsconfig.json b/web/rbac-admin/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/rbac-admin/tsconfig.json @@ -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"] +}