#1 feat: develop admin panel permissions

Gabung
karan_dev menggabungkan 3 komit dari mohamad-salimi/feature/permissions menjadi mohamad-salimi/main%! (template.HTML=2 minggu lalu)s
71 mengubah file dengan 3320 tambahan dan 253 penghapusan
  1. 3 2
      src/components/command-menu.tsx
  2. 3 2
      src/components/layout/app-sidebar.tsx
  3. 39 2
      src/components/layout/data/sidebar-data.ts
  4. 1 0
      src/components/layout/types.ts
  5. 56 0
      src/constants/permissions.ts
  6. 26 0
      src/core/react-query/keys.ts
  7. 343 0
      src/features/admins/components/admins-action-dialog.tsx
  8. 88 0
      src/features/admins/components/admins-columns.tsx
  9. 93 0
      src/features/admins/components/admins-delete-dialog.tsx
  10. 23 0
      src/features/admins/components/admins-dialogs.tsx
  11. 18 0
      src/features/admins/components/admins-primary-buttons.tsx
  12. 37 0
      src/features/admins/components/admins-provider.tsx
  13. 124 0
      src/features/admins/components/admins-table.tsx
  14. 79 0
      src/features/admins/components/data-table-row-actions.tsx
  15. 63 0
      src/features/admins/index.tsx
  16. 62 42
      src/features/evaluations/components/data-table-row-actions.tsx
  17. 44 38
      src/features/evaluations/components/evaluations-action-dialog.tsx
  18. 92 0
      src/features/permissions/index.tsx
  19. 25 16
      src/features/posts/components/data-table-row-actions.tsx
  20. 69 0
      src/features/roles/components/activity-field-picker.tsx
  21. 80 0
      src/features/roles/components/data-table-row-actions.tsx
  22. 175 0
      src/features/roles/components/permission-picker.tsx
  23. 253 0
      src/features/roles/components/roles-action-dialog.tsx
  24. 98 0
      src/features/roles/components/roles-columns.tsx
  25. 94 0
      src/features/roles/components/roles-delete-dialog.tsx
  26. 23 0
      src/features/roles/components/roles-dialogs.tsx
  27. 18 0
      src/features/roles/components/roles-primary-buttons.tsx
  28. 35 0
      src/features/roles/components/roles-provider.tsx
  29. 124 0
      src/features/roles/components/roles-table.tsx
  30. 63 0
      src/features/roles/index.tsx
  31. 25 17
      src/features/users/components/data-table-row-actions.tsx
  32. 131 106
      src/features/users/components/users-action-dialog.tsx
  33. 31 7
      src/features/users/components/users-columns.tsx
  34. 6 0
      src/features/users/components/users-primary-buttons.tsx
  35. 25 16
      src/features/users/components/users-table.tsx
  36. 8 2
      src/features/users/index.tsx
  37. 11 0
      src/hooks/admins/useAdmin.ts
  38. 14 0
      src/hooks/admins/useAdmins.ts
  39. 16 0
      src/hooks/admins/useCreateAdmin.ts
  40. 16 0
      src/hooks/admins/useDeleteAdmin.ts
  41. 29 0
      src/hooks/admins/useUpdateAdmin.ts
  42. 4 0
      src/hooks/auth/useAuth.tsx
  43. 11 0
      src/hooks/permissions/usePermissionModules.ts
  44. 16 0
      src/hooks/roles/useCreateRole.ts
  45. 18 0
      src/hooks/roles/useDeleteRole.ts
  46. 11 0
      src/hooks/roles/useRole.ts
  47. 16 0
      src/hooks/roles/useRoles.ts
  48. 25 0
      src/hooks/roles/useUpdateRole.ts
  49. 19 0
      src/hooks/use-can.ts
  50. 42 0
      src/hooks/use-visible-nav.ts
  51. 10 0
      src/lib/api-error.ts
  52. 99 0
      src/lib/permissions.ts
  53. 14 0
      src/lib/require-permission.ts
  54. 54 0
      src/lib/resolve-permissions.ts
  55. 65 0
      src/routeTree.gen.ts
  56. 18 0
      src/routes/_authenticated/admins/index.tsx
  57. 5 0
      src/routes/_authenticated/evaluations/index.tsx
  58. 11 0
      src/routes/_authenticated/permissions/index.tsx
  59. 5 0
      src/routes/_authenticated/posts/index.tsx
  60. 5 0
      src/routes/_authenticated/referral-codes/index.tsx
  61. 18 0
      src/routes/_authenticated/roles/index.tsx
  62. 6 2
      src/routes/_authenticated/route.tsx
  63. 5 0
      src/routes/_authenticated/users/index.tsx
  64. 45 0
      src/services/admins.service.ts
  65. 9 0
      src/services/permissions.service.ts
  66. 73 0
      src/services/roles.service.ts
  67. 10 0
      src/stores/auth-store.ts
  68. 43 0
      src/types/admins.types.ts
  69. 9 1
      src/types/auth.types.ts
  70. 33 0
      src/types/permissions.types.ts
  71. 58 0
      src/types/roles.types.ts

+ 3 - 2
src/components/command-menu.tsx

@@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
 import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
 import { useSearch } from '@/context/search-provider'
 import { useTheme } from '@/context/theme-provider'
+import { useVisibleNavGroups } from '@/hooks/use-visible-nav'
 import {
   CommandDialog,
   CommandEmpty,
@@ -12,13 +13,13 @@ import {
   CommandList,
   CommandSeparator,
 } from '@/components/ui/command'
-import { sidebarData } from './layout/data/sidebar-data'
 import { ScrollArea } from './ui/scroll-area'
 
 export function CommandMenu() {
   const navigate = useNavigate()
   const { setTheme } = useTheme()
   const { open, setOpen } = useSearch()
+  const navGroups = useVisibleNavGroups()
 
   const runCommand = React.useCallback(
     (command: () => unknown) => {
@@ -34,7 +35,7 @@ export function CommandMenu() {
       <CommandList>
         <ScrollArea type='hover' className='h-72 pe-1'>
           <CommandEmpty>هیچ نتیجه ای یافت نشد.</CommandEmpty>
-          {sidebarData.navGroups.map((group) => (
+          {navGroups.map((group) => (
             <CommandGroup key={group.title} heading={group.title}>
               {group.items.map((navItem, i) => {
                 if (navItem.url)

+ 3 - 2
src/components/layout/app-sidebar.tsx

@@ -1,4 +1,5 @@
 import { useLayout } from '@/context/layout-provider'
+import { useVisibleNavGroups } from '@/hooks/use-visible-nav'
 import {
   Sidebar,
   SidebarContent,
@@ -6,11 +7,11 @@ import {
   SidebarRail,
 } from '@/components/ui/sidebar'
 import { AppTitle } from './app-title'
-import { sidebarData } from './data/sidebar-data'
 import { NavGroup } from './nav-group'
 
 export function AppSidebar() {
   const { collapsible, variant } = useLayout()
+  const navGroups = useVisibleNavGroups()
   return (
     <Sidebar collapsible={collapsible} variant={variant}>
       <SidebarHeader>
@@ -21,7 +22,7 @@ export function AppSidebar() {
         <AppTitle />
       </SidebarHeader>
       <SidebarContent>
-        {sidebarData.navGroups.map((props) => (
+        {navGroups.map((props) => (
           <NavGroup key={props.title} {...props} />
         ))}
       </SidebarContent>

+ 39 - 2
src/components/layout/data/sidebar-data.ts

@@ -1,6 +1,16 @@
-import { LayoutDashboard, Users, Play, Star, Tags } from 'lucide-react'
-import { type SidebarData } from '../types'
+import {
+  KeyRound,
+  LayoutDashboard,
+  Play,
+  Shield,
+  Star,
+  Tags,
+  UserCog,
+  Users,
+} from 'lucide-react'
 import Logo from '@/assets/logo.svg'
+import { PERMISSIONS } from '@/constants/permissions'
+import { type SidebarData } from '../types'
 
 export const sidebarData: SidebarData = {
   teams: [
@@ -23,21 +33,25 @@ export const sidebarData: SidebarData = {
           title: 'کاربران',
           url: '/users',
           icon: Users,
+          permission: PERMISSIONS.users.view,
         },
         {
           title: 'پست ها',
           url: '/posts',
           icon: Play,
+          permission: PERMISSIONS.posts.view,
         },
         {
           title: 'ارزیابی',
           url: '/evaluations',
           icon: Star,
+          permission: PERMISSIONS.evaluations.view,
         },
         {
           title: 'کد دعوت',
           url: '/referral-codes',
           icon: Tags,
+          permission: PERMISSIONS.users.fields.referralCode.view,
         },
         // {
         //   title: 'کامنت ها',
@@ -65,6 +79,29 @@ export const sidebarData: SidebarData = {
         // },
       ],
     },
+    {
+      title: 'دسترسی',
+      items: [
+        {
+          title: 'مدیران',
+          url: '/admins',
+          icon: UserCog,
+          permission: PERMISSIONS.admins.view,
+        },
+        {
+          title: 'نقش‌ها',
+          url: '/roles',
+          icon: Shield,
+          permission: PERMISSIONS.roles.view,
+        },
+        {
+          title: 'دسترسی‌ها',
+          url: '/permissions',
+          icon: KeyRound,
+          permission: PERMISSIONS.permissions.view,
+        },
+      ],
+    },
     // {
     //   title: 'صفحات',
     //   items: [

+ 1 - 0
src/components/layout/types.ts

@@ -16,6 +16,7 @@ type BaseNavItem = {
   title: string
   badge?: string
   icon?: React.ElementType
+  permission?: string
 }
 
 type NavLink = BaseNavItem & {

+ 56 - 0
src/constants/permissions.ts

@@ -0,0 +1,56 @@
+export const PERMISSIONS = {
+  users: {
+    create: 'create-users',
+    view: 'read-users',
+    update: 'update-users',
+    delete: 'delete-users',
+    fields: {
+      phone: {
+        view: 'users.fields.phone.view',
+        update: 'users.fields.phone.update',
+      },
+      email: {
+        view: 'users.fields.email.view',
+        update: 'users.fields.email.update',
+      },
+      birthDate: {
+        view: 'users.fields.birth_date.view',
+        update: 'users.fields.birth_date.update',
+      },
+      subscription: {
+        view: 'users.fields.subscription.view',
+      },
+      referralCode: {
+        view: 'users.fields.referral_code.view',
+      },
+    },
+  },
+  admins: {
+    create: 'create-admins',
+    view: 'read-admins',
+    update: 'update-admins',
+    delete: 'delete-admins',
+  },
+  roles: {
+    create: 'create-roles',
+    view: 'read-roles',
+    update: 'update-roles',
+    delete: 'delete-roles',
+  },
+  permissions: {
+    view: 'read-permissions',
+  },
+  posts: {
+    view: 'read-posts',
+    update: 'update-posts',
+    delete: 'delete-posts',
+  },
+  evaluations: {
+    view: 'read-evaluation',
+    update: 'update-evaluation',
+    delete: 'delete-evaluation',
+  },
+  items: {
+    view: 'read-items',
+  },
+} as const

+ 26 - 0
src/core/react-query/keys.ts

@@ -55,4 +55,30 @@ export const queryKeys = {
   activityFields: {
     all: ['activityFields'] as const,
   },
+
+  permissions: {
+    all: ['permissions'] as const,
+  },
+
+  admins: {
+    all: ['admins'] as const,
+
+    lists: () => [...queryKeys.admins.all, 'list'] as const,
+    list: (params: unknown) => [...queryKeys.admins.lists(), params] as const,
+
+    details: () => [...queryKeys.admins.all, 'detail'] as const,
+    detail: (id: number | string) =>
+      [...queryKeys.admins.details(), id] as const,
+  },
+
+  roles: {
+    all: ['roles'] as const,
+
+    lists: () => [...queryKeys.roles.all, 'list'] as const,
+    list: (params: unknown) => [...queryKeys.roles.lists(), params] as const,
+
+    details: () => [...queryKeys.roles.all, 'detail'] as const,
+    detail: (id: number | string) =>
+      [...queryKeys.roles.details(), id] as const,
+  },
 }

+ 343 - 0
src/features/admins/components/admins-action-dialog.tsx

@@ -0,0 +1,343 @@
+import { useEffect, useMemo } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useAdmin } from '@/hooks/admins/useAdmin'
+import { useCreateAdmin } from '@/hooks/admins/useCreateAdmin'
+import { useUpdateAdmin } from '@/hooks/admins/useUpdateAdmin'
+import { useRoles } from '@/hooks/roles/useRoles'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { PasswordInput } from '@/components/password-input'
+import { useAdminsContext } from './admins-provider'
+
+const schema = z.object({
+  name: z.string().trim().min(1, 'نام الزامی است'),
+  username: z.string().trim().min(1, 'نام کاربری الزامی است'),
+  password: z.string(),
+  status: z.enum(['active', 'inactive']),
+  roles: z.array(z.number()).min(1, 'حداقل یک نقش را انتخاب کنید'),
+})
+
+type FormValues = z.infer<typeof schema>
+
+const emptyValues: FormValues = {
+  name: '',
+  username: '',
+  password: '',
+  status: 'active',
+  roles: [],
+}
+
+export function AdminsActionDialog() {
+  const { open, setOpen, currentRow, setCurrentRow } = useAdminsContext()
+  const isAdd = open === 'add'
+  const isEdit = open === 'edit'
+  const isView = open === 'view'
+  const opened = isAdd || isEdit || isView
+  const readOnly = isView
+  const { can } = useCan()
+  const canReadRoles = can(PERMISSIONS.roles.view)
+
+  const adminQuery = useAdmin(opened && !isAdd ? currentRow?.id : undefined)
+  const rolesQuery = useRoles(
+    { page: 1, per_page: 100 },
+    opened && canReadRoles
+  )
+  const createMutation = useCreateAdmin()
+  const updateMutation = useUpdateAdmin()
+  const isPending = createMutation.isPending || updateMutation.isPending
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: emptyValues,
+  })
+
+  const roleOptions = useMemo(() => {
+    const options = new Map<number, string>()
+
+    for (const role of rolesQuery.data?.data.roles ?? []) {
+      options.set(role.id, role.name)
+    }
+
+    for (const role of adminQuery.data?.role_details ?? []) {
+      if (!options.has(role.id)) options.set(role.id, role.name)
+    }
+
+    return [...options.entries()].map(([id, name]) => ({ id, name }))
+  }, [rolesQuery.data, adminQuery.data])
+
+  useEffect(() => {
+    if (!opened) return
+
+    if (isAdd) {
+      form.reset(emptyValues)
+      return
+    }
+
+    if (!adminQuery.data) return
+
+    const status = adminQuery.data.status === 'inactive' ? 'inactive' : 'active'
+
+    form.reset({
+      name: adminQuery.data.name,
+      username: adminQuery.data.username,
+      password: '',
+      status,
+      roles: adminQuery.data.role_details.map((role) => role.id),
+    })
+  }, [opened, isAdd, adminQuery.data, form])
+
+  const handleClose = () => {
+    setOpen(null)
+    setCurrentRow(null)
+    form.reset(emptyValues)
+  }
+
+  const onSubmit = (values: FormValues) => {
+    if (readOnly) return
+
+    if (isAdd && !values.password.trim()) {
+      form.setError('password', { message: 'رمز عبور الزامی است' })
+      return
+    }
+
+    const payload = {
+      name: values.name.trim(),
+      username: values.username.trim(),
+      status: values.status,
+      roles: values.roles,
+      ...(values.password.trim() ? { password: values.password } : {}),
+    }
+
+    if (isAdd) {
+      createMutation.mutate(payload, {
+        onSuccess: () => {
+          toast.success('مدیر با موفقیت ایجاد شد')
+          handleClose()
+        },
+        onError: (error) => {
+          toast.error(getApiErrorMessage(error, 'خطا در ایجاد مدیر'))
+        },
+      })
+    }
+
+    if (isEdit && currentRow) {
+      updateMutation.mutate(
+        { id: currentRow.id, payload },
+        {
+          onSuccess: () => {
+            toast.success('مدیر با موفقیت ویرایش شد')
+            handleClose()
+          },
+          onError: (error) => {
+            toast.error(getApiErrorMessage(error, 'خطا در ویرایش مدیر'))
+          },
+        }
+      )
+    }
+  }
+
+  const isLoading =
+    ((isEdit || isView) && adminQuery.isLoading) ||
+    (canReadRoles && rolesQuery.isLoading)
+
+  return (
+    <Dialog open={opened} onOpenChange={(state) => !state && handleClose()}>
+      <DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
+        <DialogHeader>
+          <DialogTitle>
+            {isAdd && 'افزودن مدیر'}
+            {isEdit && 'ویرایش مدیر'}
+            {isView && 'مشاهده مدیر'}
+          </DialogTitle>
+        </DialogHeader>
+
+        {isLoading ? (
+          <div className='py-10 text-center'>در حال دریافت...</div>
+        ) : adminQuery.isError ? (
+          <div className='py-10 text-center text-sm text-destructive'>
+            دریافت اطلاعات مدیر ممکن نشد.
+          </div>
+        ) : (
+          <Form {...form}>
+            <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
+              <FormField
+                control={form.control}
+                name='name'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نام</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        disabled={readOnly}
+                        placeholder='نام مدیر'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='username'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نام کاربری</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        disabled={readOnly}
+                        placeholder='نام کاربری'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              {!readOnly && (
+                <FormField
+                  control={form.control}
+                  name='password'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>رمز عبور</FormLabel>
+                      <FormControl>
+                        <PasswordInput
+                          {...field}
+                          placeholder={
+                            isEdit
+                              ? 'برای تغییر رمز، مقدار جدید را وارد کنید'
+                              : 'رمز عبور'
+                          }
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              )}
+
+              <FormField
+                control={form.control}
+                name='status'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>وضعیت</FormLabel>
+                    <Select
+                      disabled={readOnly}
+                      value={field.value}
+                      onValueChange={field.onChange}
+                    >
+                      <FormControl>
+                        <SelectTrigger className='w-full'>
+                          <SelectValue placeholder='انتخاب وضعیت' />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        <SelectItem value='active'>فعال</SelectItem>
+                        <SelectItem value='inactive'>غیرفعال</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='roles'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نقش‌ها</FormLabel>
+                    {!canReadRoles && roleOptions.length === 0 ? (
+                      <p className='text-sm text-muted-foreground'>
+                        برای انتخاب نقش، دسترسی مشاهده نقش‌ها لازم است.
+                      </p>
+                    ) : rolesQuery.isError ? (
+                      <p className='text-sm text-destructive'>
+                        دریافت نقش‌ها ممکن نشد.
+                      </p>
+                    ) : roleOptions.length === 0 ? (
+                      <p className='text-sm text-muted-foreground'>
+                        نقشی برای انتخاب وجود ندارد.
+                      </p>
+                    ) : (
+                      <div className='max-h-44 overflow-y-auto rounded-md border p-3'>
+                        <div className='grid gap-2'>
+                          {roleOptions.map((role) => (
+                            <label
+                              key={role.id}
+                              className='flex items-center gap-2 text-sm'
+                            >
+                              <Checkbox
+                                checked={field.value.includes(role.id)}
+                                disabled={readOnly}
+                                onCheckedChange={(checked) => {
+                                  field.onChange(
+                                    checked === true
+                                      ? [...field.value, role.id]
+                                      : field.value.filter(
+                                          (id) => id !== role.id
+                                        )
+                                  )
+                                }}
+                              />
+                              {role.name}
+                            </label>
+                          ))}
+                        </div>
+                      </div>
+                    )}
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type='button' variant='outline' onClick={handleClose}>
+                  {readOnly ? 'بستن' : 'انصراف'}
+                </Button>
+                {!readOnly && (
+                  <Button type='submit' disabled={isPending}>
+                    {isPending ? 'در حال ذخیره...' : 'ذخیره'}
+                  </Button>
+                )}
+              </DialogFooter>
+            </form>
+          </Form>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 88 - 0
src/features/admins/components/admins-columns.tsx

@@ -0,0 +1,88 @@
+import { type ColumnDef } from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { Badge } from '@/components/ui/badge'
+import { DataTableColumnHeader } from '@/components/data-table'
+import { LongText } from '@/components/long-text'
+import { DataTableRowActions } from './data-table-row-actions'
+
+const adminStatusLabels: Record<string, string> = {
+  active: 'فعال',
+  inactive: 'غیرفعال',
+}
+
+export const adminsColumns: ColumnDef<Admin>[] = [
+  {
+    accessorKey: 'id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شناسه' />
+    ),
+    cell: ({ row }) => <div className='w-15'>#{row.original.id}</div>,
+  },
+  {
+    accessorKey: 'name',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام' />
+    ),
+    cell: ({ row }) => (
+      <LongText className='max-w-48'>{row.original.name}</LongText>
+    ),
+  },
+  {
+    accessorKey: 'username',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام کاربری' />
+    ),
+    cell: ({ row }) => <div>{row.original.username}</div>,
+  },
+  {
+    accessorKey: 'status',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='وضعیت' />
+    ),
+    cell: ({ row }) => (
+      <Badge
+        variant={row.original.status === 'active' ? 'default' : 'secondary'}
+      >
+        {adminStatusLabels[row.original.status] ?? row.original.status}
+      </Badge>
+    ),
+  },
+  {
+    id: 'roles',
+    accessorFn: (row) => row.roles.join(', '),
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نقش‌ها' />
+    ),
+    cell: ({ row }) => (
+      <div className='flex flex-wrap gap-1'>
+        {row.original.role_details.length > 0
+          ? row.original.role_details.map((role) => (
+              <Badge key={role.id} variant='secondary'>
+                {role.name}
+              </Badge>
+            ))
+          : row.original.roles.map((role) => (
+              <Badge key={role} variant='secondary'>
+                {role}
+              </Badge>
+            ))}
+      </div>
+    ),
+  },
+  {
+    accessorKey: 'created_at',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='تاریخ ایجاد' />
+    ),
+    cell: ({ row }) => (
+      <div className='text-sm text-muted-foreground'>
+        {new Date(row.original.created_at).toLocaleDateString('fa-IR')}
+      </div>
+    ),
+  },
+  {
+    id: 'actions',
+    cell: DataTableRowActions,
+    enableSorting: false,
+  },
+]

+ 93 - 0
src/features/admins/components/admins-delete-dialog.tsx

@@ -0,0 +1,93 @@
+import { useState } from 'react'
+import { type Admin } from '@/types/admins.types'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { useDeleteAdmin } from '@/hooks/admins/useDeleteAdmin'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Admin | null
+}
+
+export function AdminsDeleteDialog({ open, onOpenChange, currentRow }: Props) {
+  const [value, setValue] = useState('')
+  const { mutate, isPending } = useDeleteAdmin()
+
+  if (!currentRow) return null
+
+  const handleDelete = () => {
+    mutate(currentRow.id, {
+      onSuccess: () => {
+        toast.success('مدیر با موفقیت حذف شد')
+        setValue('')
+        onOpenChange(false)
+      },
+      onError: (error) => {
+        toast.error(getApiErrorMessage(error, 'خطا در حذف مدیر'))
+      },
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={(state) => {
+        if (!state) setValue('')
+        onOpenChange(state)
+      }}
+      form='admins-delete-form'
+      disabled={value.trim() !== currentRow.username}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='me-1 inline-block stroke-destructive'
+            size={18}
+          />{' '}
+          حذف مدیر
+        </span>
+      }
+      desc={
+        <form
+          id='admins-delete-form'
+          onSubmit={(event) => {
+            event.preventDefault()
+            handleDelete()
+          }}
+          className='space-y-4'
+        >
+          <p className='mb-2'>
+            آیا از حذف مدیر <span className='font-bold'>{currentRow.name}</span>{' '}
+            مطمئن هستید؟
+          </p>
+
+          <Label>
+            نام کاربری:
+            <Input
+              value={value}
+              onChange={(event) => setValue(event.target.value)}
+              placeholder='نام کاربری را برای تایید حذف وارد کنید.'
+              autoFocus
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>هشدار!</AlertTitle>
+            <AlertDescription>
+              این عملیات قابل برگشت نیست و دسترسی این مدیر قطع می‌شود.
+            </AlertDescription>
+          </Alert>
+        </form>
+      }
+      confirmText='حذف'
+      cancelBtnText='انصراف'
+      isLoading={isPending}
+      destructive
+    />
+  )
+}

+ 23 - 0
src/features/admins/components/admins-dialogs.tsx

@@ -0,0 +1,23 @@
+import { AdminsActionDialog } from './admins-action-dialog'
+import { AdminsDeleteDialog } from './admins-delete-dialog'
+import { useAdminsContext } from './admins-provider'
+
+export function AdminsDialogs() {
+  const { open, setOpen, currentRow, setCurrentRow } = useAdminsContext()
+
+  return (
+    <>
+      <AdminsActionDialog />
+      <AdminsDeleteDialog
+        open={open === 'delete'}
+        onOpenChange={(state) => {
+          if (!state) {
+            setOpen(null)
+            setCurrentRow(null)
+          }
+        }}
+        currentRow={currentRow}
+      />
+    </>
+  )
+}

+ 18 - 0
src/features/admins/components/admins-primary-buttons.tsx

@@ -0,0 +1,18 @@
+import { UserPlus } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import { useAdminsContext } from './admins-provider'
+
+export function AdminsPrimaryButtons() {
+  const { setOpen } = useAdminsContext()
+  const { can } = useCan()
+
+  if (!can(PERMISSIONS.admins.create)) return null
+
+  return (
+    <Button className='space-x-1' onClick={() => setOpen('add')}>
+      <span>افزودن مدیر</span> <UserPlus size={18} />
+    </Button>
+  )
+}

+ 37 - 0
src/features/admins/components/admins-provider.tsx

@@ -0,0 +1,37 @@
+import React, { createContext, useContext, useState } from 'react'
+import { type Admin } from '@/types/admins.types'
+
+type AdminsDialogType = 'add' | 'edit' | 'view' | 'delete'
+
+interface AdminsContextType {
+  open: AdminsDialogType | null
+  setOpen: (value: AdminsDialogType | null) => void
+  currentRow: Admin | null
+  setCurrentRow: React.Dispatch<React.SetStateAction<Admin | null>>
+}
+
+const AdminsContext = createContext<AdminsContextType | undefined>(undefined)
+
+export function AdminsProvider({ children }: { children: React.ReactNode }) {
+  const [open, setOpen] = useState<AdminsDialogType | null>(null)
+  const [currentRow, setCurrentRow] = useState<Admin | null>(null)
+
+  return (
+    <AdminsContext.Provider
+      value={{ open, setOpen, currentRow, setCurrentRow }}
+    >
+      {children}
+    </AdminsContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useAdminsContext() {
+  const context = useContext(AdminsContext)
+
+  if (!context) {
+    throw new Error('useAdminsContext must be used within AdminsProvider')
+  }
+
+  return context
+}

+ 124 - 0
src/features/admins/components/admins-table.tsx

@@ -0,0 +1,124 @@
+import { useEffect, useState } from 'react'
+import {
+  type SortingState,
+  flexRender,
+  getCoreRowModel,
+  getSortedRowModel,
+  useReactTable,
+} from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { type Pagination } from '@/types/common.types'
+import { cn } from '@/lib/utils'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { DataTablePagination } from '@/components/data-table'
+import { adminsColumns as columns } from './admins-columns'
+
+type AdminsTableProps = {
+  data: Admin[]
+  paginationData?: Pagination
+  search: Record<string, unknown>
+  navigate: NavigateFn
+}
+
+export function AdminsTable({
+  data,
+  paginationData,
+  search,
+  navigate,
+}: AdminsTableProps) {
+  const [sorting, setSorting] = useState<SortingState>([])
+  const { pagination, onPaginationChange, ensurePageInRange } =
+    useTableUrlState({
+      search,
+      navigate,
+      pagination: { defaultPage: 1, defaultPageSize: 10 },
+      globalFilter: { enabled: false },
+    })
+
+  // eslint-disable-next-line react-hooks/incompatible-library
+  const table = useReactTable({
+    data,
+    columns,
+    state: { sorting, pagination },
+    pageCount: paginationData?.last_page ?? -1,
+    manualPagination: true,
+    onPaginationChange,
+    onSortingChange: setSorting,
+    getCoreRowModel: getCoreRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+  })
+
+  useEffect(() => {
+    ensurePageInRange(table.getPageCount())
+  }, [table, ensurePageInRange])
+
+  return (
+    <div className='flex flex-1 flex-col gap-4'>
+      <div className='overflow-hidden rounded-md border'>
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id} className='group/row'>
+                {headerGroup.headers.map((header) => (
+                  <TableHead
+                    key={header.id}
+                    colSpan={header.colSpan}
+                    className='bg-background group-hover/row:bg-muted'
+                  >
+                    {header.isPlaceholder
+                      ? null
+                      : flexRender(
+                          header.column.columnDef.header,
+                          header.getContext()
+                        )}
+                  </TableHead>
+                ))}
+              </TableRow>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {table.getRowModel().rows.length ? (
+              table.getRowModel().rows.map((row) => (
+                <TableRow key={row.id} className='group/row'>
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell
+                      key={cell.id}
+                      className={cn(
+                        'bg-background group-hover/row:bg-muted',
+                        cell.column.columnDef.meta?.className,
+                        cell.column.columnDef.meta?.tdClassName
+                      )}
+                    >
+                      {flexRender(
+                        cell.column.columnDef.cell,
+                        cell.getContext()
+                      )}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell
+                  colSpan={columns.length}
+                  className='h-24 text-center'
+                >
+                  نتیجه‌ای یافت نشد.
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </div>
+      <DataTablePagination table={table} className='mt-auto' />
+    </div>
+  )
+}

+ 79 - 0
src/features/admins/components/data-table-row-actions.tsx

@@ -0,0 +1,79 @@
+import { DotsHorizontalIcon } from '@radix-ui/react-icons'
+import { type Row } from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { Eye, Pencil, Trash2 } from 'lucide-react'
+import { useAuthStore } from '@/stores/auth-store'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useAdminsContext } from './admins-provider'
+
+interface Props {
+  row: Row<Admin>
+}
+
+export function DataTableRowActions({ row }: Props) {
+  const { setOpen, setCurrentRow } = useAdminsContext()
+  const { can } = useCan()
+  const currentUserId = useAuthStore((state) => state.user?.id)
+  const admin = row.original
+  const isCurrentUser = currentUserId === admin.id
+  const canUpdate = can(PERMISSIONS.admins.update)
+  const canDelete = can(PERMISSIONS.admins.delete) && !isCurrentUser
+
+  const openDialog = (type: 'view' | 'edit' | 'delete') => {
+    setCurrentRow(admin)
+    setOpen(type)
+  }
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant='ghost' className='flex h-8 w-8 p-0'>
+          <DotsHorizontalIcon className='h-4 w-4' />
+        </Button>
+      </DropdownMenuTrigger>
+
+      <DropdownMenuContent align='end'>
+        <DropdownMenuItem onClick={() => openDialog('view')}>
+          مشاهده
+          <DropdownMenuShortcut>
+            <Eye size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        {canUpdate && (
+          <DropdownMenuItem onClick={() => openDialog('edit')}>
+            ویرایش
+            <DropdownMenuShortcut>
+              <Pencil size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem
+              onClick={() => openDialog('delete')}
+              className='text-red-600'
+            >
+              حذف
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 63 - 0
src/features/admins/index.tsx

@@ -0,0 +1,63 @@
+import { getRouteApi } from '@tanstack/react-router'
+import { useAdmins } from '@/hooks/admins/useAdmins'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { Header } from '@/components/layout/header'
+import { Main } from '@/components/layout/main'
+import { ProfileDropdown } from '@/components/profile-dropdown'
+import { Search } from '@/components/search'
+import { ThemeSwitch } from '@/components/theme-switch'
+import { AdminsDialogs } from './components/admins-dialogs'
+import { AdminsPrimaryButtons } from './components/admins-primary-buttons'
+import { AdminsProvider } from './components/admins-provider'
+import { AdminsTable } from './components/admins-table'
+
+const route = getRouteApi('/_authenticated/admins/')
+
+export function Admins() {
+  const search = route.useSearch()
+  const navigate = route.useNavigate()
+  const { data, isLoading, isError } = useAdmins({
+    page: search.page ?? 1,
+    per_page: search.pageSize ?? 10,
+  })
+
+  return (
+    <AdminsProvider>
+      <Header fixed>
+        <Search className='me-auto' />
+        <ThemeSwitch />
+        <ConfigDrawer />
+        <ProfileDropdown />
+      </Header>
+
+      <Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
+        <div className='flex flex-wrap items-end justify-between gap-2'>
+          <div>
+            <h2 className='text-2xl font-bold tracking-tight'>لیست مدیران</h2>
+            <p className='text-muted-foreground'>
+              مدیران پنل و نقش هر کدام را از اینجا مدیریت کنید.
+            </p>
+          </div>
+          <AdminsPrimaryButtons />
+        </div>
+
+        {isLoading ? (
+          <div>در حال دریافت...</div>
+        ) : isError ? (
+          <div className='text-sm text-destructive'>
+            دریافت مدیران ممکن نشد.
+          </div>
+        ) : (
+          <AdminsTable
+            data={data?.data.admins ?? []}
+            paginationData={data?.data.pagination}
+            search={search}
+            navigate={navigate}
+          />
+        )}
+      </Main>
+
+      <AdminsDialogs />
+    </AdminsProvider>
+  )
+}

+ 62 - 42
src/features/evaluations/components/data-table-row-actions.tsx

@@ -1,8 +1,20 @@
 import { useState } from 'react'
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
-import { CircleCheck, Download, Eye, Pencil, RefreshCcw, Trash2 } from 'lucide-react'
+import { evaluationsService } from '@/services/evaluations.service'
+import { type Evaluation } from '@/types/evaluations.types'
+import {
+  CircleCheck,
+  Download,
+  Eye,
+  Pencil,
+  RefreshCcw,
+  Trash2,
+} from 'lucide-react'
 import { toast } from 'sonner'
+import { downloadFromUrl } from '@/lib/downloadFromUrl'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { Button } from '@/components/ui/button'
 import {
   DropdownMenu,
@@ -12,11 +24,7 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
-
-import { evaluationsService } from '@/services/evaluations.service'
-import { type Evaluation } from '@/types/evaluations.types'
 import { useEvaluations } from './evaluations-provider'
-import { downloadFromUrl } from '@/lib/downloadFromUrl'
 
 interface Props {
   row: Row<Evaluation>
@@ -24,6 +32,9 @@ interface Props {
 
 export function DataTableRowActions({ row }: Props) {
   const { setOpen, setCurrentRow } = useEvaluations()
+  const { can } = useCan()
+  const canUpdate = can(PERMISSIONS.evaluations.update)
+  const canDelete = can(PERMISSIONS.evaluations.delete)
   const [isDownloading, setIsDownloading] = useState(false)
 
   const evaluations = row.original
@@ -98,15 +109,17 @@ export function DataTableRowActions({ row }: Props) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
-        <DropdownMenuItem
-          onClick={handleEdit}
-          disabled={evaluations?.status === 'pending'}
-        >
-          ویرایش ارزیابی
-          <DropdownMenuShortcut>
-            <Pencil size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
+        {canUpdate && (
+          <DropdownMenuItem
+            onClick={handleEdit}
+            disabled={evaluations?.status === 'pending'}
+          >
+            ویرایش ارزیابی
+            <DropdownMenuShortcut>
+              <Pencil size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
 
         <DropdownMenuItem
           onClick={handleDownloadVideo}
@@ -118,34 +131,41 @@ export function DataTableRowActions({ row }: Props) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
-        <DropdownMenuItem
-          onClick={handleShowConfirmApprove}
-          disabled={evaluations?.status !== 'approved_ai'}
-        >
-          تایید ارزیابی
-          <DropdownMenuShortcut>
-            <CircleCheck size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        <DropdownMenuItem
-          onClick={handleShowConfirmRetry}
-          disabled={evaluations?.status === 'pending'}
-        >
-          ارزیابی مجدد
-          <DropdownMenuShortcut>
-            <RefreshCcw size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        <DropdownMenuSeparator />
-
-        <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
-          حذف ارزیابی
-          <DropdownMenuShortcut>
-            <Trash2 size={16} color='red' />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
+        {canUpdate && (
+          <DropdownMenuItem
+            onClick={handleShowConfirmApprove}
+            disabled={evaluations?.status !== 'approved_ai'}
+          >
+            تایید ارزیابی
+            <DropdownMenuShortcut>
+              <CircleCheck size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
+        {canUpdate && (
+          <DropdownMenuItem
+            onClick={handleShowConfirmRetry}
+            disabled={evaluations?.status === 'pending'}
+          >
+            ارزیابی مجدد
+            <DropdownMenuShortcut>
+              <RefreshCcw size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
+              حذف ارزیابی
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
       </DropdownMenuContent>
     </DropdownMenu>
   )

+ 44 - 38
src/features/evaluations/components/evaluations-action-dialog.tsx

@@ -1,21 +1,23 @@
+import { AlertTriangle } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useEvaluation } from '@/hooks/evaluations/useEvaluation'
+import { useCan } from '@/hooks/use-can'
 import {
   Dialog,
   DialogContent,
   DialogHeader,
   DialogTitle,
 } from '@/components/ui/dialog'
-
-import { useEvaluations } from './evaluations-provider'
-
-import { useEvaluation } from '@/hooks/evaluations/useEvaluation'
 import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
-import { AlertTriangle } from 'lucide-react'
 import VideoPlayer from '@/components/video-player'
+import { useEvaluations } from './evaluations-provider'
 
 export function EvaluationsActionDialog() {
   const { open, setOpen, currentRow } = useEvaluations()
 
   const opened = open === 'view'
+  const { can } = useCan()
+  const canViewItems = can(PERMISSIONS.items.view)
 
   const { data: evaluation, isLoading } = useEvaluation(
     currentRow?.id as number
@@ -37,47 +39,51 @@ export function EvaluationsActionDialog() {
           <div className='flex flex-col gap-4'>
             {mediaSrc && <VideoPlayer src={mediaSrc as string} />}
 
-            {evaluation.items.length === 0 ? (
+            {canViewItems && evaluation.items.length === 0 ? (
               <div className='flex items-center justify-center gap-3 rounded-xl border bg-muted px-2 py-4 text-center text-sm font-bold text-primary'>
                 <AlertTriangle />
                 <span>ارزیابی برای این کاربر یافت نشد.</span>
               </div>
             ) : (
               <>
-                <h2>نتیجه ارزیابی</h2>
-                <div className='overflow-hidden rounded-2xl border border-slate-200'>
-                  <Table>
-                    <TableBody>
-                      {evaluation?.items.map((item) => (
-                        <TableRow
-                          key={item.id}
-                          className='border-b border-slate-100 last:border-0 hover:bg-transparent'
-                        >
-                          <TableCell className='p-3 text-right text-sm leading-5'>
-                            {item.item.name}
-                          </TableCell>
+                {canViewItems && (
+                  <>
+                    <h2>نتیجه ارزیابی</h2>
+                    <div className='overflow-hidden rounded-2xl border border-slate-200'>
+                      <Table>
+                        <TableBody>
+                          {evaluation?.items.map((item) => (
+                            <TableRow
+                              key={item.id}
+                              className='border-b border-slate-100 last:border-0 hover:bg-transparent'
+                            >
+                              <TableCell className='p-3 text-right text-sm leading-5'>
+                                {item.item.name}
+                              </TableCell>
 
-                          <TableCell className='flex items-center justify-end gap-1 p-3 text-muted-foreground'>
-                            <span className='text-foreground'> 100 /</span>
-                            <span className='text-base text-foreground'>
-                              {item.score}
-                            </span>
-                          </TableCell>
-                        </TableRow>
-                      ))}
-                      <TableRow className='border-b border-slate-100 last:border-0 hover:bg-transparent'>
-                        <TableCell className='p-3 text-right text-sm leading-5 font-semibold'>
-                          مجموع عملکرد
-                        </TableCell>
+                              <TableCell className='flex items-center justify-end gap-1 p-3 text-muted-foreground'>
+                                <span className='text-foreground'> 100 /</span>
+                                <span className='text-base text-foreground'>
+                                  {item.score}
+                                </span>
+                              </TableCell>
+                            </TableRow>
+                          ))}
+                          <TableRow className='border-b border-slate-100 last:border-0 hover:bg-transparent'>
+                            <TableCell className='p-3 text-right text-sm leading-5 font-semibold'>
+                              مجموع عملکرد
+                            </TableCell>
 
-                        <TableCell className='flex items-center justify-end gap-1 p-3 text-base font-semibold'>
-                          <span> 100 /</span>
-                          <span>{evaluation.score}</span>
-                        </TableCell>
-                      </TableRow>
-                    </TableBody>
-                  </Table>
-                </div>
+                            <TableCell className='flex items-center justify-end gap-1 p-3 text-base font-semibold'>
+                              <span> 100 /</span>
+                              <span>{evaluation.score}</span>
+                            </TableCell>
+                          </TableRow>
+                        </TableBody>
+                      </Table>
+                    </div>
+                  </>
+                )}
 
                 {evaluation?.pros_comment && (
                   <div className='flex flex-col gap-4'>

+ 92 - 0
src/features/permissions/index.tsx

@@ -0,0 +1,92 @@
+import { usePermissionModules } from '@/hooks/permissions/usePermissionModules'
+import { Badge } from '@/components/ui/badge'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { Header } from '@/components/layout/header'
+import { Main } from '@/components/layout/main'
+import { ProfileDropdown } from '@/components/profile-dropdown'
+import { Search } from '@/components/search'
+import { ThemeSwitch } from '@/components/theme-switch'
+
+export function Permissions() {
+  const { data: modules, isLoading, isError } = usePermissionModules()
+
+  return (
+    <>
+      <Header fixed>
+        <Search className='me-auto' />
+        <ThemeSwitch />
+        <ConfigDrawer />
+        <ProfileDropdown />
+      </Header>
+
+      <Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
+        <div>
+          <h2 className='text-2xl font-bold tracking-tight'>دسترسی‌ها</h2>
+          <p className='text-muted-foreground'>
+            فهرست دسترسی‌هایی که می‌توان به نقش‌ها اختصاص داد.
+          </p>
+        </div>
+
+        {isLoading ? (
+          <div>در حال دریافت...</div>
+        ) : isError ? (
+          <div className='text-sm text-destructive'>
+            دریافت دسترسی‌ها ممکن نشد.
+          </div>
+        ) : (
+          <div className='grid gap-4'>
+            {modules?.map((module) => (
+              <Card key={module.key}>
+                <CardHeader>
+                  <CardTitle>{module.label}</CardTitle>
+                  <CardDescription>{module.key}</CardDescription>
+                </CardHeader>
+                <CardContent className='space-y-4'>
+                  {module.actions.length > 0 && (
+                    <div className='space-y-2'>
+                      <p className='text-sm font-medium'>عملیات</p>
+                      <div className='flex flex-wrap gap-2'>
+                        {module.actions.map((action) => (
+                          <Badge key={action.name} variant='secondary'>
+                            {action.label}
+                          </Badge>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+
+                  {module.fields.length > 0 && (
+                    <div className='space-y-3'>
+                      <p className='text-sm font-medium'>فیلدها</p>
+                      {module.fields.map((field) => (
+                        <div key={field.key} className='space-y-2'>
+                          <p className='text-sm text-muted-foreground'>
+                            {field.label}
+                          </p>
+                          <div className='flex flex-wrap gap-2'>
+                            {field.permissions.map((permission) => (
+                              <Badge key={permission.name} variant='outline'>
+                                {permission.label}
+                              </Badge>
+                            ))}
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </CardContent>
+              </Card>
+            ))}
+          </div>
+        )}
+      </Main>
+    </>
+  )
+}

+ 25 - 16
src/features/posts/components/data-table-row-actions.tsx

@@ -1,6 +1,9 @@
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
+import { type Post } from '@/types/posts.types'
 import { Trash2, Eye, EyeOff } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { Button } from '@/components/ui/button'
 import {
   DropdownMenu,
@@ -10,9 +13,7 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
-
 import { usePosts } from './posts-provider'
-import { type Post } from '@/types/posts.types'
 
 interface Props {
   row: Row<Post>
@@ -20,6 +21,9 @@ interface Props {
 
 export function DataTableRowActions({ row }: Props) {
   const { setOpen, setCurrentRow } = usePosts()
+  const { can } = useCan()
+  const canUpdate = can(PERMISSIONS.posts.update)
+  const canDelete = can(PERMISSIONS.posts.delete)
 
   const post = row.original
 
@@ -54,21 +58,26 @@ export function DataTableRowActions({ row }: Props) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
-        <DropdownMenuItem onClick={handleChangeState}>
-          تغییر وضعیت
-          <DropdownMenuShortcut>
-            <EyeOff size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        <DropdownMenuSeparator />
+        {canUpdate && (
+          <DropdownMenuItem onClick={handleChangeState}>
+            تغییر وضعیت
+            <DropdownMenuShortcut>
+              <EyeOff size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
 
-        <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
-          حذف
-          <DropdownMenuShortcut>
-            <Trash2 size={16} color='red' />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
+              حذف
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
       </DropdownMenuContent>
     </DropdownMenu>
   )

+ 69 - 0
src/features/roles/components/activity-field-picker.tsx

@@ -0,0 +1,69 @@
+import { Checkbox } from '@/components/ui/checkbox'
+
+type ActivityFieldOption = {
+  id: number
+  name: string
+}
+
+type ActivityFieldPickerProps = {
+  fields: ActivityFieldOption[]
+  value: number[]
+  onChange: (value: number[]) => void
+  disabled?: boolean
+}
+
+export function ActivityFieldPicker({
+  fields,
+  value,
+  onChange,
+  disabled = false,
+}: ActivityFieldPickerProps) {
+  const selected = new Set(value)
+  const selectedCount = fields.filter((field) => selected.has(field.id)).length
+  const allChecked = fields.length > 0 && selectedCount === fields.length
+
+  const toggle = (id: number, checked: boolean) => {
+    onChange(
+      checked ? [...value, id] : value.filter((currentId) => currentId !== id)
+    )
+  }
+
+  return (
+    <div className='overflow-hidden rounded-md border'>
+      <div className='flex items-center justify-between gap-3 border-b px-3 py-2'>
+        <label className='flex items-center gap-2 text-sm font-medium'>
+          <Checkbox
+            checked={
+              allChecked ? true : selectedCount > 0 ? 'indeterminate' : false
+            }
+            disabled={disabled || fields.length === 0}
+            onCheckedChange={(checked) =>
+              onChange(checked === true ? fields.map((field) => field.id) : [])
+            }
+          />
+          همه رشته‌ها
+        </label>
+        <span className='text-xs text-muted-foreground'>
+          {selectedCount} از {fields.length}
+        </span>
+      </div>
+
+      <div className='max-h-44 overflow-y-auto p-3'>
+        <div className='grid gap-2 sm:grid-cols-2'>
+          {fields.map((field) => (
+            <label key={field.id} className='flex items-center gap-2 text-sm'>
+              <Checkbox
+                checked={selected.has(field.id)}
+                disabled={disabled}
+                onCheckedChange={(checked) =>
+                  toggle(field.id, checked === true)
+                }
+              />
+              {field.name}
+            </label>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 80 - 0
src/features/roles/components/data-table-row-actions.tsx

@@ -0,0 +1,80 @@
+import { DotsHorizontalIcon } from '@radix-ui/react-icons'
+import { type Row } from '@tanstack/react-table'
+import { type Role } from '@/types/roles.types'
+import { Eye, Pencil, Trash2 } from 'lucide-react'
+import { useAuthStore } from '@/stores/auth-store'
+import { roleNamesOf } from '@/lib/resolve-permissions'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useRolesContext } from './roles-provider'
+
+interface Props {
+  row: Row<Role>
+}
+
+export function DataTableRowActions({ row }: Props) {
+  const { setOpen, setCurrentRow } = useRolesContext()
+  const { can } = useCan()
+  const user = useAuthStore((state) => state.user)
+  const role = row.original
+  const ownsRole = roleNamesOf(user).includes(role.name)
+  const canUpdate = can(PERMISSIONS.roles.update)
+  const canDelete = can(PERMISSIONS.roles.delete) && !ownsRole
+
+  const openDialog = (type: 'view' | 'edit' | 'delete') => {
+    setCurrentRow(role)
+    setOpen(type)
+  }
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant='ghost' className='flex h-8 w-8 p-0'>
+          <DotsHorizontalIcon className='h-4 w-4' />
+        </Button>
+      </DropdownMenuTrigger>
+
+      <DropdownMenuContent align='end'>
+        <DropdownMenuItem onClick={() => openDialog('view')}>
+          مشاهده
+          <DropdownMenuShortcut>
+            <Eye size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        {canUpdate && (
+          <DropdownMenuItem onClick={() => openDialog('edit')}>
+            ویرایش
+            <DropdownMenuShortcut>
+              <Pencil size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem
+              onClick={() => openDialog('delete')}
+              className='text-red-600'
+            >
+              حذف
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 175 - 0
src/features/roles/components/permission-picker.tsx

@@ -0,0 +1,175 @@
+import type { PermissionModule } from '@/types/permissions.types'
+import {
+  flattenModulePermissions,
+  modulePermissionNames,
+  togglePermissionNames,
+  togglePermissionSelection,
+} from '@/lib/permissions'
+import { Checkbox } from '@/components/ui/checkbox'
+
+type PermissionPickerProps = {
+  modules: PermissionModule[]
+  value: string[]
+  onChange: (value: string[]) => void
+  disabled?: boolean
+}
+
+export function PermissionPicker({
+  modules,
+  value,
+  onChange,
+  disabled = false,
+}: PermissionPickerProps) {
+  const catalog = flattenModulePermissions(modules)
+  const selected = new Set(value)
+  const allNames = catalog.map((item) => item.name)
+  const selectedCount = allNames.filter((name) => selected.has(name)).length
+  const allChecked = allNames.length > 0 && selectedCount === allNames.length
+
+  return (
+    <div className='space-y-4'>
+      <div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/40 px-3 py-2'>
+        <label className='flex items-center gap-2 text-sm font-medium'>
+          <Checkbox
+            checked={
+              allChecked ? true : selectedCount > 0 ? 'indeterminate' : false
+            }
+            disabled={disabled || allNames.length === 0}
+            onCheckedChange={(checked) =>
+              onChange(
+                togglePermissionNames(
+                  value,
+                  allNames,
+                  checked === true,
+                  catalog
+                )
+              )
+            }
+          />
+          همه دسترسی‌ها
+        </label>
+        <span className='text-xs text-muted-foreground'>
+          {selectedCount} از {allNames.length}
+        </span>
+      </div>
+
+      <p className='text-xs text-muted-foreground'>
+        با انتخاب ویرایش، دسترسی مشاهده همان مورد هم فعال می‌شود. برداشتن
+        مشاهده، ویرایش وابسته را هم برمی‌دارد.
+      </p>
+
+      {modules.map((module) => {
+        const names = modulePermissionNames(module)
+        const moduleSelectedCount = names.filter((name) =>
+          selected.has(name)
+        ).length
+        const moduleChecked =
+          names.length > 0 && moduleSelectedCount === names.length
+
+        return (
+          <section key={module.key} className='rounded-lg border'>
+            <div className='flex items-center justify-between gap-3 border-b px-3 py-2'>
+              <label className='flex items-center gap-2 text-sm font-semibold'>
+                <Checkbox
+                  checked={
+                    moduleChecked
+                      ? true
+                      : moduleSelectedCount > 0
+                        ? 'indeterminate'
+                        : false
+                  }
+                  disabled={disabled || names.length === 0}
+                  onCheckedChange={(checked) =>
+                    onChange(
+                      togglePermissionNames(
+                        value,
+                        names,
+                        checked === true,
+                        catalog
+                      )
+                    )
+                  }
+                />
+                {module.label}
+              </label>
+              <span className='text-xs text-muted-foreground'>
+                {moduleSelectedCount} از {names.length}
+              </span>
+            </div>
+
+            <div className='space-y-4 p-3'>
+              {module.actions.length > 0 && (
+                <div className='space-y-2'>
+                  <p className='text-xs font-medium text-muted-foreground'>
+                    عملیات
+                  </p>
+                  <div className='grid gap-2 sm:grid-cols-2'>
+                    {module.actions.map((action) => (
+                      <label
+                        key={action.name}
+                        className='flex items-center gap-2 text-sm'
+                      >
+                        <Checkbox
+                          checked={selected.has(action.name)}
+                          disabled={disabled}
+                          onCheckedChange={(checked) =>
+                            onChange(
+                              togglePermissionSelection(
+                                value,
+                                action.name,
+                                checked === true,
+                                catalog
+                              )
+                            )
+                          }
+                        />
+                        {action.label}
+                      </label>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {module.fields.length > 0 && (
+                <div className='space-y-3'>
+                  <p className='text-xs font-medium text-muted-foreground'>
+                    فیلدها
+                  </p>
+                  {module.fields.map((field) => (
+                    <div key={field.key} className='space-y-2'>
+                      <p className='text-sm font-medium'>{field.label}</p>
+                      <div className='grid gap-2 sm:grid-cols-2'>
+                        {field.permissions.map((permission) => (
+                          <label
+                            key={permission.name}
+                            className='flex items-center gap-2 text-sm'
+                          >
+                            <Checkbox
+                              checked={selected.has(permission.name)}
+                              disabled={disabled}
+                              onCheckedChange={(checked) =>
+                                onChange(
+                                  togglePermissionSelection(
+                                    value,
+                                    permission.name,
+                                    checked === true,
+                                    catalog
+                                  )
+                                )
+                              }
+                            />
+                            {permission.label}
+                          </label>
+                        ))}
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          </section>
+        )
+      })}
+    </div>
+  )
+}

+ 253 - 0
src/features/roles/components/roles-action-dialog.tsx

@@ -0,0 +1,253 @@
+import { useEffect, useMemo } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { useActivityFields } from '@/hooks/activity-fields/useActivityFields'
+import { usePermissionModules } from '@/hooks/permissions/usePermissionModules'
+import { useCreateRole } from '@/hooks/roles/useCreateRole'
+import { useRole } from '@/hooks/roles/useRole'
+import { useUpdateRole } from '@/hooks/roles/useUpdateRole'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { ActivityFieldPicker } from './activity-field-picker'
+import { PermissionPicker } from './permission-picker'
+import { useRolesContext } from './roles-provider'
+
+const schema = z.object({
+  name: z.string().trim().min(1, 'نام نقش الزامی است'),
+  permissions: z.array(z.string()).min(1, 'حداقل یک دسترسی را انتخاب کنید'),
+  activity_field_ids: z
+    .array(z.number())
+    .min(1, 'حداقل یک رشته را انتخاب کنید'),
+})
+
+type FormValues = z.infer<typeof schema>
+
+export function RolesActionDialog() {
+  const { open, setOpen, currentRow, setCurrentRow } = useRolesContext()
+  const isAdd = open === 'add'
+  const isEdit = open === 'edit'
+  const isView = open === 'view'
+  const opened = isAdd || isEdit || isView
+  const readOnly = isView
+
+  const roleQuery = useRole(opened && !isAdd ? currentRow?.id : undefined)
+  const modulesQuery = usePermissionModules(opened)
+  const activityFieldsQuery = useActivityFields(opened)
+  const createMutation = useCreateRole()
+  const updateMutation = useUpdateRole()
+  const isPending = createMutation.isPending || updateMutation.isPending
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: {
+      name: '',
+      permissions: [],
+      activity_field_ids: [],
+    },
+  })
+
+  useEffect(() => {
+    if (!opened) return
+
+    if (isAdd) {
+      form.reset({ name: '', permissions: [], activity_field_ids: [] })
+      return
+    }
+
+    if (!roleQuery.data) return
+
+    form.reset({
+      name: roleQuery.data.name,
+      permissions: roleQuery.data.permissions.map(
+        (permission) => permission.name
+      ),
+      activity_field_ids: roleQuery.data.activity_field_ids?.length
+        ? roleQuery.data.activity_field_ids
+        : (roleQuery.data.activity_fields ?? []).map((field) => field.id),
+    })
+  }, [opened, isAdd, roleQuery.data, form])
+
+  const handleClose = () => {
+    setOpen(null)
+    setCurrentRow(null)
+    form.reset({ name: '', permissions: [], activity_field_ids: [] })
+  }
+
+  const onSubmit = (values: FormValues) => {
+    if (readOnly) return
+
+    const payload = {
+      name: values.name.trim(),
+      permissions: values.permissions,
+      activity_field_ids: values.activity_field_ids,
+    }
+
+    if (isAdd) {
+      createMutation.mutate(payload, {
+        onSuccess: () => {
+          toast.success('نقش با موفقیت ایجاد شد')
+          handleClose()
+        },
+        onError: (error) => {
+          toast.error(getApiErrorMessage(error, 'خطا در ایجاد نقش'))
+        },
+      })
+    }
+
+    if (isEdit && currentRow) {
+      updateMutation.mutate(
+        { id: currentRow.id, payload },
+        {
+          onSuccess: () => {
+            toast.success('نقش با موفقیت ویرایش شد')
+            handleClose()
+          },
+          onError: (error) => {
+            toast.error(getApiErrorMessage(error, 'خطا در ویرایش نقش'))
+          },
+        }
+      )
+    }
+  }
+
+  const activityFieldOptions = useMemo(() => {
+    const options = new Map<number, string>()
+
+    for (const field of activityFieldsQuery.data ?? []) {
+      options.set(field.id, field.name)
+    }
+
+    for (const field of roleQuery.data?.activity_fields ?? []) {
+      if (!options.has(field.id)) options.set(field.id, field.name)
+    }
+
+    return [...options.entries()].map(([id, name]) => ({ id, name }))
+  }, [activityFieldsQuery.data, roleQuery.data])
+
+  const isLoading =
+    modulesQuery.isLoading ||
+    activityFieldsQuery.isLoading ||
+    ((isEdit || isView) && roleQuery.isLoading)
+
+  return (
+    <Dialog open={opened} onOpenChange={(state) => !state && handleClose()}>
+      <DialogContent className='flex max-h-[90vh] flex-col gap-4 overflow-hidden sm:max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle>
+            {isAdd && 'افزودن نقش'}
+            {isEdit && 'ویرایش نقش'}
+            {isView && 'مشاهده نقش'}
+          </DialogTitle>
+        </DialogHeader>
+
+        {isLoading ? (
+          <div className='py-10 text-center'>در حال دریافت...</div>
+        ) : modulesQuery.isError || roleQuery.isError ? (
+          <div className='py-10 text-center text-sm text-destructive'>
+            دریافت اطلاعات نقش ممکن نشد.
+          </div>
+        ) : (
+          <Form {...form}>
+            <form
+              onSubmit={form.handleSubmit(onSubmit)}
+              className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'
+            >
+              <FormField
+                control={form.control}
+                name='name'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نام نقش</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        disabled={readOnly}
+                        placeholder='نام نقش را وارد کنید'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='activity_field_ids'
+                render={({ field }) => (
+                  <FormItem className='shrink-0'>
+                    <FormLabel>رشته‌ها</FormLabel>
+                    {activityFieldsQuery.isError ? (
+                      <p className='text-sm text-destructive'>
+                        دریافت رشته‌ها ممکن نشد.
+                      </p>
+                    ) : activityFieldOptions.length === 0 ? (
+                      <p className='text-sm text-muted-foreground'>
+                        رشته‌ای برای انتخاب وجود ندارد.
+                      </p>
+                    ) : (
+                      <ActivityFieldPicker
+                        fields={activityFieldOptions}
+                        value={field.value}
+                        onChange={field.onChange}
+                        disabled={readOnly}
+                      />
+                    )}
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='permissions'
+                render={({ field }) => (
+                  <FormItem className='flex min-h-0 flex-1 flex-col'>
+                    <FormLabel>دسترسی‌ها</FormLabel>
+                    <div className='min-h-48 flex-1 overflow-y-auto rounded-md border p-3'>
+                      <PermissionPicker
+                        modules={modulesQuery.data ?? []}
+                        value={field.value}
+                        onChange={field.onChange}
+                        disabled={readOnly}
+                      />
+                    </div>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter className='shrink-0'>
+                <Button type='button' variant='outline' onClick={handleClose}>
+                  {readOnly ? 'بستن' : 'انصراف'}
+                </Button>
+                {!readOnly && (
+                  <Button type='submit' disabled={isPending}>
+                    {isPending ? 'در حال ذخیره...' : 'ذخیره'}
+                  </Button>
+                )}
+              </DialogFooter>
+            </form>
+          </Form>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 98 - 0
src/features/roles/components/roles-columns.tsx

@@ -0,0 +1,98 @@
+import { type ColumnDef } from '@tanstack/react-table'
+import { type Role } from '@/types/roles.types'
+import { Badge } from '@/components/ui/badge'
+import { DataTableColumnHeader } from '@/components/data-table'
+import { LongText } from '@/components/long-text'
+import { DataTableRowActions } from './data-table-row-actions'
+
+const roleStatusLabels: Record<string, string> = {
+  active: 'فعال',
+  inactive: 'غیرفعال',
+}
+
+export const rolesColumns: ColumnDef<Role>[] = [
+  {
+    accessorKey: 'id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شناسه' />
+    ),
+    cell: ({ row }) => <div className='w-15'>#{row.original.id}</div>,
+  },
+  {
+    accessorKey: 'name',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام نقش' />
+    ),
+    cell: ({ row }) => (
+      <LongText className='max-w-48'>{row.original.name}</LongText>
+    ),
+  },
+  {
+    accessorKey: 'status',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='وضعیت' />
+    ),
+    cell: ({ row }) => (
+      <Badge
+        variant={row.original.status === 'active' ? 'default' : 'secondary'}
+      >
+        {roleStatusLabels[row.original.status] ?? row.original.status}
+      </Badge>
+    ),
+  },
+  {
+    id: 'activity_fields',
+    accessorFn: (row) =>
+      row.activity_fields?.map((field) => field.name).join(', ') ?? '',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='رشته‌ها' />
+    ),
+    cell: ({ row }) => {
+      const fields = row.original.activity_fields ?? []
+
+      if (fields.length === 0) {
+        return <span className='text-sm text-muted-foreground'>—</span>
+      }
+
+      const visibleFields = fields.length > 3 ? fields.slice(0, 2) : fields
+      const hiddenCount = fields.length - visibleFields.length
+
+      return (
+        <div className='flex max-w-64 flex-nowrap items-center gap-1'>
+          {visibleFields.map((field) => (
+            <Badge key={field.id} variant='outline'>
+              {field.name}
+            </Badge>
+          ))}
+          {hiddenCount > 0 && <Badge variant='secondary'>+{hiddenCount}</Badge>}
+        </div>
+      )
+    },
+  },
+  {
+    id: 'permissions_count',
+    accessorFn: (row) => row.permissions.length,
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='تعداد دسترسی' />
+    ),
+    cell: ({ row }) => (
+      <Badge variant='secondary'>{row.original.permissions.length}</Badge>
+    ),
+  },
+  {
+    accessorKey: 'created_at',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='تاریخ ایجاد' />
+    ),
+    cell: ({ row }) => (
+      <div className='text-sm text-muted-foreground'>
+        {new Date(row.original.created_at).toLocaleDateString('fa-IR')}
+      </div>
+    ),
+  },
+  {
+    id: 'actions',
+    cell: DataTableRowActions,
+    enableSorting: false,
+  },
+]

+ 94 - 0
src/features/roles/components/roles-delete-dialog.tsx

@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { type Role } from '@/types/roles.types'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { useDeleteRole } from '@/hooks/roles/useDeleteRole'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Role | null
+}
+
+export function RolesDeleteDialog({ open, onOpenChange, currentRow }: Props) {
+  const [value, setValue] = useState('')
+  const { mutate, isPending } = useDeleteRole()
+
+  if (!currentRow) return null
+
+  const handleDelete = () => {
+    mutate(currentRow.id, {
+      onSuccess: () => {
+        toast.success('نقش با موفقیت حذف شد')
+        setValue('')
+        onOpenChange(false)
+      },
+      onError: (error) => {
+        toast.error(getApiErrorMessage(error, 'خطا در حذف نقش'))
+      },
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={(state) => {
+        if (!state) setValue('')
+        onOpenChange(state)
+      }}
+      form='roles-delete-form'
+      disabled={value.trim() !== currentRow.name}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='me-1 inline-block stroke-destructive'
+            size={18}
+          />{' '}
+          حذف نقش
+        </span>
+      }
+      desc={
+        <form
+          id='roles-delete-form'
+          onSubmit={(event) => {
+            event.preventDefault()
+            handleDelete()
+          }}
+          className='space-y-4'
+        >
+          <p className='mb-2'>
+            آیا از حذف نقش <span className='font-bold'>{currentRow.name}</span>{' '}
+            مطمئن هستید؟
+          </p>
+
+          <Label>
+            نام نقش:
+            <Input
+              value={value}
+              onChange={(event) => setValue(event.target.value)}
+              placeholder='نام نقش را برای تایید حذف وارد کنید.'
+              autoFocus
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>هشدار!</AlertTitle>
+            <AlertDescription>
+              این عملیات قابل برگشت نیست و دسترسی مدیران دارای این نقش تغییر
+              می‌کند.
+            </AlertDescription>
+          </Alert>
+        </form>
+      }
+      confirmText='حذف'
+      cancelBtnText='انصراف'
+      isLoading={isPending}
+      destructive
+    />
+  )
+}

+ 23 - 0
src/features/roles/components/roles-dialogs.tsx

@@ -0,0 +1,23 @@
+import { RolesActionDialog } from './roles-action-dialog'
+import { RolesDeleteDialog } from './roles-delete-dialog'
+import { useRolesContext } from './roles-provider'
+
+export function RolesDialogs() {
+  const { open, setOpen, currentRow, setCurrentRow } = useRolesContext()
+
+  return (
+    <>
+      <RolesActionDialog />
+      <RolesDeleteDialog
+        open={open === 'delete'}
+        onOpenChange={(state) => {
+          if (!state) {
+            setOpen(null)
+            setCurrentRow(null)
+          }
+        }}
+        currentRow={currentRow}
+      />
+    </>
+  )
+}

+ 18 - 0
src/features/roles/components/roles-primary-buttons.tsx

@@ -0,0 +1,18 @@
+import { ShieldPlus } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import { useRolesContext } from './roles-provider'
+
+export function RolesPrimaryButtons() {
+  const { setOpen } = useRolesContext()
+  const { can } = useCan()
+
+  if (!can(PERMISSIONS.roles.create)) return null
+
+  return (
+    <Button className='space-x-1' onClick={() => setOpen('add')}>
+      <span>افزودن نقش</span> <ShieldPlus size={18} />
+    </Button>
+  )
+}

+ 35 - 0
src/features/roles/components/roles-provider.tsx

@@ -0,0 +1,35 @@
+import React, { createContext, useContext, useState } from 'react'
+import { type Role } from '@/types/roles.types'
+
+type RolesDialogType = 'add' | 'edit' | 'view' | 'delete'
+
+interface RolesContextType {
+  open: RolesDialogType | null
+  setOpen: (value: RolesDialogType | null) => void
+  currentRow: Role | null
+  setCurrentRow: React.Dispatch<React.SetStateAction<Role | null>>
+}
+
+const RolesContext = createContext<RolesContextType | undefined>(undefined)
+
+export function RolesProvider({ children }: { children: React.ReactNode }) {
+  const [open, setOpen] = useState<RolesDialogType | null>(null)
+  const [currentRow, setCurrentRow] = useState<Role | null>(null)
+
+  return (
+    <RolesContext.Provider value={{ open, setOpen, currentRow, setCurrentRow }}>
+      {children}
+    </RolesContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useRolesContext() {
+  const context = useContext(RolesContext)
+
+  if (!context) {
+    throw new Error('useRolesContext must be used within RolesProvider')
+  }
+
+  return context
+}

+ 124 - 0
src/features/roles/components/roles-table.tsx

@@ -0,0 +1,124 @@
+import { useEffect, useState } from 'react'
+import {
+  type SortingState,
+  flexRender,
+  getCoreRowModel,
+  getSortedRowModel,
+  useReactTable,
+} from '@tanstack/react-table'
+import { type Pagination } from '@/types/common.types'
+import { type Role } from '@/types/roles.types'
+import { cn } from '@/lib/utils'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { DataTablePagination } from '@/components/data-table'
+import { rolesColumns as columns } from './roles-columns'
+
+type RolesTableProps = {
+  data: Role[]
+  paginationData?: Pagination
+  search: Record<string, unknown>
+  navigate: NavigateFn
+}
+
+export function RolesTable({
+  data,
+  paginationData,
+  search,
+  navigate,
+}: RolesTableProps) {
+  const [sorting, setSorting] = useState<SortingState>([])
+  const { pagination, onPaginationChange, ensurePageInRange } =
+    useTableUrlState({
+      search,
+      navigate,
+      pagination: { defaultPage: 1, defaultPageSize: 10 },
+      globalFilter: { enabled: false },
+    })
+
+  // eslint-disable-next-line react-hooks/incompatible-library
+  const table = useReactTable({
+    data,
+    columns,
+    state: { sorting, pagination },
+    pageCount: paginationData?.last_page ?? -1,
+    manualPagination: true,
+    onPaginationChange,
+    onSortingChange: setSorting,
+    getCoreRowModel: getCoreRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+  })
+
+  useEffect(() => {
+    ensurePageInRange(table.getPageCount())
+  }, [table, ensurePageInRange])
+
+  return (
+    <div className='flex flex-1 flex-col gap-4'>
+      <div className='overflow-hidden rounded-md border'>
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id} className='group/row'>
+                {headerGroup.headers.map((header) => (
+                  <TableHead
+                    key={header.id}
+                    colSpan={header.colSpan}
+                    className='bg-background group-hover/row:bg-muted'
+                  >
+                    {header.isPlaceholder
+                      ? null
+                      : flexRender(
+                          header.column.columnDef.header,
+                          header.getContext()
+                        )}
+                  </TableHead>
+                ))}
+              </TableRow>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {table.getRowModel().rows.length ? (
+              table.getRowModel().rows.map((row) => (
+                <TableRow key={row.id} className='group/row'>
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell
+                      key={cell.id}
+                      className={cn(
+                        'bg-background group-hover/row:bg-muted',
+                        cell.column.columnDef.meta?.className,
+                        cell.column.columnDef.meta?.tdClassName
+                      )}
+                    >
+                      {flexRender(
+                        cell.column.columnDef.cell,
+                        cell.getContext()
+                      )}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell
+                  colSpan={columns.length}
+                  className='h-24 text-center'
+                >
+                  نتیجه‌ای یافت نشد.
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </div>
+      <DataTablePagination table={table} className='mt-auto' />
+    </div>
+  )
+}

+ 63 - 0
src/features/roles/index.tsx

@@ -0,0 +1,63 @@
+import { getRouteApi } from '@tanstack/react-router'
+import { useRoles } from '@/hooks/roles/useRoles'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { Header } from '@/components/layout/header'
+import { Main } from '@/components/layout/main'
+import { ProfileDropdown } from '@/components/profile-dropdown'
+import { Search } from '@/components/search'
+import { ThemeSwitch } from '@/components/theme-switch'
+import { RolesDialogs } from './components/roles-dialogs'
+import { RolesPrimaryButtons } from './components/roles-primary-buttons'
+import { RolesProvider } from './components/roles-provider'
+import { RolesTable } from './components/roles-table'
+
+const route = getRouteApi('/_authenticated/roles/')
+
+export function Roles() {
+  const search = route.useSearch()
+  const navigate = route.useNavigate()
+  const { data, isLoading, isError } = useRoles({
+    page: search.page ?? 1,
+    per_page: search.pageSize ?? 10,
+  })
+
+  return (
+    <RolesProvider>
+      <Header fixed>
+        <Search className='me-auto' />
+        <ThemeSwitch />
+        <ConfigDrawer />
+        <ProfileDropdown />
+      </Header>
+
+      <Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
+        <div className='flex flex-wrap items-end justify-between gap-2'>
+          <div>
+            <h2 className='text-2xl font-bold tracking-tight'>لیست نقش‌ها</h2>
+            <p className='text-muted-foreground'>
+              نقش‌ها و دسترسی هر نقش را از اینجا مدیریت کنید.
+            </p>
+          </div>
+          <RolesPrimaryButtons />
+        </div>
+
+        {isLoading ? (
+          <div>در حال دریافت...</div>
+        ) : isError ? (
+          <div className='text-sm text-destructive'>
+            دریافت نقش‌ها ممکن نشد.
+          </div>
+        ) : (
+          <RolesTable
+            data={data?.data.roles ?? []}
+            paginationData={data?.data.pagination}
+            search={search}
+            navigate={navigate}
+          />
+        )}
+      </Main>
+
+      <RolesDialogs />
+    </RolesProvider>
+  )
+}

+ 25 - 17
src/features/users/components/data-table-row-actions.tsx

@@ -1,6 +1,9 @@
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
+import { type User } from '@/types/users.types'
 import { Trash2, UserPen, Eye } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { Button } from '@/components/ui/button'
 import {
   DropdownMenu,
@@ -10,9 +13,6 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
-
-import { type User } from '@/types/users.types'
-
 import { useUsers } from './users-provider'
 
 interface Props {
@@ -21,6 +21,9 @@ interface Props {
 
 export function DataTableRowActions({ row }: Props) {
   const { setOpen, setCurrentRow } = useUsers()
+  const { can } = useCan()
+  const canUpdate = can(PERMISSIONS.users.update)
+  const canDelete = can(PERMISSIONS.users.delete)
 
   const user = row.original
 
@@ -55,21 +58,26 @@ export function DataTableRowActions({ row }: Props) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
-        <DropdownMenuItem onClick={handleEdit}>
-          ویرایش
-          <DropdownMenuShortcut>
-            <UserPen size={16} />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
-
-        <DropdownMenuSeparator />
+        {canUpdate && (
+          <DropdownMenuItem onClick={handleEdit}>
+            ویرایش
+            <DropdownMenuShortcut>
+              <UserPen size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
 
-        <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
-          حذف
-          <DropdownMenuShortcut>
-            <Trash2 size={16} color='red' />
-          </DropdownMenuShortcut>
-        </DropdownMenuItem>
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
+              حذف
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
       </DropdownMenuContent>
     </DropdownMenu>
   )

+ 131 - 106
src/features/users/components/users-action-dialog.tsx

@@ -3,14 +3,30 @@ import { z } from 'zod'
 import { useForm, useWatch } from 'react-hook-form'
 import { zodResolver } from '@hookform/resolvers/zod'
 import { format, parse } from 'date-fns-jalali'
-
+import { faIR } from 'react-day-picker/persian'
+import { toast } from 'sonner'
+import { PERMISSIONS } from '@/constants/permissions'
+import {
+  genderOption,
+  footOptions,
+  positionOptions,
+  skillLevelOptions,
+  activityHistoryOptions,
+} from '@/constants/users/users-constants'
+import { useCities } from '@/hooks/location/useCities'
+import { useProvinces } from '@/hooks/location/useProvinces'
+import { useCan } from '@/hooks/use-can'
+import { useCreateUser } from '@/hooks/users/useCreateUser'
+import { useUpdateUser } from '@/hooks/users/useUpdateUser'
+import { useUser } from '@/hooks/users/useUser'
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
 import {
   Dialog,
   DialogContent,
   DialogHeader,
   DialogTitle,
 } from '@/components/ui/dialog'
-
 import {
   Form,
   FormControl,
@@ -19,10 +35,12 @@ import {
   FormLabel,
   FormMessage,
 } from '@/components/ui/form'
-
 import { Input } from '@/components/ui/input'
-import { Textarea } from '@/components/ui/textarea'
-
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
 import {
   Select,
   SelectContent,
@@ -30,37 +48,12 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
-
-import { Button } from '@/components/ui/button'
-
+import { Textarea } from '@/components/ui/textarea'
 import { useUsers } from './users-provider'
 
-import { useCreateUser } from '@/hooks/users/useCreateUser'
-import { useUpdateUser } from '@/hooks/users/useUpdateUser'
-import { useUser } from '@/hooks/users/useUser'
-
-import { useProvinces } from '@/hooks/location/useProvinces'
-import { useCities } from '@/hooks/location/useCities'
-
-import {
-  genderOption,
-  footOptions,
-  positionOptions,
-  skillLevelOptions,
-  activityHistoryOptions,
-} from '@/constants/users/users-constants'
-import {
-  Popover,
-  PopoverContent,
-  PopoverTrigger,
-} from '@/components/ui/popover'
-import { Calendar } from '@/components/ui/calendar'
-import { faIR } from 'react-day-picker/persian'
-import { toast } from 'sonner'
-
 const schema = z.object({
   username: z.string().min(1, 'نام کاربری الزامی است'),
-  phone: z.string().min(1, 'شماره موبایل الزامی است'),
+  phone: z.string(),
   first_name: z.string().min(1, 'نام الزامی است'),
   last_name: z.string().min(1, 'نام خانوادگی الزامی است'),
   email: z.string().optional(),
@@ -93,6 +86,19 @@ export function UsersActionDialog() {
   const opened = isAdd || isEdit || isView
 
   const readOnly = isView
+  const { can } = useCan()
+  const canViewPhone = can(PERMISSIONS.users.fields.phone.view)
+  const canUpdatePhone = can(PERMISSIONS.users.fields.phone.update)
+  const canViewEmail = can(PERMISSIONS.users.fields.email.view)
+  const canUpdateEmail = can(PERMISSIONS.users.fields.email.update)
+  const canViewBirthDate = can(PERMISSIONS.users.fields.birthDate.view)
+  const canUpdateBirthDate = can(PERMISSIONS.users.fields.birthDate.update)
+  const showPhone = isAdd || canViewPhone
+  const showEmail = isAdd ? canUpdateEmail : canViewEmail
+  const showBirthDate = isAdd ? canUpdateBirthDate : canViewBirthDate
+  const phoneDisabled = readOnly || (isEdit && !canUpdatePhone)
+  const emailDisabled = readOnly || (isEdit && !canUpdateEmail)
+  const birthDateDisabled = readOnly || (isEdit && !canUpdateBirthDate)
 
   const form = useForm<FormValues>({
     resolver: zodResolver(schema),
@@ -207,6 +213,11 @@ export function UsersActionDialog() {
   const onSubmit = (values: FormValues) => {
     if (isView) return
 
+    if ((isAdd || canUpdatePhone) && !values.phone.trim()) {
+      form.setError('phone', { message: 'شماره موبایل الزامی است' })
+      return
+    }
+
     const payload = {
       ...values,
       activity_history: values.activity_history ? 1 : 0,
@@ -228,6 +239,18 @@ export function UsersActionDialog() {
         : undefined,
     }
 
+    if (isAdd) {
+      if (!canUpdateEmail) payload.email = undefined
+      if (!canUpdateBirthDate) payload.birth_date = undefined
+    }
+
+    if (isEdit) {
+      if (!canUpdatePhone) payload.phone = user?.phone || values.phone
+      if (!canUpdateEmail) payload.email = user?.email || undefined
+      if (!canUpdateBirthDate)
+        payload.birth_date = user?.birth_date || undefined
+    }
+
     if (isAdd) {
       createMutation.mutate(payload, {
         onSuccess: () => {
@@ -321,87 +344,89 @@ export function UsersActionDialog() {
                   )}
                 />
 
-                {/* phone */}
-                <FormField
-                  control={form.control}
-                  name='phone'
-                  render={({ field }) => (
-                    <FormItem>
-                      <FormLabel>موبایل</FormLabel>
-
-                      <FormControl>
-                        <Input {...field} disabled={readOnly} />
-                      </FormControl>
+                {showPhone && (
+                  <FormField
+                    control={form.control}
+                    name='phone'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>موبایل</FormLabel>
 
-                      <FormMessage />
-                    </FormItem>
-                  )}
-                />
+                        <FormControl>
+                          <Input {...field} disabled={phoneDisabled} />
+                        </FormControl>
 
-                {/* email */}
-                <FormField
-                  control={form.control}
-                  name='email'
-                  render={({ field }) => (
-                    <FormItem>
-                      <FormLabel>ایمیل</FormLabel>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                )}
 
-                      <FormControl>
-                        <Input
-                          {...field}
-                          disabled={readOnly}
-                          value={field.value ?? ''}
-                        />
-                      </FormControl>
+                {showEmail && (
+                  <FormField
+                    control={form.control}
+                    name='email'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>ایمیل</FormLabel>
 
-                      <FormMessage />
-                    </FormItem>
-                  )}
-                />
+                        <FormControl>
+                          <Input
+                            {...field}
+                            disabled={emailDisabled}
+                            value={field.value ?? ''}
+                          />
+                        </FormControl>
 
-                {/* birth date */}
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                )}
 
-                <FormField
-                  control={form.control}
-                  name='birth_date'
-                  render={({ field }) => (
-                    <FormItem>
-                      <FormLabel>تاریخ تولد</FormLabel>
-                      <FormControl>
-                        <Popover>
-                          <PopoverTrigger asChild>
-                            <Button
-                              variant='outline'
-                              disabled={readOnly}
-                              className={`${field.value ? '' : 'text-muted-foreground'} w-full justify-start font-normal`}
-                              size={'lg'}
+                {showBirthDate && (
+                  <FormField
+                    control={form.control}
+                    name='birth_date'
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>تاریخ تولد</FormLabel>
+                        <FormControl>
+                          <Popover>
+                            <PopoverTrigger asChild>
+                              <Button
+                                variant='outline'
+                                disabled={birthDateDisabled}
+                                className={`${field.value ? '' : 'text-muted-foreground'} w-full justify-start font-normal`}
+                                size={'lg'}
+                              >
+                                {field.value
+                                  ? format(field.value, 'yyyy/MM/dd')
+                                  : '****/**/**'}
+                              </Button>
+                            </PopoverTrigger>
+                            <PopoverContent
+                              className='w-auto overflow-hidden p-0'
+                              align='start'
                             >
-                              {field.value
-                                ? format(field.value, 'yyyy/MM/dd')
-                                : '****/**/**'}
-                            </Button>
-                          </PopoverTrigger>
-                          <PopoverContent
-                            className='w-auto overflow-hidden p-0'
-                            align='start'
-                          >
-                            <Calendar
-                              mode='single'
-                              dir='rtl'
-                              locale={faIR}
-                              selected={field.value as Date | undefined}
-                              defaultMonth={field.value as Date | undefined}
-                              captionLayout='dropdown'
-                              onSelect={(date) => field.onChange(date)}
-                              disabled={readOnly}
-                            />
-                          </PopoverContent>
-                        </Popover>
-                      </FormControl>
-                      <FormMessage />
-                    </FormItem>
-                  )}
-                />
+                              <Calendar
+                                mode='single'
+                                dir='rtl'
+                                locale={faIR}
+                                selected={field.value as Date | undefined}
+                                defaultMonth={field.value as Date | undefined}
+                                captionLayout='dropdown'
+                                onSelect={(date) => field.onChange(date)}
+                                disabled={birthDateDisabled}
+                              />
+                            </PopoverContent>
+                          </Popover>
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                )}
 
                 {/* gender */}
                 <FormField

+ 31 - 7
src/features/users/components/users-columns.tsx

@@ -1,19 +1,17 @@
+import { useMemo } from 'react'
 import { type ColumnDef } from '@tanstack/react-table'
-
+import { type User } from '@/types/users.types'
 import { cn } from '@/lib/utils'
-
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { Badge } from '@/components/ui/badge'
 import { Checkbox } from '@/components/ui/checkbox'
-
 import { DataTableColumnHeader } from '@/components/data-table'
 import { LongText } from '@/components/long-text'
-
-import { type User } from '@/types/users.types'
-
 import { DataTableRowActions } from './data-table-row-actions'
 import { UserSubscriptionCell } from './user-subscription-cell'
 
-export const usersColumns: ColumnDef<User>[] = [
+const usersColumns: ColumnDef<User>[] = [
   {
     id: 'select',
     header: ({ table }) => (
@@ -154,3 +152,29 @@ export const usersColumns: ColumnDef<User>[] = [
     enableHiding: false,
   },
 ]
+
+function columnKey(column: ColumnDef<User>) {
+  if ('accessorKey' in column && typeof column.accessorKey === 'string') {
+    return column.accessorKey
+  }
+
+  return column.id
+}
+
+export function useUsersColumns() {
+  const { can } = useCan()
+
+  return useMemo(() => {
+    return usersColumns.filter((column) => {
+      const key = columnKey(column)
+
+      if (key === 'phone') return can(PERMISSIONS.users.fields.phone.view)
+
+      if (key === 'subscription' || key === 'subscription_type') {
+        return can(PERMISSIONS.users.fields.subscription.view)
+      }
+
+      return true
+    })
+  }, [can])
+}

+ 6 - 0
src/features/users/components/users-primary-buttons.tsx

@@ -1,9 +1,15 @@
 import { UserPlus } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { Button } from '@/components/ui/button'
 import { useUsers } from './users-provider'
 
 export function UsersPrimaryButtons() {
   const { setOpen } = useUsers()
+  const { can } = useCan()
+
+  if (!can(PERMISSIONS.users.create)) return null
+
   return (
     <div className='flex gap-2'>
       <Button className='space-x-1' onClick={() => setOpen('add')}>

+ 25 - 16
src/features/users/components/users-table.tsx

@@ -8,7 +8,11 @@ import {
   getFacetedUniqueValues,
   useReactTable,
 } from '@tanstack/react-table'
+import { type Pagination } from '@/types/common.types'
+import { type User } from '@/types/users.types'
 import { cn } from '@/lib/utils'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
 import {
   Table,
@@ -20,9 +24,7 @@ import {
 } from '@/components/ui/table'
 import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
 import { DataTableBulkActions } from './data-table-bulk-actions'
-import { usersColumns as columns } from './users-columns'
-import { type User } from '@/types/users.types'
-import { type Pagination } from '@/types/common.types'
+import { useUsersColumns } from './users-columns'
 
 type DataTableProps = {
   data: User[]
@@ -37,6 +39,9 @@ export function UsersTable({
   search,
   navigate,
 }: DataTableProps) {
+  const columns = useUsersColumns()
+  const { can } = useCan()
+  const canViewSubscription = can(PERMISSIONS.users.fields.subscription.view)
   // Local UI-only states
   const [rowSelection, setRowSelection] = useState({})
   const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
@@ -114,19 +119,23 @@ export function UsersTable({
         table={table}
         searchPlaceholder='فیلتر کاربران...'
         searchKey='username'
-        filters={[
-          {
-            columnId: 'subscription_type',
-            title: 'نوع اشتراک',
-            multiple: false,
-            options: [
-              { label: 'رایگان', value: 'no' },
-              { label: 'بیسیک', value: 'basic' },
-              { label: 'پرو', value: 'pro' },
-              { label: 'VIP', value: 'vip' },
-            ],
-          },
-        ]}
+        filters={
+          canViewSubscription
+            ? [
+                {
+                  columnId: 'subscription_type',
+                  title: 'نوع اشتراک',
+                  multiple: false,
+                  options: [
+                    { label: 'رایگان', value: 'no' },
+                    { label: 'بیسیک', value: 'basic' },
+                    { label: 'پرو', value: 'pro' },
+                    { label: 'VIP', value: 'vip' },
+                  ],
+                },
+              ]
+            : []
+        }
       />
       <div className='overflow-hidden rounded-md border'>
         <Table>

+ 8 - 2
src/features/users/index.tsx

@@ -1,26 +1,32 @@
 import { getRouteApi } from '@tanstack/react-router'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
 import { ConfigDrawer } from '@/components/config-drawer'
 import { Header } from '@/components/layout/header'
 import { Main } from '@/components/layout/main'
 import { ProfileDropdown } from '@/components/profile-dropdown'
 import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
+import { useUsers } from '../../hooks/users/useUsers'
 import { UsersDialogs } from './components/users-dialogs'
 import { UsersPrimaryButtons } from './components/users-primary-buttons'
 import { UsersProvider } from './components/users-provider'
 import { UsersTable } from './components/users-table'
-import { useUsers } from '../../hooks/users/useUsers'
 
 const route = getRouteApi('/_authenticated/users/')
 
 export function Users() {
   const search = route.useSearch()
   const navigate = route.useNavigate()
+  const { can } = useCan()
+  const canViewSubscription = can(PERMISSIONS.users.fields.subscription.view)
 
   const { data, isLoading } = useUsers({
     page: search.page ?? 1,
     per_page: search.pageSize ?? 10,
-    subscription_type: search.subscription_type,
+    subscription_type: canViewSubscription
+      ? search.subscription_type
+      : undefined,
   })
   const users = data?.data.users
   const paginationInfo = data?.data?.pagination

+ 11 - 0
src/hooks/admins/useAdmin.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useAdmin(adminId?: number) {
+  return useQuery({
+    queryKey: queryKeys.admins.detail(adminId ?? 'new'),
+    queryFn: () => adminsService.detail(adminId as number),
+    enabled: typeof adminId === 'number',
+  })
+}

+ 14 - 0
src/hooks/admins/useAdmins.ts

@@ -0,0 +1,14 @@
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+import type { PaginationParams } from '@/types/common.types'
+
+export function useAdmins(
+  params: PaginationParams = { page: 1, per_page: 10 }
+) {
+  return useQuery({
+    queryKey: queryKeys.admins.list(params),
+    queryFn: () => adminsService.list(params),
+    placeholderData: keepPreviousData,
+  })
+}

+ 16 - 0
src/hooks/admins/useCreateAdmin.ts

@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useCreateAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: adminsService.create,
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+    },
+  })
+}

+ 16 - 0
src/hooks/admins/useDeleteAdmin.ts

@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useDeleteAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: (adminId: number) => adminsService.delete(adminId),
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+    },
+  })
+}

+ 29 - 0
src/hooks/admins/useUpdateAdmin.ts

@@ -0,0 +1,29 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+import type { AdminPayload } from '@/types/admins.types'
+import { useAuthStore } from '@/stores/auth-store'
+import { syncCurrentPermissions } from '@/lib/resolve-permissions'
+
+type UpdateAdminVariables = {
+  id: number
+  payload: AdminPayload
+}
+
+export function useUpdateAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: UpdateAdminVariables) =>
+      adminsService.update(id, payload),
+    onSuccess: async (_data, variables) => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+
+      if (useAuthStore.getState().user?.id === variables.id) {
+        await syncCurrentPermissions()
+      }
+    },
+  })
+}

+ 4 - 0
src/hooks/auth/useAuth.tsx

@@ -5,6 +5,8 @@ import { useAuthStore } from '@/stores/auth-store'
 export function useLogin() {
   const setToken = useAuthStore((s) => s.setToken)
   const setUser = useAuthStore((s) => s.setUser)
+  const setPermissions = useAuthStore((s) => s.setPermissions)
+  const setProfileFetched = useAuthStore((s) => s.setProfileFetched)
 
   return useMutation({
     mutationFn: authService.login,
@@ -12,6 +14,8 @@ export function useLogin() {
     onSuccess: (data) => {
       setToken(data.token)
       setUser(data.admin)
+      setPermissions([])
+      setProfileFetched(false)
     },
   })
 }

+ 11 - 0
src/hooks/permissions/usePermissionModules.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { permissionsService } from '@/services/permissions.service'
+
+export function usePermissionModules(enabled = true) {
+  return useQuery({
+    queryKey: queryKeys.permissions.all,
+    queryFn: permissionsService.list,
+    enabled,
+  })
+}

+ 16 - 0
src/hooks/roles/useCreateRole.ts

@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { rolesService } from '@/services/roles.service'
+
+export function useCreateRole() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: rolesService.create,
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.roles.all,
+      })
+    },
+  })
+}

+ 18 - 0
src/hooks/roles/useDeleteRole.ts

@@ -0,0 +1,18 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { rolesService } from '@/services/roles.service'
+import { syncCurrentPermissions } from '@/lib/resolve-permissions'
+
+export function useDeleteRole() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: (roleId: number) => rolesService.delete(roleId),
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.roles.all,
+      })
+      await syncCurrentPermissions()
+    },
+  })
+}

+ 11 - 0
src/hooks/roles/useRole.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { rolesService } from '@/services/roles.service'
+
+export function useRole(roleId?: number) {
+  return useQuery({
+    queryKey: queryKeys.roles.detail(roleId ?? 'new'),
+    queryFn: () => rolesService.detail(roleId as number),
+    enabled: typeof roleId === 'number',
+  })
+}

+ 16 - 0
src/hooks/roles/useRoles.ts

@@ -0,0 +1,16 @@
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { rolesService } from '@/services/roles.service'
+import type { PaginationParams } from '@/types/common.types'
+
+export function useRoles(
+  params: PaginationParams = { page: 1, per_page: 10 },
+  enabled = true
+) {
+  return useQuery({
+    queryKey: queryKeys.roles.list(params),
+    queryFn: () => rolesService.list(params),
+    placeholderData: keepPreviousData,
+    enabled,
+  })
+}

+ 25 - 0
src/hooks/roles/useUpdateRole.ts

@@ -0,0 +1,25 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { rolesService } from '@/services/roles.service'
+import type { RolePayload } from '@/types/roles.types'
+import { syncCurrentPermissions } from '@/lib/resolve-permissions'
+
+type UpdateRoleVariables = {
+  id: number
+  payload: RolePayload
+}
+
+export function useUpdateRole() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: UpdateRoleVariables) =>
+      rolesService.update(id, payload),
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.roles.all,
+      })
+      await syncCurrentPermissions()
+    },
+  })
+}

+ 19 - 0
src/hooks/use-can.ts

@@ -0,0 +1,19 @@
+import { useCallback } from 'react'
+import { useAuthStore } from '@/stores/auth-store'
+import { hasAnyPermission, hasPermission } from '@/lib/permissions'
+
+export function useCan() {
+  const permissions = useAuthStore((state) => state.permissions)
+
+  const can = useCallback(
+    (permission: string) => hasPermission(permissions, permission),
+    [permissions]
+  )
+
+  const canAny = useCallback(
+    (names: string[]) => hasAnyPermission(permissions, names),
+    [permissions]
+  )
+
+  return { can, canAny, permissions }
+}

+ 42 - 0
src/hooks/use-visible-nav.ts

@@ -0,0 +1,42 @@
+import { useMemo } from 'react'
+import { useAuthStore } from '@/stores/auth-store'
+import { hasPermission } from '@/lib/permissions'
+import { sidebarData } from '@/components/layout/data/sidebar-data'
+import { type NavGroup, type NavItem } from '@/components/layout/types'
+
+function canSee(permission: string | undefined, permissions: string[]) {
+  if (!permission) return true
+  return hasPermission(permissions, permission)
+}
+
+function filterNavGroups(groups: NavGroup[], permissions: string[]) {
+  return groups
+    .map((group) => ({
+      ...group,
+      items: group.items
+        .map((item) => {
+          if (!canSee(item.permission, permissions)) return null
+
+          if (!item.items) return item
+
+          const items = item.items.filter((subItem) =>
+            canSee(subItem.permission, permissions)
+          )
+
+          if (items.length === 0) return null
+
+          return { ...item, items }
+        })
+        .filter((item): item is NavItem => item !== null),
+    }))
+    .filter((group) => group.items.length > 0)
+}
+
+export function useVisibleNavGroups() {
+  const permissions = useAuthStore((state) => state.permissions)
+
+  return useMemo(
+    () => filterNavGroups(sidebarData.navGroups, permissions),
+    [permissions]
+  )
+}

+ 10 - 0
src/lib/api-error.ts

@@ -0,0 +1,10 @@
+import { AxiosError } from 'axios'
+
+export function getApiErrorMessage(error: unknown, fallback: string) {
+  if (error instanceof AxiosError) {
+    const message = error.response?.data?.message
+    if (typeof message === 'string' && message.trim()) return message
+  }
+
+  return fallback
+}

+ 99 - 0
src/lib/permissions.ts

@@ -0,0 +1,99 @@
+import type { PermissionModule } from '@/types/permissions.types'
+
+export const ALL_ACCESS = '*'
+
+export interface FlatPermission {
+  name: string
+  requires: string[]
+}
+
+export function hasPermission(permissions: string[], permission: string) {
+  return permissions.includes(ALL_ACCESS) || permissions.includes(permission)
+}
+
+export function hasAnyPermission(permissions: string[], names: string[]) {
+  return names.some((name) => hasPermission(permissions, name))
+}
+
+export function flattenModulePermissions(
+  modules: PermissionModule[]
+): FlatPermission[] {
+  return modules.flatMap((module) => [
+    ...module.actions.map((action) => ({
+      name: action.name,
+      requires: [] as string[],
+    })),
+    ...module.fields.flatMap((field) =>
+      field.permissions.map((permission) => ({
+        name: permission.name,
+        requires: permission.requires,
+      }))
+    ),
+  ])
+}
+
+export function modulePermissionNames(module: PermissionModule) {
+  return [
+    ...module.actions.map((action) => action.name),
+    ...module.fields.flatMap((field) =>
+      field.permissions.map((permission) => permission.name)
+    ),
+  ]
+}
+
+export function togglePermissionSelection(
+  selected: string[],
+  name: string,
+  checked: boolean,
+  catalog: FlatPermission[]
+) {
+  const next = new Set(selected)
+  const byName = new Map(catalog.map((item) => [item.name, item]))
+
+  if (checked) {
+    const stack = [name]
+
+    while (stack.length > 0) {
+      const current = stack.pop()
+      if (!current || next.has(current)) continue
+
+      next.add(current)
+
+      for (const required of byName.get(current)?.requires ?? []) {
+        stack.push(required)
+      }
+    }
+  } else {
+    next.delete(name)
+
+    let changed = true
+    while (changed) {
+      changed = false
+
+      for (const item of catalog) {
+        if (!next.has(item.name)) continue
+        if (item.requires.some((required) => !next.has(required))) {
+          next.delete(item.name)
+          changed = true
+        }
+      }
+    }
+  }
+
+  return catalog
+    .map((item) => item.name)
+    .filter((permissionName) => next.has(permissionName))
+}
+
+export function togglePermissionNames(
+  selected: string[],
+  names: string[],
+  checked: boolean,
+  catalog: FlatPermission[]
+) {
+  return names.reduce(
+    (current, name) =>
+      togglePermissionSelection(current, name, checked, catalog),
+    selected
+  )
+}

+ 14 - 0
src/lib/require-permission.ts

@@ -0,0 +1,14 @@
+import { redirect } from '@tanstack/react-router'
+import { useAuthStore } from '@/stores/auth-store'
+import { hasPermission } from '@/lib/permissions'
+
+export function requirePermission(permission: string) {
+  const { permissions } = useAuthStore.getState()
+
+  if (hasPermission(permissions, permission)) return
+
+  throw redirect({
+    to: '/errors/$error',
+    params: { error: 'forbidden' },
+  })
+}

+ 54 - 0
src/lib/resolve-permissions.ts

@@ -0,0 +1,54 @@
+import { authService } from '@/services/auth.service'
+import { rolesService } from '@/services/roles.service'
+import type { AdminUser, PermissionRef } from '@/types/auth.types'
+import { useAuthStore } from '@/stores/auth-store'
+import { ALL_ACCESS } from '@/lib/permissions'
+
+const SUPER_ADMIN_ROLE = 'SuperAdmin'
+
+function permissionName(permission: PermissionRef) {
+  return typeof permission === 'string' ? permission : permission.name
+}
+
+export function roleNamesOf(user: AdminUser | null | undefined) {
+  if (!user) return []
+
+  return user.roles.map((role) => (typeof role === 'string' ? role : role.name))
+}
+
+export function extractEmbeddedPermissions(user: AdminUser) {
+  const direct = (user.permissions ?? []).map(permissionName)
+  if (direct.length > 0) return direct
+
+  return user.roles.flatMap((role) => {
+    if (typeof role === 'string') return []
+    return (role.permissions ?? []).map(permissionName)
+  })
+}
+
+export async function resolveAdminPermissions(user: AdminUser) {
+  const embedded = extractEmbeddedPermissions(user)
+  if (embedded.length > 0) return embedded
+
+  const roleNames = roleNamesOf(user)
+
+  try {
+    return await rolesService.permissionsForRoleNames(roleNames)
+  } catch {
+    if (roleNames.includes(SUPER_ADMIN_ROLE)) return [ALL_ACCESS]
+    return []
+  }
+}
+
+export async function syncCurrentPermissions() {
+  const { user, setUser, setPermissions } = useAuthStore.getState()
+  if (!user) return
+
+  try {
+    const profile = await authService.profile()
+    setUser(profile.admin)
+    setPermissions(await resolveAdminPermissions(profile.admin))
+  } catch {
+    setPermissions(await resolveAdminPermissions(user))
+  }
+}

+ 65 - 0
src/routeTree.gen.ts

@@ -21,11 +21,14 @@ import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authen
 import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
 import { Route as AuthenticatedTasksIndexRouteImport } from './routes/_authenticated/tasks/index'
 import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
+import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
 import { Route as AuthenticatedReferralCodesIndexRouteImport } from './routes/_authenticated/referral-codes/index'
 import { Route as AuthenticatedPostsIndexRouteImport } from './routes/_authenticated/posts/index'
+import { Route as AuthenticatedPermissionsIndexRouteImport } from './routes/_authenticated/permissions/index'
 import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
 import { Route as AuthenticatedEvaluationsIndexRouteImport } from './routes/_authenticated/evaluations/index'
 import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
+import { Route as AuthenticatedAdminsIndexRouteImport } from './routes/_authenticated/admins/index'
 import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
 import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
 import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
@@ -93,6 +96,11 @@ const AuthenticatedSettingsIndexRoute =
     path: '/',
     getParentRoute: () => AuthenticatedSettingsRouteRoute,
   } as any)
+const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({
+  id: '/roles/',
+  path: '/roles/',
+  getParentRoute: () => AuthenticatedRouteRoute,
+} as any)
 const AuthenticatedReferralCodesIndexRoute =
   AuthenticatedReferralCodesIndexRouteImport.update({
     id: '/referral-codes/',
@@ -104,6 +112,12 @@ const AuthenticatedPostsIndexRoute = AuthenticatedPostsIndexRouteImport.update({
   path: '/posts/',
   getParentRoute: () => AuthenticatedRouteRoute,
 } as any)
+const AuthenticatedPermissionsIndexRoute =
+  AuthenticatedPermissionsIndexRouteImport.update({
+    id: '/permissions/',
+    path: '/permissions/',
+    getParentRoute: () => AuthenticatedRouteRoute,
+  } as any)
 const AuthenticatedHelpCenterIndexRoute =
   AuthenticatedHelpCenterIndexRouteImport.update({
     id: '/help-center/',
@@ -121,6 +135,12 @@ const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
   path: '/chats/',
   getParentRoute: () => AuthenticatedRouteRoute,
 } as any)
+const AuthenticatedAdminsIndexRoute =
+  AuthenticatedAdminsIndexRouteImport.update({
+    id: '/admins/',
+    path: '/admins/',
+    getParentRoute: () => AuthenticatedRouteRoute,
+  } as any)
 const AuthenticatedSettingsNotificationsRoute =
   AuthenticatedSettingsNotificationsRouteImport.update({
     id: '/notifications',
@@ -166,11 +186,14 @@ export interface FileRoutesByFullPath {
   '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/admins/': typeof AuthenticatedAdminsIndexRoute
   '/chats/': typeof AuthenticatedChatsIndexRoute
   '/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center/': typeof AuthenticatedHelpCenterIndexRoute
+  '/permissions/': typeof AuthenticatedPermissionsIndexRoute
   '/posts/': typeof AuthenticatedPostsIndexRoute
   '/referral-codes/': typeof AuthenticatedReferralCodesIndexRoute
+  '/roles/': typeof AuthenticatedRolesIndexRoute
   '/settings/': typeof AuthenticatedSettingsIndexRoute
   '/tasks/': typeof AuthenticatedTasksIndexRoute
   '/users/': typeof AuthenticatedUsersIndexRoute
@@ -188,11 +211,14 @@ export interface FileRoutesByTo {
   '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/admins': typeof AuthenticatedAdminsIndexRoute
   '/chats': typeof AuthenticatedChatsIndexRoute
   '/evaluations': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center': typeof AuthenticatedHelpCenterIndexRoute
+  '/permissions': typeof AuthenticatedPermissionsIndexRoute
   '/posts': typeof AuthenticatedPostsIndexRoute
   '/referral-codes': typeof AuthenticatedReferralCodesIndexRoute
+  '/roles': typeof AuthenticatedRolesIndexRoute
   '/settings': typeof AuthenticatedSettingsIndexRoute
   '/tasks': typeof AuthenticatedTasksIndexRoute
   '/users': typeof AuthenticatedUsersIndexRoute
@@ -213,11 +239,14 @@ export interface FileRoutesById {
   '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/_authenticated/admins/': typeof AuthenticatedAdminsIndexRoute
   '/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
   '/_authenticated/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
+  '/_authenticated/permissions/': typeof AuthenticatedPermissionsIndexRoute
   '/_authenticated/posts/': typeof AuthenticatedPostsIndexRoute
   '/_authenticated/referral-codes/': typeof AuthenticatedReferralCodesIndexRoute
+  '/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
   '/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
   '/_authenticated/tasks/': typeof AuthenticatedTasksIndexRoute
   '/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
@@ -238,11 +267,14 @@ export interface FileRouteTypes {
     | '/settings/appearance'
     | '/settings/display'
     | '/settings/notifications'
+    | '/admins/'
     | '/chats/'
     | '/evaluations/'
     | '/help-center/'
+    | '/permissions/'
     | '/posts/'
     | '/referral-codes/'
+    | '/roles/'
     | '/settings/'
     | '/tasks/'
     | '/users/'
@@ -260,11 +292,14 @@ export interface FileRouteTypes {
     | '/settings/appearance'
     | '/settings/display'
     | '/settings/notifications'
+    | '/admins'
     | '/chats'
     | '/evaluations'
     | '/help-center'
+    | '/permissions'
     | '/posts'
     | '/referral-codes'
+    | '/roles'
     | '/settings'
     | '/tasks'
     | '/users'
@@ -284,11 +319,14 @@ export interface FileRouteTypes {
     | '/_authenticated/settings/appearance'
     | '/_authenticated/settings/display'
     | '/_authenticated/settings/notifications'
+    | '/_authenticated/admins/'
     | '/_authenticated/chats/'
     | '/_authenticated/evaluations/'
     | '/_authenticated/help-center/'
+    | '/_authenticated/permissions/'
     | '/_authenticated/posts/'
     | '/_authenticated/referral-codes/'
+    | '/_authenticated/roles/'
     | '/_authenticated/settings/'
     | '/_authenticated/tasks/'
     | '/_authenticated/users/'
@@ -390,6 +428,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedSettingsIndexRouteImport
       parentRoute: typeof AuthenticatedSettingsRouteRoute
     }
+    '/_authenticated/roles/': {
+      id: '/_authenticated/roles/'
+      path: '/roles'
+      fullPath: '/roles/'
+      preLoaderRoute: typeof AuthenticatedRolesIndexRouteImport
+      parentRoute: typeof AuthenticatedRouteRoute
+    }
     '/_authenticated/referral-codes/': {
       id: '/_authenticated/referral-codes/'
       path: '/referral-codes'
@@ -404,6 +449,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedPostsIndexRouteImport
       parentRoute: typeof AuthenticatedRouteRoute
     }
+    '/_authenticated/permissions/': {
+      id: '/_authenticated/permissions/'
+      path: '/permissions'
+      fullPath: '/permissions/'
+      preLoaderRoute: typeof AuthenticatedPermissionsIndexRouteImport
+      parentRoute: typeof AuthenticatedRouteRoute
+    }
     '/_authenticated/help-center/': {
       id: '/_authenticated/help-center/'
       path: '/help-center'
@@ -425,6 +477,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedChatsIndexRouteImport
       parentRoute: typeof AuthenticatedRouteRoute
     }
+    '/_authenticated/admins/': {
+      id: '/_authenticated/admins/'
+      path: '/admins'
+      fullPath: '/admins/'
+      preLoaderRoute: typeof AuthenticatedAdminsIndexRouteImport
+      parentRoute: typeof AuthenticatedRouteRoute
+    }
     '/_authenticated/settings/notifications': {
       id: '/_authenticated/settings/notifications'
       path: '/notifications'
@@ -490,11 +549,14 @@ interface AuthenticatedRouteRouteChildren {
   AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
   AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
   AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
+  AuthenticatedAdminsIndexRoute: typeof AuthenticatedAdminsIndexRoute
   AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
   AuthenticatedEvaluationsIndexRoute: typeof AuthenticatedEvaluationsIndexRoute
   AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
+  AuthenticatedPermissionsIndexRoute: typeof AuthenticatedPermissionsIndexRoute
   AuthenticatedPostsIndexRoute: typeof AuthenticatedPostsIndexRoute
   AuthenticatedReferralCodesIndexRoute: typeof AuthenticatedReferralCodesIndexRoute
+  AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
   AuthenticatedTasksIndexRoute: typeof AuthenticatedTasksIndexRoute
   AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
 }
@@ -503,11 +565,14 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
   AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
   AuthenticatedIndexRoute: AuthenticatedIndexRoute,
   AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
+  AuthenticatedAdminsIndexRoute: AuthenticatedAdminsIndexRoute,
   AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
   AuthenticatedEvaluationsIndexRoute: AuthenticatedEvaluationsIndexRoute,
   AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,
+  AuthenticatedPermissionsIndexRoute: AuthenticatedPermissionsIndexRoute,
   AuthenticatedPostsIndexRoute: AuthenticatedPostsIndexRoute,
   AuthenticatedReferralCodesIndexRoute: AuthenticatedReferralCodesIndexRoute,
+  AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
   AuthenticatedTasksIndexRoute: AuthenticatedTasksIndexRoute,
   AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
 }

+ 18 - 0
src/routes/_authenticated/admins/index.tsx

@@ -0,0 +1,18 @@
+import z from 'zod'
+import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
+import { Admins } from '@/features/admins'
+
+const adminsSearchSchema = z.object({
+  page: z.coerce.number().optional().catch(1),
+  pageSize: z.coerce.number().optional().catch(10),
+})
+
+export const Route = createFileRoute('/_authenticated/admins/')({
+  validateSearch: adminsSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.admins.view)
+  },
+  component: Admins,
+})

+ 5 - 0
src/routes/_authenticated/evaluations/index.tsx

@@ -1,5 +1,7 @@
 import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
 import { Evaluations } from '@/features/evaluations'
 
 const evaluationsSearchSchema = z.object({
@@ -18,5 +20,8 @@ const evaluationsSearchSchema = z.object({
 
 export const Route = createFileRoute('/_authenticated/evaluations/')({
   validateSearch: evaluationsSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.evaluations.view)
+  },
   component: Evaluations,
 })

+ 11 - 0
src/routes/_authenticated/permissions/index.tsx

@@ -0,0 +1,11 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
+import { Permissions } from '@/features/permissions'
+
+export const Route = createFileRoute('/_authenticated/permissions/')({
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.permissions.view)
+  },
+  component: Permissions,
+})

+ 5 - 0
src/routes/_authenticated/posts/index.tsx

@@ -1,5 +1,7 @@
 import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
 import { Posts } from '@/features/posts'
 
 const postsSearchSchema = z.object({
@@ -9,5 +11,8 @@ const postsSearchSchema = z.object({
 
 export const Route = createFileRoute('/_authenticated/posts/')({
   validateSearch: postsSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.posts.view)
+  },
   component: Posts,
 })

+ 5 - 0
src/routes/_authenticated/referral-codes/index.tsx

@@ -1,5 +1,7 @@
 import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
 import { ReferralCodes } from '@/features/referral-codes'
 
 const referralCodesSearchSchema = z.object({
@@ -9,5 +11,8 @@ const referralCodesSearchSchema = z.object({
 
 export const Route = createFileRoute('/_authenticated/referral-codes/')({
   validateSearch: referralCodesSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.users.fields.referralCode.view)
+  },
   component: ReferralCodes,
 })

+ 18 - 0
src/routes/_authenticated/roles/index.tsx

@@ -0,0 +1,18 @@
+import z from 'zod'
+import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
+import { Roles } from '@/features/roles'
+
+const rolesSearchSchema = z.object({
+  page: z.coerce.number().optional().catch(1),
+  pageSize: z.coerce.number().optional().catch(10),
+})
+
+export const Route = createFileRoute('/_authenticated/roles/')({
+  validateSearch: rolesSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.roles.view)
+  },
+  component: Roles,
+})

+ 6 - 2
src/routes/_authenticated/route.tsx

@@ -1,7 +1,8 @@
 import { createFileRoute, redirect } from '@tanstack/react-router'
-import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'
-import { useAuthStore } from '@/stores/auth-store'
 import { authService } from '@/services/auth.service'
+import { useAuthStore } from '@/stores/auth-store'
+import { resolveAdminPermissions } from '@/lib/resolve-permissions'
+import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'
 
 export const Route = createFileRoute('/_authenticated')({
   beforeLoad: async ({ location }) => {
@@ -26,6 +27,7 @@ export const Route = createFileRoute('/_authenticated')({
       profileFetched,
       logout,
       setUser,
+      setPermissions,
       setProfileFetched,
     } = authState
 
@@ -43,8 +45,10 @@ export const Route = createFileRoute('/_authenticated')({
     if (!profileFetched) {
       try {
         const profile = await authService.profile()
+        const permissions = await resolveAdminPermissions(profile.admin)
 
         setUser(profile.admin)
+        setPermissions(permissions)
         setProfileFetched(true)
       } catch {
         logout()

+ 5 - 0
src/routes/_authenticated/users/index.tsx

@@ -1,5 +1,7 @@
 import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
 import { Users } from '@/features/users'
 
 const usersSearchSchema = z.object({
@@ -32,5 +34,8 @@ const usersSearchSchema = z.object({
 
 export const Route = createFileRoute('/_authenticated/users/')({
   validateSearch: usersSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.users.view)
+  },
   component: Users,
 })

+ 45 - 0
src/services/admins.service.ts

@@ -0,0 +1,45 @@
+import http from '@/core/api/http'
+import type {
+  AdminDetailResponse,
+  AdminPayload,
+  AdminsListResponse,
+} from '@/types/admins.types'
+import type { ApiResponse, PaginationParams } from '@/types/common.types'
+
+const BASE_URL = '/admin/admins'
+
+export const adminsService = {
+  list: async (params?: PaginationParams) => {
+    const searchParams = new URLSearchParams()
+
+    if (params?.page) searchParams.set('page', String(params.page))
+    if (params?.per_page) searchParams.set('per_page', String(params.per_page))
+
+    const query = searchParams.toString()
+    const url = query ? `${BASE_URL}?${query}` : BASE_URL
+
+    return http.get<AdminsListResponse>(url)
+  },
+
+  detail: async (adminId: number | string) => {
+    const response = await http.get<AdminDetailResponse>(
+      `${BASE_URL}/${adminId}`
+    )
+    return response.data.admin
+  },
+
+  create: async (payload: AdminPayload) => {
+    return http.post<ApiResponse<unknown>, AdminPayload>(BASE_URL, payload)
+  },
+
+  update: async (adminId: number | string, payload: AdminPayload) => {
+    return http.put<ApiResponse<unknown>, AdminPayload>(
+      `${BASE_URL}/${adminId}`,
+      payload
+    )
+  },
+
+  delete: async (adminId: number | string) => {
+    return http.delete(`${BASE_URL}/${adminId}`)
+  },
+}

+ 9 - 0
src/services/permissions.service.ts

@@ -0,0 +1,9 @@
+import http from '@/core/api/http'
+import type { PermissionsResponse } from '@/types/permissions.types'
+
+export const permissionsService = {
+  list: async () => {
+    const response = await http.get<PermissionsResponse>('/admin/permissions')
+    return response.data.modules
+  },
+}

+ 73 - 0
src/services/roles.service.ts

@@ -0,0 +1,73 @@
+import http from '@/core/api/http'
+import type { ApiResponse, PaginationParams } from '@/types/common.types'
+import type {
+  RoleDetailResponse,
+  RolePayload,
+  RolesListResponse,
+} from '@/types/roles.types'
+
+const BASE_URL = '/admin/roles'
+
+async function listRoles(params?: PaginationParams) {
+  const searchParams = new URLSearchParams()
+
+  if (params?.page) searchParams.set('page', String(params.page))
+  if (params?.per_page) searchParams.set('per_page', String(params.per_page))
+
+  const query = searchParams.toString()
+  const url = query ? `${BASE_URL}?${query}` : BASE_URL
+
+  return http.get<RolesListResponse>(url)
+}
+
+export const rolesService = {
+  list: listRoles,
+
+  detail: async (roleId: number | string) => {
+    const response = await http.get<RoleDetailResponse>(`${BASE_URL}/${roleId}`)
+    return response.data.role
+  },
+
+  create: async (payload: RolePayload) => {
+    return http.post<ApiResponse<unknown>, RolePayload>(BASE_URL, payload)
+  },
+
+  update: async (roleId: number | string, payload: RolePayload) => {
+    return http.put<ApiResponse<unknown>, RolePayload>(
+      `${BASE_URL}/${roleId}`,
+      payload
+    )
+  },
+
+  delete: async (roleId: number | string) => {
+    return http.delete(`${BASE_URL}/${roleId}`)
+  },
+
+  permissionsForRoleNames: async (roleNames: string[]) => {
+    const wanted = new Set(roleNames)
+    const found = new Set<string>()
+
+    if (wanted.size === 0) return []
+
+    let page = 1
+    let hasMore = true
+
+    while (hasMore && page <= 20) {
+      const response = await listRoles({ page, per_page: 100 })
+      const roles = response.data?.roles ?? []
+
+      for (const role of roles) {
+        if (!wanted.has(role.name)) continue
+
+        for (const permission of role.permissions ?? []) {
+          found.add(permission.name)
+        }
+      }
+
+      hasMore = Boolean(response.data?.pagination?.has_more_pages)
+      page += 1
+    }
+
+    return [...found]
+  },
+}

+ 10 - 0
src/stores/auth-store.ts

@@ -6,11 +6,13 @@ import { persist } from 'zustand/middleware'
 interface AuthState {
   user: AdminUser | null
   token: string | null
+  permissions: string[]
   isAuthenticated: boolean
   isHydrated: boolean
   profileFetched: boolean
   setToken: (token: string | null) => void
   setUser: (user: AdminUser | null) => void
+  setPermissions: (permissions: string[]) => void
   setHydrated: (state: boolean) => void
   setProfileFetched: (state: boolean) => void
   clearAuth: () => void
@@ -22,6 +24,7 @@ export const useAuthStore = create<AuthState>()(
     (set) => ({
       token: null,
       user: null,
+      permissions: [],
 
       isAuthenticated: false,
       isHydrated: false,
@@ -38,6 +41,11 @@ export const useAuthStore = create<AuthState>()(
           user,
         }),
 
+      setPermissions: (permissions) =>
+        set({
+          permissions,
+        }),
+
       setHydrated: (state) =>
         set({
           isHydrated: state,
@@ -52,6 +60,7 @@ export const useAuthStore = create<AuthState>()(
         set({
           user: null,
           token: null,
+          permissions: [],
           isAuthenticated: false,
           profileFetched: false,
         }),
@@ -63,6 +72,7 @@ export const useAuthStore = create<AuthState>()(
           set({
             user: null,
             token: null,
+            permissions: [],
             isAuthenticated: false,
             profileFetched: false,
           })

+ 43 - 0
src/types/admins.types.ts

@@ -0,0 +1,43 @@
+import type { Pagination } from './common.types'
+
+export interface AdminRoleDetail {
+  id: number
+  name: string
+  status: string
+}
+
+export interface Admin {
+  id: number
+  name: string
+  username: string
+  status: string
+  roles: string[]
+  role_details: AdminRoleDetail[]
+  created_at: string
+  updated_at: string
+}
+
+export interface AdminsListResponse {
+  success: boolean
+  message: string
+  data: {
+    admins: Admin[]
+    pagination: Pagination
+  }
+}
+
+export interface AdminDetailResponse {
+  success: boolean
+  message: string
+  data: {
+    admin: Admin
+  }
+}
+
+export interface AdminPayload {
+  name: string
+  username: string
+  password?: string
+  status: 'active' | 'inactive'
+  roles: number[]
+}

+ 9 - 1
src/types/auth.types.ts

@@ -1,10 +1,18 @@
+export type PermissionRef = string | { name: string }
+
+export interface AuthRole {
+  name: string
+  permissions?: PermissionRef[]
+}
+
 export interface AdminUser {
   id: number
   name: string
   username: string
   phone: string | null
   status: 'active' | 'inactive'
-  roles: string[]
+  roles: Array<string | AuthRole>
+  permissions?: PermissionRef[]
 }
 
 export interface LoginPayload {

+ 33 - 0
src/types/permissions.types.ts

@@ -0,0 +1,33 @@
+export interface PermissionAction {
+  action: string
+  name: string
+  label: string
+}
+
+export interface FieldPermission {
+  action: string
+  name: string
+  label: string
+  requires: string[]
+}
+
+export interface PermissionField {
+  key: string
+  label: string
+  permissions: FieldPermission[]
+}
+
+export interface PermissionModule {
+  key: string
+  label: string
+  actions: PermissionAction[]
+  fields: PermissionField[]
+}
+
+export interface PermissionsResponse {
+  success: boolean
+  message: string
+  data: {
+    modules: PermissionModule[]
+  }
+}

+ 58 - 0
src/types/roles.types.ts

@@ -0,0 +1,58 @@
+import type { Pagination } from './common.types'
+
+export interface RolePermission {
+  id: number
+  name: string
+  pivot?: {
+    role_id: number
+    permission_id: number
+  }
+}
+
+export interface RoleActivityField {
+  id: number
+  name: string
+  slug: string
+  tag: string
+  pivot?: {
+    role_id: number
+    activity_field_id: number
+    created_at?: string
+    updated_at?: string
+  }
+}
+
+export interface Role {
+  id: number
+  name: string
+  status: string
+  guard_name: string
+  created_at: string
+  updated_at: string
+  activity_field_ids: number[]
+  permissions: RolePermission[]
+  activity_fields: RoleActivityField[]
+}
+
+export interface RolesListResponse {
+  success: boolean
+  message: string
+  data: {
+    roles: Role[]
+    pagination: Pagination
+  }
+}
+
+export interface RoleDetailResponse {
+  success: boolean
+  message: string
+  data: {
+    role: Role
+  }
+}
+
+export interface RolePayload {
+  name: string
+  permissions: string[]
+  activity_field_ids: number[]
+}