feat(rbac): add roles and permissions
This commit is contained in:
parent
86a32a942c
commit
694b7bd09a
37 changed files with 2682 additions and 218 deletions
7
frontend/athena/app/api/me/route.ts
Normal file
7
frontend/athena/app/api/me/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/auth/me");
|
||||
}
|
||||
7
frontend/athena/app/api/permissions/route.ts
Normal file
7
frontend/athena/app/api/permissions/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/permissions");
|
||||
}
|
||||
21
frontend/athena/app/api/roles/[id]/permissions/route.ts
Normal file
21
frontend/athena/app/api/roles/[id]/permissions/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/roles/${id}/permissions`);
|
||||
}
|
||||
39
frontend/athena/app/api/roles/[id]/route.ts
Normal file
39
frontend/athena/app/api/roles/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyRoleRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/roles/${id}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyRoleRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyRoleRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyRoleRequest(request, context);
|
||||
}
|
||||
18
frontend/athena/app/api/roles/route.ts
Normal file
18
frontend/athena/app/api/roles/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/roles");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/roles");
|
||||
}
|
||||
|
|
@ -1,14 +1,6 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
clearAuthCookie,
|
||||
getAccessToken,
|
||||
getHermesUrl,
|
||||
hermesJsonResponse,
|
||||
unauthorizedResponse,
|
||||
upstreamConfigurationErrorResponse,
|
||||
upstreamUnavailableResponse,
|
||||
} from "@/lib/server/hermes";
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
|
|
@ -18,48 +10,11 @@ type Params = {
|
|||
};
|
||||
|
||||
async function proxyUserRequest(request: NextRequest, { params }: Params) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const hermesUrl = getHermesUrl();
|
||||
|
||||
if (!hermesUrl) {
|
||||
return upstreamConfigurationErrorResponse();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const action = request.nextUrl.searchParams.get("action");
|
||||
const path = action === "password" ? `/users/${id}/password` : `/users/${id}`;
|
||||
|
||||
let hermesResponse: Response;
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: request.method === "GET" || request.method === "DELETE"
|
||||
? undefined
|
||||
: await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return upstreamUnavailableResponse();
|
||||
}
|
||||
|
||||
if (hermesResponse.status === 401) {
|
||||
const response = await hermesJsonResponse(hermesResponse);
|
||||
clearAuthCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
return hermesJsonResponse(hermesResponse);
|
||||
return proxyHermesRequest(request, path);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,10 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
clearAuthCookie,
|
||||
getAccessToken,
|
||||
getHermesUrl,
|
||||
hermesJsonResponse,
|
||||
unauthorizedResponse,
|
||||
upstreamConfigurationErrorResponse,
|
||||
upstreamUnavailableResponse,
|
||||
} from "@/lib/server/hermes";
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
async function proxyUsersRequest(request: NextRequest) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const hermesUrl = getHermesUrl();
|
||||
|
||||
if (!hermesUrl) {
|
||||
return upstreamConfigurationErrorResponse();
|
||||
}
|
||||
|
||||
let hermesResponse: Response;
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}/users${request.nextUrl.search}`, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: request.method === "GET" ? undefined : await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return upstreamUnavailableResponse();
|
||||
}
|
||||
|
||||
if (hermesResponse.status === 401) {
|
||||
const response = await hermesJsonResponse(hermesResponse);
|
||||
clearAuthCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
return hermesJsonResponse(hermesResponse);
|
||||
return proxyHermesRequest(request, `/users${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
535
frontend/athena/app/roles/page.tsx
Normal file
535
frontend/athena/app/roles/page.tsx
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
"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 { 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 [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
|
||||
)));
|
||||
} else {
|
||||
const response = await api.post<Role>("/roles", payload);
|
||||
setRoles((current) => [response.data, ...current]);
|
||||
}
|
||||
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
} 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));
|
||||
setDeleteRole(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import UserFormDialog from "@/components/users/UserFormDialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser, Role } from "@/types/rbac";
|
||||
import type { User, UserPayload } from "@/types/user";
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
|
|
@ -41,6 +43,8 @@ function formatDateTime(value: string) {
|
|||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
|
@ -52,8 +56,19 @@ export default function UserDetailPage() {
|
|||
|
||||
const loadUser = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<User>(`/users/${params.id}`);
|
||||
setUser(response.data);
|
||||
const [userResponse, meResponse] = await Promise.all([
|
||||
api.get<User>(`/users/${params.id}`),
|
||||
api.get<CurrentUser>("/me"),
|
||||
]);
|
||||
setUser(userResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
|
||||
if (hasPermission(meResponse.data, "roles.read")) {
|
||||
const rolesResponse = await api.get<Role[]>("/roles");
|
||||
setRoles(rolesResponse.data);
|
||||
} else {
|
||||
setRoles([userResponse.data.primary_role]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -147,14 +162,18 @@ export default function UserDetailPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
|
||||
<KeyRound size={16} />
|
||||
Passwort ändern
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setFormOpen(true)}>
|
||||
<Pencil size={16} />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
{hasPermission(currentUser, "users.password.update") && (
|
||||
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
|
||||
<KeyRound size={16} />
|
||||
Passwort ändern
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(currentUser, "users.update") && (
|
||||
<Button type="button" onClick={() => setFormOpen(true)}>
|
||||
<Pencil size={16} />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -165,7 +184,7 @@ export default function UserDetailPage() {
|
|||
<Detail label="Nachname" value={user.last_name || "-"} />
|
||||
<Detail label="Benutzername" value={user.username} />
|
||||
<Detail label="E-Mail" value={user.email} />
|
||||
<Detail label="Rolle" value={user.role} />
|
||||
<Detail label="Rolle" value={user.primary_role.display_name} />
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">Status</dt>
|
||||
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
|
||||
|
|
@ -180,6 +199,7 @@ export default function UserDetailPage() {
|
|||
user={user}
|
||||
pending={pending}
|
||||
serverError={formError}
|
||||
roles={roles}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={saveUser}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
|
|||
import UserFormDialog from "@/components/users/UserFormDialog";
|
||||
import UserTable from "@/components/users/UserTable";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasAllPermissions, hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser, Role } from "@/types/rbac";
|
||||
import type { User, UserPayload, UserRole } from "@/types/user";
|
||||
|
||||
const pageSize = 10;
|
||||
|
|
@ -31,6 +33,8 @@ function getSortableValue(user: User, key: string) {
|
|||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
|
@ -48,8 +52,22 @@ export default function UsersPage() {
|
|||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<User[]>("/users");
|
||||
setUsers(response.data);
|
||||
const [usersResponse, meResponse] = await Promise.all([
|
||||
api.get<User[]>("/users"),
|
||||
api.get<CurrentUser>("/me"),
|
||||
]);
|
||||
setUsers(usersResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
|
||||
if (hasPermission(meResponse.data, "roles.read")) {
|
||||
const rolesResponse = await api.get<Role[]>("/roles");
|
||||
setRoles(rolesResponse.data);
|
||||
} else {
|
||||
const userRoles = usersResponse.data.map((user) => user.primary_role);
|
||||
setRoles(userRoles.filter((role, index, all) => (
|
||||
all.findIndex((item) => item.id === role.id) === index
|
||||
)));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -77,10 +95,11 @@ export default function UsersPage() {
|
|||
user.last_name,
|
||||
user.username,
|
||||
user.email,
|
||||
user.role,
|
||||
user.primary_role.display_name,
|
||||
user.primary_role.name,
|
||||
].some((value) => value.toLowerCase().includes(term));
|
||||
})
|
||||
.filter((user) => roleFilter === "all" || user.role === roleFilter)
|
||||
.filter((user) => roleFilter === "all" || user.primary_role.name === roleFilter)
|
||||
.filter((user) => {
|
||||
if (statusFilter === "all") {
|
||||
return true;
|
||||
|
|
@ -182,10 +201,12 @@ export default function UsersPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={16} />
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
{hasAllPermissions(currentUser, ["users.create", "roles.assign"]) && (
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={16} />
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
||||
|
|
@ -207,9 +228,11 @@ export default function UsersPage() {
|
|||
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<option value="all">Alle Rollen</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="user">Benutzer</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.name}>
|
||||
{role.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
|
|
@ -232,6 +255,8 @@ export default function UsersPage() {
|
|||
error={error}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
canUpdate={hasPermission(currentUser, "users.update")}
|
||||
canDelete={hasPermission(currentUser, "users.delete")}
|
||||
onSort={handleSort}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={setDeleteUser}
|
||||
|
|
@ -266,6 +291,7 @@ export default function UsersPage() {
|
|||
user={selectedUser}
|
||||
pending={pending}
|
||||
serverError={formError}
|
||||
roles={roles}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={saveUser}
|
||||
/>
|
||||
|
|
@ -295,7 +321,7 @@ export default function UsersPage() {
|
|||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Rolle</dt>
|
||||
<dd className="font-medium">{deleteUser.role}</dd>
|
||||
<dd className="font-medium">{deleteUser.primary_role.display_name}</dd>
|
||||
</div>
|
||||
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Settings,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Wrench,
|
||||
Package,
|
||||
FileText,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||
|
||||
const menu = [
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
|
|
@ -19,6 +27,13 @@ const menu = [
|
|||
icon: Users,
|
||||
name: "Benutzer",
|
||||
href: "/users",
|
||||
permission: "users.read",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
name: "Rollen & Rechte",
|
||||
href: "/roles",
|
||||
permission: "roles.read",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
|
|
@ -50,21 +65,45 @@ const menu = [
|
|||
name: "Einstellungen",
|
||||
href: "/settings",
|
||||
},
|
||||
];
|
||||
] satisfies Array<{
|
||||
icon: typeof LayoutDashboard;
|
||||
name: string;
|
||||
href: string;
|
||||
permission?: PermissionName;
|
||||
}>;
|
||||
|
||||
export default function Sidebar() {
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
|
||||
const loadCurrentUser = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<CurrentUser>("/me");
|
||||
setCurrentUser(response.data);
|
||||
} catch {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadCurrentUser();
|
||||
});
|
||||
}, [loadCurrentUser]);
|
||||
|
||||
const visibleMenu = menu.filter((item) => (
|
||||
!item.permission || hasPermission(currentUser, item.permission)
|
||||
));
|
||||
|
||||
return (
|
||||
<aside className="w-64 h-screen bg-slate-900 text-white flex flex-col">
|
||||
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<h1 className="text-3xl font-bold">
|
||||
🏛 Olympus
|
||||
Olympus
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
|
||||
{menu.map((item) => (
|
||||
{visibleMenu.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
|
|
@ -74,13 +113,11 @@ export default function Sidebar() {
|
|||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
||||
Olympus ERP v0.1
|
||||
Olympus CRM v0.1
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
30
frontend/athena/components/common/Can.tsx
Normal file
30
frontend/athena/components/common/Can.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { hasAllPermissions, hasAnyPermission, hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||
|
||||
type Props = {
|
||||
user: CurrentUser | null;
|
||||
permission?: PermissionName;
|
||||
any?: PermissionName[];
|
||||
all?: PermissionName[];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Can({ user, permission, any, all, children }: Props) {
|
||||
if (permission && !hasPermission(user, permission)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (any && !hasAnyPermission(user, any)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (all && !hasAllPermissions(user, all)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -14,20 +14,16 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Role } from "@/types/rbac";
|
||||
import type { User, UserPayload, UserRole } from "@/types/user";
|
||||
|
||||
const roles: Array<{ value: UserRole; label: string }> = [
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "manager", label: "Manager" },
|
||||
{ value: "user", label: "Benutzer" },
|
||||
];
|
||||
|
||||
const emptyForm: UserPayload = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
role: "user",
|
||||
role: "support",
|
||||
role_id: undefined,
|
||||
is_active: true,
|
||||
password: "",
|
||||
};
|
||||
|
|
@ -37,6 +33,7 @@ type Props = {
|
|||
user?: User | null;
|
||||
pending?: boolean;
|
||||
serverError?: string;
|
||||
roles: Role[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: UserPayload) => Promise<void>;
|
||||
};
|
||||
|
|
@ -46,6 +43,7 @@ export default function UserFormDialog({
|
|||
user,
|
||||
pending = false,
|
||||
serverError,
|
||||
roles,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
|
|
@ -57,10 +55,15 @@ export default function UserFormDialog({
|
|||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
role_id: user.role_id,
|
||||
is_active: user.is_active,
|
||||
password: "",
|
||||
}
|
||||
: emptyForm;
|
||||
: {
|
||||
...emptyForm,
|
||||
role: roles[0]?.name ?? "support",
|
||||
role_id: roles[0]?.id,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -72,6 +75,7 @@ export default function UserFormDialog({
|
|||
isEditing={isEditing}
|
||||
pending={pending}
|
||||
serverError={serverError}
|
||||
roles={roles}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
|
@ -86,6 +90,7 @@ function UserForm({
|
|||
isEditing,
|
||||
pending,
|
||||
serverError,
|
||||
roles,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
|
|
@ -93,6 +98,7 @@ function UserForm({
|
|||
isEditing: boolean;
|
||||
pending: boolean;
|
||||
serverError?: string;
|
||||
roles: Role[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (payload: UserPayload) => Promise<void>;
|
||||
}) {
|
||||
|
|
@ -179,13 +185,17 @@ function UserForm({
|
|||
|
||||
<Field label="Rolle">
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(event) => updateField("role", event.target.value as UserRole)}
|
||||
value={form.role_id ?? ""}
|
||||
onChange={(event) => {
|
||||
const role = roles.find((item) => item.id === Number(event.target.value));
|
||||
updateField("role_id", role?.id);
|
||||
updateField("role", (role?.name ?? "") as UserRole);
|
||||
}}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type Props = {
|
|||
error?: string;
|
||||
sortKey: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onSort: (key: string) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
|
|
@ -33,6 +35,8 @@ export default function UserTable({
|
|||
error,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onSort,
|
||||
onEdit,
|
||||
onDelete,
|
||||
|
|
@ -66,7 +70,7 @@ export default function UserTable({
|
|||
key: "role",
|
||||
label: "Rolle",
|
||||
sortable: true,
|
||||
render: (user) => user.role,
|
||||
render: (user) => user.primary_role.display_name,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
|
|
@ -93,24 +97,28 @@ export default function UserTable({
|
|||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Bearbeiten"
|
||||
onClick={() => onEdit(user)}
|
||||
>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
title="Löschen"
|
||||
onClick={() => onDelete(user)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Bearbeiten"
|
||||
onClick={() => onEdit(user)}
|
||||
>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
title="Löschen"
|
||||
onClick={() => onDelete(user)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
19
frontend/athena/lib/permissions.ts
Normal file
19
frontend/athena/lib/permissions.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||
|
||||
export function hasPermission(user: CurrentUser | null | undefined, permission: PermissionName) {
|
||||
return Boolean(user?.permissions.includes(permission));
|
||||
}
|
||||
|
||||
export function hasAnyPermission(
|
||||
user: CurrentUser | null | undefined,
|
||||
permissions: PermissionName[],
|
||||
) {
|
||||
return permissions.some((permission) => hasPermission(user, permission));
|
||||
}
|
||||
|
||||
export function hasAllPermissions(
|
||||
user: CurrentUser | null | undefined,
|
||||
permissions: PermissionName[],
|
||||
) {
|
||||
return permissions.every((permission) => hasPermission(user, permission));
|
||||
}
|
||||
55
frontend/athena/lib/server/hermes-proxy.ts
Normal file
55
frontend/athena/lib/server/hermes-proxy.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
clearAuthCookie,
|
||||
getAccessToken,
|
||||
getHermesUrl,
|
||||
hermesJsonResponse,
|
||||
unauthorizedResponse,
|
||||
upstreamConfigurationErrorResponse,
|
||||
upstreamUnavailableResponse,
|
||||
} from "@/lib/server/hermes";
|
||||
|
||||
export async function proxyHermesRequest(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const hermesUrl = getHermesUrl();
|
||||
|
||||
if (!hermesUrl) {
|
||||
return upstreamConfigurationErrorResponse();
|
||||
}
|
||||
|
||||
let hermesResponse: Response;
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: request.method === "GET" || request.method === "DELETE"
|
||||
? undefined
|
||||
: await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return upstreamUnavailableResponse();
|
||||
}
|
||||
|
||||
if (hermesResponse.status === 401) {
|
||||
const response = await hermesJsonResponse(hermesResponse);
|
||||
clearAuthCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
return hermesJsonResponse(hermesResponse);
|
||||
}
|
||||
|
|
@ -20,5 +20,6 @@ export const config = {
|
|||
"/login",
|
||||
"/dashboard/:path*",
|
||||
"/users/:path*",
|
||||
"/roles/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
37
frontend/athena/types/rbac.ts
Normal file
37
frontend/athena/types/rbac.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export type PermissionName = string;
|
||||
|
||||
export interface Permission {
|
||||
id: number;
|
||||
name: PermissionName;
|
||||
display_name: string;
|
||||
description: string;
|
||||
module: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
is_system: boolean;
|
||||
permissions: Permission[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RolePayload {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
permission_ids: number[];
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
permissions: PermissionName[];
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export type UserRole = "admin" | "manager" | "user";
|
||||
import type { Role } from "@/types/rbac";
|
||||
|
||||
export type UserRole = string;
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
|
|
@ -7,6 +9,8 @@ export interface User {
|
|||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
role_id: number;
|
||||
primary_role: Role;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -18,6 +22,7 @@ export type UserPayload = {
|
|||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
role_id?: number;
|
||||
is_active: boolean;
|
||||
password?: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue