template-saas-frontend-v2/src/pages/dashboard/rbac/RolesPage.tsx
Adrian Flores Cortes 39cf33c3e5 feat(rbac,portfolio): Add RBAC hook and Portfolio/RBAC pages
- Add useRbac hook with roles, permissions, and user role management
- Add RBAC API endpoints to api.ts (roles, permissions, user assignments)
- Add RolesPage for managing roles and viewing permissions
- Add PortfolioPage dashboard with stats and quick links
- Add ProductsPage with search, filters, and pagination
- Add CategoriesPage with tree view and expand/collapse
- Fix useMyEarnings export conflict between useMlm and useCommissions
- Export useMyCommissionEarnings as alias to avoid naming conflict

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 13:05:05 -06:00

242 lines
11 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Shield, Plus, Search, Edit, Trash2, Lock } from 'lucide-react';
import {
useRoles,
useDeleteRole,
usePermissions,
getPermissionCategoryLabel,
} from '@/hooks/useRbac';
export default function RolesPage() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const { data: roles, isLoading } = useRoles();
const { data: permissions } = usePermissions();
const deleteMutation = useDeleteRole();
const handleDelete = async (id: string, isSystem: boolean) => {
if (isSystem) {
alert('System roles cannot be deleted.');
return;
}
if (window.confirm('Are you sure you want to delete this role?')) {
await deleteMutation.mutateAsync(id);
}
};
const filteredRoles = roles?.filter((role: any) =>
role.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedRoleData = roles?.find((r: any) => r.id === selectedRole);
// Group permissions by category
const permissionsByCategory = permissions?.reduce((acc: any, perm: any) => {
if (!acc[perm.category]) {
acc[perm.category] = [];
}
acc[perm.category].push(perm);
return acc;
}, {});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Roles & Permissions</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage user roles and their permissions
</p>
</div>
<Link
to="/dashboard/rbac/roles/new"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Role
</Link>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Roles List */}
<div className="lg:col-span-1">
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search roles..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="max-h-[600px] overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : filteredRoles?.length ? (
<div className="space-y-2">
{filteredRoles.map((role: any) => (
<button
key={role.id}
onClick={() => setSelectedRole(role.id)}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedRole === role.id
? 'bg-blue-50 ring-2 ring-blue-500 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${
role.is_system
? 'bg-purple-100 dark:bg-purple-900/20'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
<Shield className={`h-5 w-5 ${
role.is_system
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-600 dark:text-gray-400'
}`} />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{role.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{role.permissions?.length || 0} permissions
</p>
</div>
</div>
{role.is_system && (
<Lock className="h-4 w-4 text-gray-400" />
)}
</div>
</button>
))}
</div>
) : (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">
No roles found.
</p>
)}
</div>
</div>
</div>
{/* Role Details */}
<div className="lg:col-span-2">
{selectedRoleData ? (
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedRoleData.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedRoleData.description || 'No description'}
</p>
</div>
<div className="flex gap-2">
{!selectedRoleData.is_system && (
<>
<Link
to={`/dashboard/rbac/roles/${selectedRoleData.id}/edit`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Edit className="h-4 w-4" />
Edit
</Link>
<button
onClick={() => handleDelete(selectedRoleData.id, selectedRoleData.is_system)}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-600 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
)}
</div>
</div>
{/* Role Info */}
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Type</p>
<p className="font-medium text-gray-900 dark:text-white">
{selectedRoleData.is_system ? 'System' : 'Custom'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Default</p>
<p className="font-medium text-gray-900 dark:text-white">
{selectedRoleData.is_default ? 'Yes' : 'No'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Slug</p>
<p className="font-mono text-sm text-gray-900 dark:text-white">
{selectedRoleData.slug}
</p>
</div>
</div>
</div>
{/* Permissions */}
<div className="p-4">
<h3 className="mb-4 font-medium text-gray-900 dark:text-white">Permissions</h3>
{permissionsByCategory && Object.keys(permissionsByCategory).length > 0 ? (
<div className="space-y-4">
{Object.entries(permissionsByCategory).map(([category, perms]: [string, any]) => {
const rolePermSlugs = selectedRoleData.permissions?.map((p: any) => p.slug) || [];
const categoryPerms = perms.filter((p: any) => rolePermSlugs.includes(p.slug));
if (categoryPerms.length === 0) return null;
return (
<div key={category}>
<h4 className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{getPermissionCategoryLabel(category)}
</h4>
<div className="flex flex-wrap gap-2">
{categoryPerms.map((perm: any) => (
<span
key={perm.id}
className="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
{perm.name}
</span>
))}
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
No permissions assigned to this role.
</p>
)}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-gray-200 bg-white p-12 dark:border-gray-700 dark:bg-gray-800">
<div className="text-center">
<Shield className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">
Select a role to view its details and permissions
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}