feat: Phase 5 – Berechtigungssystem (User-Module-Zuweisung, Gateway-Access-Control)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(';')) {
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user