feat(rbac): add roles and permissions
This commit is contained in:
parent
86a32a942c
commit
694b7bd09a
37 changed files with 2682 additions and 218 deletions
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue