feat(rbac): add roles and permissions

This commit is contained in:
Schubert Ferenc 2026-07-02 23:05:59 +02:00
parent 86a32a942c
commit 694b7bd09a
37 changed files with 2682 additions and 218 deletions

View file

@ -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>
);
}

View 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}</>;
}

View file

@ -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>

View file

@ -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>
),
},