544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import { Edit, Eye, Shield, Trash2 } from "lucide-react";
|
|
|
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
|
import SearchInput from "@/components/common/SearchInput";
|
|
import { useToast } from "@/components/common/ToastProvider";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { api } from "@/lib/api";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import type { CurrentUser, Permission, Role, RolePayload } from "@/types/rbac";
|
|
|
|
const pageSize = 10;
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
|
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
return "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function getSortableValue(role: Role, key: string) {
|
|
const value = role[key as keyof Role];
|
|
return typeof value === "string" ? value.toLowerCase() : value;
|
|
}
|
|
|
|
export default function RolesPage() {
|
|
const { showToast } = useToast();
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [formError, setFormError] = useState("");
|
|
const [deleteError, setDeleteError] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [sortKey, setSortKey] = useState("name");
|
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
|
const [page, setPage] = useState(1);
|
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
|
const [detailsRole, setDetailsRole] = useState<Role | null>(null);
|
|
const [deleteRole, setDeleteRole] = useState<Role | null>(null);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
|
|
const loadRoles = useCallback(async () => {
|
|
try {
|
|
const [rolesResponse, permissionsResponse, meResponse] = await Promise.all([
|
|
api.get<Role[]>("/roles"),
|
|
api.get<Permission[]>("/permissions"),
|
|
api.get<CurrentUser>("/me"),
|
|
]);
|
|
setRoles(rolesResponse.data);
|
|
setPermissions(permissionsResponse.data);
|
|
setCurrentUser(meResponse.data);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadRoles();
|
|
});
|
|
}, [loadRoles]);
|
|
|
|
const filteredRoles = useMemo(() => {
|
|
const term = search.trim().toLowerCase();
|
|
|
|
return roles
|
|
.filter((role) => {
|
|
if (!term) {
|
|
return true;
|
|
}
|
|
|
|
return [
|
|
role.name,
|
|
role.display_name,
|
|
role.description,
|
|
].some((value) => value.toLowerCase().includes(term));
|
|
})
|
|
.sort((first, second) => {
|
|
const firstValue = getSortableValue(first, sortKey);
|
|
const secondValue = getSortableValue(second, sortKey);
|
|
|
|
if (firstValue === secondValue) {
|
|
return 0;
|
|
}
|
|
|
|
const result = firstValue > secondValue ? 1 : -1;
|
|
return sortDirection === "asc" ? result : -result;
|
|
});
|
|
}, [roles, search, sortDirection, sortKey]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredRoles.length / pageSize));
|
|
const currentPage = Math.min(page, pageCount);
|
|
const pageRoles = filteredRoles.slice(
|
|
(currentPage - 1) * pageSize,
|
|
currentPage * pageSize,
|
|
);
|
|
|
|
const columns: DataTableColumn<Role>[] = [
|
|
{
|
|
key: "display_name",
|
|
label: "Rolle",
|
|
sortable: true,
|
|
render: (role) => (
|
|
<div>
|
|
<p className="font-medium text-slate-950">{role.display_name}</p>
|
|
<p className="text-xs text-slate-500">{role.name}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "description",
|
|
label: "Beschreibung",
|
|
sortable: true,
|
|
render: (role) => role.description || "-",
|
|
},
|
|
{
|
|
key: "is_system",
|
|
label: "Typ",
|
|
sortable: true,
|
|
render: (role) => (
|
|
<span className="inline-flex h-6 items-center rounded-full bg-slate-100 px-2 text-xs font-medium text-slate-700">
|
|
{role.is_system ? "Systemrolle" : "Individuell"}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "permissions",
|
|
label: "Berechtigungen",
|
|
render: (role) => `${role.permissions.length}`,
|
|
},
|
|
{
|
|
key: "created_at",
|
|
label: "Erstellt",
|
|
sortable: true,
|
|
render: (role) => formatDate(role.created_at),
|
|
},
|
|
{
|
|
key: "actions",
|
|
label: "Aktionen",
|
|
className: "px-4 py-3 text-right",
|
|
render: (role) => (
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setDetailsRole(role)} title="Details">
|
|
<Eye size={16} />
|
|
</Button>
|
|
{hasPermission(currentUser, "roles.update") && (
|
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => openEditDialog(role)} title="Bearbeiten">
|
|
<Edit size={16} />
|
|
</Button>
|
|
)}
|
|
{hasPermission(currentUser, "roles.delete") && !role.is_system && (
|
|
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteRole(role)} title="Löschen">
|
|
<Trash2 size={16} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
function handleSort(key: string) {
|
|
if (sortKey === key) {
|
|
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
|
return;
|
|
}
|
|
|
|
setSortKey(key);
|
|
setSortDirection("asc");
|
|
}
|
|
|
|
function openCreateDialog() {
|
|
setSelectedRole(null);
|
|
setFormError("");
|
|
setFormOpen(true);
|
|
}
|
|
|
|
function openEditDialog(role: Role) {
|
|
setSelectedRole(role);
|
|
setFormError("");
|
|
setFormOpen(true);
|
|
}
|
|
|
|
async function saveRole(payload: RolePayload) {
|
|
setPending(true);
|
|
setFormError("");
|
|
|
|
try {
|
|
if (selectedRole) {
|
|
const response = await api.put<Role>(`/roles/${selectedRole.id}`, payload);
|
|
setRoles((current) => current.map((role) => (
|
|
role.id === selectedRole.id ? response.data : role
|
|
)));
|
|
showToast({ type: "success", title: "Rolle aktualisiert", description: response.data.display_name });
|
|
} else {
|
|
const response = await api.post<Role>("/roles", payload);
|
|
setRoles((current) => [response.data, ...current]);
|
|
showToast({ type: "success", title: "Rolle erstellt", description: response.data.display_name });
|
|
}
|
|
|
|
setFormOpen(false);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setFormError(message);
|
|
showToast({ type: "error", title: "Rolle konnte nicht gespeichert werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteRole) {
|
|
return;
|
|
}
|
|
|
|
setPending(true);
|
|
setDeleteError("");
|
|
|
|
try {
|
|
await api.delete(`/roles/${deleteRole.id}`);
|
|
setRoles((current) => current.filter((role) => role.id !== deleteRole.id));
|
|
showToast({ type: "success", title: "Rolle gelöscht", description: deleteRole.display_name });
|
|
setDeleteRole(null);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setDeleteError(message);
|
|
showToast({ type: "error", title: "Rolle konnte nicht gelöscht werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-slate-950">Rollen & Rechte</h1>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
{filteredRoles.length} von {roles.length} Rollen
|
|
</p>
|
|
</div>
|
|
|
|
{hasPermission(currentUser, "roles.create") && (
|
|
<Button type="button" onClick={openCreateDialog}>
|
|
<Shield size={16} />
|
|
Neue Rolle
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-white p-4">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={(value) => {
|
|
setSearch(value);
|
|
setPage(1);
|
|
}}
|
|
placeholder="Rollen suchen"
|
|
/>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
rows={pageRoles}
|
|
rowKey={(role) => role.id}
|
|
sortKey={sortKey}
|
|
sortDirection={sortDirection}
|
|
loading={loading}
|
|
error={error}
|
|
emptyTitle="Keine Rollen gefunden"
|
|
emptyDescription="Passe Suche oder Filter an oder erstelle eine neue Rolle."
|
|
onSort={handleSort}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
|
<span>
|
|
Seite {currentPage} von {pageCount}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={currentPage === 1}
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
>
|
|
Zurück
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={currentPage === pageCount}
|
|
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<RoleFormDialog
|
|
key={selectedRole?.id ?? "new"}
|
|
open={formOpen}
|
|
role={selectedRole}
|
|
permissions={permissions}
|
|
pending={pending}
|
|
serverError={formError}
|
|
onOpenChange={setFormOpen}
|
|
onSubmit={saveRole}
|
|
/>
|
|
|
|
<RoleDetailDialog
|
|
role={detailsRole}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDetailsRole(null);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteRole)}
|
|
title="Rolle löschen"
|
|
description="Möchten Sie diese Rolle wirklich löschen?"
|
|
pending={pending}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteRole(null);
|
|
setDeleteError("");
|
|
}
|
|
}}
|
|
onConfirm={confirmDelete}
|
|
>
|
|
{deleteRole && (
|
|
<dl className="grid gap-2 text-sm">
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-slate-500">Name</dt>
|
|
<dd className="font-medium">{deleteRole.display_name}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-slate-500">Systemrolle</dt>
|
|
<dd className="font-medium">{deleteRole.is_system ? "Ja" : "Nein"}</dd>
|
|
</div>
|
|
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
|
</dl>
|
|
)}
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoleFormDialog({
|
|
open,
|
|
role,
|
|
permissions,
|
|
pending,
|
|
serverError,
|
|
onOpenChange,
|
|
onSubmit,
|
|
}: {
|
|
open: boolean;
|
|
role: Role | null;
|
|
permissions: Permission[];
|
|
pending: boolean;
|
|
serverError?: string;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (payload: RolePayload) => Promise<void>;
|
|
}) {
|
|
const initialPermissionIds = role?.permissions.map((permission) => permission.id) ?? [];
|
|
const [name, setName] = useState(role?.name ?? "");
|
|
const [displayName, setDisplayName] = useState(role?.display_name ?? "");
|
|
const [description, setDescription] = useState(role?.description ?? "");
|
|
const [permissionIds, setPermissionIds] = useState<number[]>(initialPermissionIds);
|
|
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
|
|
const groupedPermissions = permissions.reduce<Record<string, Permission[]>>((groups, permission) => {
|
|
groups[permission.module] = groups[permission.module] ?? [];
|
|
groups[permission.module].push(permission);
|
|
return groups;
|
|
}, {});
|
|
|
|
const valid = name.trim().length >= 3 && displayName.trim().length >= 2;
|
|
|
|
function togglePermission(permissionId: number) {
|
|
setPermissionIds((current) => (
|
|
current.includes(permissionId)
|
|
? current.filter((id) => id !== permissionId)
|
|
: [...current, permissionId]
|
|
));
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
|
|
await onSubmit({
|
|
name,
|
|
display_name: displayName,
|
|
description,
|
|
permission_ids: permissionIds,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-3xl">
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<DialogHeader>
|
|
<DialogTitle>{role ? "Rolle bearbeiten" : "Rolle erstellen"}</DialogTitle>
|
|
<DialogDescription>
|
|
Rollenstammdaten und Berechtigungen verwalten.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label>Technischer Name</Label>
|
|
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={role?.is_system} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Anzeigename</Label>
|
|
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label>Beschreibung</Label>
|
|
<Input value={description} onChange={(event) => setDescription(event.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-80 overflow-auto rounded-lg border">
|
|
{Object.entries(groupedPermissions).map(([module, modulePermissions]) => (
|
|
<div key={module} className="border-b p-4 last:border-b-0">
|
|
<h3 className="mb-3 text-sm font-semibold capitalize text-slate-900">{module}</h3>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{modulePermissions.map((permission) => (
|
|
<label key={permission.id} className="flex items-start gap-2 rounded border p-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={permissionIds.includes(permission.id)}
|
|
onChange={() => togglePermission(permission.id)}
|
|
/>
|
|
<span>
|
|
<span className="block font-medium">{permission.display_name}</span>
|
|
<span className="block text-xs text-slate-500">{permission.name}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="submit" disabled={!valid || pending}>
|
|
{pending ? "Speichern..." : "Speichern"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function RoleDetailDialog({
|
|
role,
|
|
onOpenChange,
|
|
}: {
|
|
role: Role | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
return (
|
|
<Dialog open={Boolean(role)} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
{role && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{role.display_name}</DialogTitle>
|
|
<DialogDescription>{role.description || role.name}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
|
<div>
|
|
<p className="text-slate-500">Technischer Name</p>
|
|
<p className="font-medium">{role.name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-500">Typ</p>
|
|
<p className="font-medium">{role.is_system ? "Systemrolle" : "Individuell"}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium">Berechtigungen</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{role.permissions.map((permission) => (
|
|
<span key={permission.id} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-700">
|
|
{permission.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|