feat: Phase 5 – Berechtigungssystem (User-Module-Zuweisung, Gateway-Access-Control)

This commit is contained in:
MPM Dev
2026-10-07 16:10:15 +02:00
parent 5d775c317a
commit 4d7610328c
19 changed files with 938 additions and 29 deletions

View File

@@ -0,0 +1,133 @@
import { type ReactNode, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/button';
import { Modal } from '../../components/ui/modal';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import {
fetchModules,
fetchUserModulePermissions,
grantModuleAccess,
revokeModuleAccess,
} from '../../lib/modules-api';
import type { Module, User } from '../../lib/schemas';
/**
* Dialog: Modul-Berechtigungen eines Benutzers verwalten.
* Zeigt alle installierten Module mit GRANTED/DENIED-Schaltern.
*/
export function UserPermissionsModal({
user,
onClose,
}: {
user: User;
onClose: () => void;
}): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const modulesQuery = useQuery({
queryKey: ['modules'],
queryFn: fetchModules,
});
const permissionsQuery = useQuery({
queryKey: ['user-module-permissions', user.id],
queryFn: () => fetchUserModulePermissions(user.id),
});
const [grantedModuleIds, setGrantedModuleIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (permissionsQuery.data) {
setGrantedModuleIds(
new Set(
permissionsQuery.data
.filter((permission) => permission.permission === 'GRANTED')
.map((permission) => permission.moduleId),
),
);
}
}, [permissionsQuery.data]);
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: ['user-module-permissions', user.id] });
};
const toggleMutation = useMutation({
mutationFn: async (input: { module: Module; grant: boolean }) => {
if (input.grant) {
return grantModuleAccess(user.id, input.module.id);
}
return revokeModuleAccess(user.id, input.module.id);
},
onSuccess: (_result, variables) => {
showToast(
'success',
variables.grant
? `"${variables.module.name}" für ${user.username} freigegeben`
: `"${variables.module.name}" für ${user.username} entzogen`,
);
invalidate();
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen');
},
});
function handleToggle(module: Module): void {
const isGranted = grantedModuleIds.has(module.id);
toggleMutation.mutate({ module, grant: !isGranted });
}
return (
<Modal
open
title="Modul-Berechtigungen"
description={`Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`}
onClose={onClose}
>
{modulesQuery.isLoading || permissionsQuery.isLoading ? (
<p className="py-4 text-center text-sm text-slate-500">Wird geladen…</p>
) : modulesQuery.isError ? (
<p className="py-4 text-center text-sm text-red-600">
Module konnten nicht geladen werden.
</p>
) : modulesQuery.data && modulesQuery.data.length === 0 ? (
<p className="py-4 text-center text-sm text-slate-500">
Noch keine Module installiert.
</p>
) : (
<ul className="space-y-2">
{modulesQuery.data?.map((module) => {
const isGranted = grantedModuleIds.has(module.id);
return (
<li
key={module.id}
className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{module.name}</p>
<p className="text-xs text-slate-500">
/{module.slug} · Version {module.version}
</p>
</div>
<Button
size="sm"
variant={isGranted ? 'danger' : 'primary'}
loading={
toggleMutation.isPending &&
toggleMutation.variables?.module.id === module.id
}
onClick={() => handleToggle(module)}
>
{isGranted ? 'Entziehen' : 'Freigeben'}
</Button>
</li>
);
})}
</ul>
)}
</Modal>
);
}

View File

@@ -14,6 +14,7 @@ import {
resetUserPassword,
updateUser,
} from '../../lib/users-api';
import { UserPermissionsModal } from './user-permissions-modal';
import {
createUserSchema,
resetPasswordSchema,
@@ -370,6 +371,7 @@ export function UsersPage(): ReactNode {
const [editUser, setEditUser] = useState<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [permissionsUser, setPermissionsUser] = useState<User | null>(null);
const usersQuery = useQuery({
queryKey: ['users'],
@@ -466,6 +468,9 @@ export function UsersPage(): ReactNode {
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
Passwort
</Button>
<Button size="sm" variant="ghost" onClick={() => setPermissionsUser(user)}>
Module
</Button>
{user.id !== currentUser?.id && (
<>
<Button
@@ -511,6 +516,9 @@ export function UsersPage(): ReactNode {
{deleteTarget && (
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
)}
{permissionsUser && (
<UserPermissionsModal user={permissionsUser} onClose={() => setPermissionsUser(null)} />
)}
</div>
);
}

View File

@@ -2,16 +2,22 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client';
import { fetchAccessibleModules } from '../../lib/modules-api';
import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Dashboard: Begrüßung, eigene Anwendungen (ab Phase 3) und Systemstatus. */
/** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */
export function DashboardPage(): ReactNode {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const modulesQuery = useQuery({
queryKey: ['accessible-modules'],
queryFn: fetchAccessibleModules,
});
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> => healthSchema.parse(
@@ -32,18 +38,55 @@ export function DashboardPage(): ReactNode {
</p>
</div>
{/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */}
{/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
<Card>
<CardHeader
title="Meine Anwendungen"
description="Freigegebene Module der Plattform"
/>
<CardBody>
<EmptyState
title="Noch keine Module installiert"
description="Sobald der Administrator Module installiert und Ihnen zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
{modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />}
{modulesQuery.isError && (
<ErrorState
title="Anwendungen nicht verfügbar"
message="Die Module konnten nicht geladen werden."
/>
)}
{modulesQuery.data && modulesQuery.data.length === 0 && (
<EmptyState
title="Noch keine Module freigegeben"
description="Sobald der Administrator Ihnen Module zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
)}
{modulesQuery.data && modulesQuery.data.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{modulesQuery.data.map((module) => (
<a
key={module.id}
href={`/${module.slug}`}
className="group rounded-xl border border-slate-200 p-5 transition-colors hover:border-brand-300 hover:bg-brand-50/50"
>
<div className="flex items-start justify-between">
<span className="text-2xl" aria-hidden="true">🧩</span>
<Badge variant={module.status === 'RUNNING' ? 'success' : 'neutral'}>
{module.status === 'RUNNING' ? 'Verfügbar' : module.status}
</Badge>
</div>
<h3 className="mt-3 text-base font-semibold text-slate-900 group-hover:text-brand-700">
{module.name}
</h3>
{module.description && (
<p className="mt-1 text-sm text-slate-500">{module.description}</p>
)}
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-600">
Öffnen
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
</span>
</a>
))}
</div>
)}
</CardBody>
</Card>

View File

@@ -1,5 +1,12 @@
import { apiRequest, ApiError } from './api-client';
import { moduleSchema, type Module } from './schemas';
import {
accessibleModuleSchema,
modulePermissionSchema,
moduleSchema,
type AccessibleModule,
type Module,
type ModulePermission,
} from './schemas';
/** Typsichere API-Funktionen für die Modul-Verwaltung. */
@@ -88,6 +95,30 @@ export async function checkModuleHealth(
return apiRequest(`/api/v1/modules/${id}/health`);
}
/** Module, die der angemeldete Benutzer sehen darf (Dashboard). */
export async function fetchAccessibleModules(): Promise<AccessibleModule[]> {
const response = await apiRequest<{ modules: unknown[] }>('/api/v1/profile/modules');
return response.modules.map((module) => accessibleModuleSchema.parse(module));
}
/** Modul-Berechtigungen eines Benutzers (Admin). */
export async function fetchUserModulePermissions(userId: string): Promise<ModulePermission[]> {
const response = await apiRequest<{ permissions: unknown[]}>(
`/api/v1/users/${userId}/modules`,
);
return response.permissions.map((permission) => modulePermissionSchema.parse(permission));
}
/** Modul-Zugriff gewähren (Admin). */
export async function grantModuleAccess(userId: string, moduleId: string): Promise<void> {
await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'POST' });
}
/** Modul-Zugriff entziehen (Admin). */
export async function revokeModuleAccess(userId: string, moduleId: string): Promise<void> {
await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'DELETE' });
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {

View File

@@ -135,4 +135,24 @@ export const moduleSchema = z.object({
enabled: z.boolean(),
createdAt: z.string(),
});
export type Module = z.infer<typeof moduleSchema>;
export type Module = z.infer<typeof moduleSchema>;
/** Modul-Kachel für das Dashboard (/api/v1/profile/modules). */
export const accessibleModuleSchema = z.object({
id: z.string(),
moduleId: z.string(),
name: z.string(),
slug: z.string(),
description: z.string(),
status: z.enum(MODULE_STATUSES),
});
export type AccessibleModule = z.infer<typeof accessibleModuleSchema>;
/** Modul-Berechtigung eines Benutzers. */
export const modulePermissionSchema = z.object({
moduleId: z.string(),
moduleSlug: z.string(),
moduleName: z.string(),
permission: z.enum(['GRANTED', 'DENIED']),
});
export type ModulePermission = z.infer<typeof modulePermissionSchema>;