mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: enhance role management API with organization-specific role creation and retrieval, including comprehensive RBAC checks for permissions
This commit is contained in:
parent
3ce019abec
commit
531e1863c0
10 changed files with 2174 additions and 32 deletions
295
apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx
Normal file
295
apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
'use client'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import AddRole from '@components/Objects/Modals/Dash/OrgRoles/AddRole'
|
||||
import EditRole from '@components/Objects/Modals/Dash/OrgRoles/EditRole'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { deleteRole } from '@services/roles/roles'
|
||||
import { swrFetcher } from '@services/utils/ts/requests'
|
||||
import { Pencil, Shield, Users, X, Globe } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
|
||||
function OrgRoles() {
|
||||
const org = useOrg() as any
|
||||
const session = useLHSession() as any
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const [createRoleModal, setCreateRoleModal] = React.useState(false)
|
||||
const [editRoleModal, setEditRoleModal] = React.useState(false)
|
||||
const [selectedRole, setSelectedRole] = React.useState(null) as any
|
||||
|
||||
const { data: roles } = useSWR(
|
||||
org ? `${getAPIUrl()}roles/org/${org.id}` : null,
|
||||
(url) => swrFetcher(url, access_token)
|
||||
)
|
||||
|
||||
const deleteRoleUI = async (role_id: any) => {
|
||||
const toastId = toast.loading("Deleting...");
|
||||
const res = await deleteRole(role_id, org.id, access_token)
|
||||
if (res.status === 200) {
|
||||
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||
toast.success("Deleted role", {id:toastId})
|
||||
}
|
||||
else {
|
||||
toast.error('Error deleting role', {id:toastId})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditRoleModal = (role: any) => {
|
||||
setSelectedRole(role)
|
||||
setEditRoleModal(!editRoleModal)
|
||||
}
|
||||
|
||||
const getRightsSummary = (rights: any) => {
|
||||
if (!rights) return 'No permissions'
|
||||
|
||||
const totalPermissions = Object.keys(rights).reduce((acc, key) => {
|
||||
if (typeof rights[key] === 'object') {
|
||||
return acc + Object.keys(rights[key]).filter(k => rights[key][k] === true).length
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
|
||||
return `${totalPermissions} permissions`
|
||||
}
|
||||
|
||||
// Check if a role is system-wide (TYPE_GLOBAL or role_uuid starts with role_global_)
|
||||
const isSystemRole = (role: any) => {
|
||||
// Check for role_type field first
|
||||
if (role.role_type === 'TYPE_GLOBAL') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for role_uuid starting with role_global_
|
||||
if (role.role_uuid && role.role_uuid.startsWith('role_global_')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common system role IDs (1-4 are typically system roles)
|
||||
if (role.id && [1, 2, 3, 4].includes(role.id)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the role name indicates it's a system role
|
||||
if (role.name && ['Admin', 'Maintainer', 'Instructor', 'User'].includes(role.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-6"></div>
|
||||
<div className="mx-4 sm:mx-6 lg:mx-10 bg-white rounded-xl nice-shadow px-3 sm:px-4 py-4">
|
||||
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Manage Roles & Permissions</h1>
|
||||
<h2 className="text-gray-500 text-xs sm:text-sm">
|
||||
{' '}
|
||||
Roles define what users can do within your organization. Create custom roles with specific permissions for different user types.{' '}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Mobile view - Cards */}
|
||||
<div className="block sm:hidden space-y-3">
|
||||
{roles?.map((role: any) => {
|
||||
const isSystem = isSystemRole(role)
|
||||
return (
|
||||
<div key={role.id} className="bg-white border border-gray-200 rounded-lg p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-sm">{role.name}</span>
|
||||
{isSystem && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<Globe className="w-3 h-3 mr-1" />
|
||||
System-wide
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{getRightsSummary(role.rights)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">{role.description || 'No description'}</p>
|
||||
<div className="flex space-x-2">
|
||||
{!isSystem ? (
|
||||
<>
|
||||
<Modal
|
||||
isDialogOpen={
|
||||
editRoleModal &&
|
||||
selectedRole?.id === role.id
|
||||
}
|
||||
onOpenChange={() =>
|
||||
handleEditRoleModal(role)
|
||||
}
|
||||
minHeight="lg"
|
||||
minWidth='xl'
|
||||
customWidth="max-w-7xl"
|
||||
dialogContent={
|
||||
<EditRole
|
||||
role={role}
|
||||
setEditRoleModal={setEditRoleModal}
|
||||
/>
|
||||
}
|
||||
dialogTitle="Edit Role"
|
||||
dialogDescription={
|
||||
'Edit the role permissions and details'
|
||||
}
|
||||
dialogTrigger={
|
||||
<button className="flex-1 flex justify-center space-x-2 hover:cursor-pointer p-2 bg-black rounded-md font-bold items-center text-sm text-white hover:bg-gray-800 transition-colors shadow-sm">
|
||||
<Pencil className="w-4 h-4" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Delete Role"
|
||||
confirmationMessage="This action cannot be undone. All users with this role will lose their permissions. Are you sure you want to delete this role?"
|
||||
dialogTitle={'Delete Role ?'}
|
||||
dialogTrigger={
|
||||
<button className="flex-1 flex justify-center space-x-2 hover:cursor-pointer p-2 bg-red-600 rounded-md font-bold items-center text-sm text-white hover:bg-red-700 transition-colors shadow-sm">
|
||||
<X className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
}
|
||||
functionToExecute={() => {
|
||||
deleteRoleUI(role.id)
|
||||
}}
|
||||
status="warning"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop view - Table */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
|
||||
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
|
||||
<tr className="font-bolder text-sm">
|
||||
<th className="py-3 px-4">Role Name</th>
|
||||
<th className="py-3 px-4">Description</th>
|
||||
<th className="py-3 px-4">Permissions</th>
|
||||
<th className="py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<>
|
||||
<tbody className="mt-5 bg-white rounded-md">
|
||||
{roles?.map((role: any) => {
|
||||
const isSystem = isSystemRole(role)
|
||||
return (
|
||||
<tr key={role.id} className="border-b border-gray-100 text-sm hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium">{role.name}</span>
|
||||
{isSystem && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<Globe className="w-3 h-3 mr-1" />
|
||||
System-wide
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{role.description || 'No description'}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{getRightsSummary(role.rights)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex space-x-2">
|
||||
{!isSystem ? (
|
||||
<>
|
||||
<Modal
|
||||
isDialogOpen={
|
||||
editRoleModal &&
|
||||
selectedRole?.id === role.id
|
||||
}
|
||||
onOpenChange={() =>
|
||||
handleEditRoleModal(role)
|
||||
}
|
||||
minHeight="lg"
|
||||
minWidth='xl'
|
||||
customWidth="max-w-7xl"
|
||||
dialogContent={
|
||||
<EditRole
|
||||
role={role}
|
||||
setEditRoleModal={setEditRoleModal}
|
||||
/>
|
||||
}
|
||||
dialogTitle="Edit Role"
|
||||
dialogDescription={
|
||||
'Edit the role permissions and details'
|
||||
}
|
||||
dialogTrigger={
|
||||
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-black rounded-md font-bold items-center text-sm text-white hover:bg-gray-800 transition-colors shadow-sm">
|
||||
<Pencil className="w-4 h-4" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Delete Role"
|
||||
confirmationMessage="This action cannot be undone. All users with this role will lose their permissions. Are you sure you want to delete this role?"
|
||||
dialogTitle={'Delete Role ?'}
|
||||
dialogTrigger={
|
||||
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-red-600 rounded-md font-bold items-center text-sm text-white hover:bg-red-700 transition-colors shadow-sm">
|
||||
<X className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
}
|
||||
functionToExecute={() => {
|
||||
deleteRoleUI(role.id)
|
||||
}}
|
||||
status="warning"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end mt-3 mr-2'>
|
||||
<Modal
|
||||
isDialogOpen={createRoleModal}
|
||||
onOpenChange={() => setCreateRoleModal(!createRoleModal)}
|
||||
minHeight="no-min"
|
||||
minWidth='xl'
|
||||
customWidth="max-w-7xl"
|
||||
dialogContent={
|
||||
<AddRole
|
||||
setCreateRoleModal={setCreateRoleModal}
|
||||
/>
|
||||
}
|
||||
dialogTitle="Create a Role"
|
||||
dialogDescription={
|
||||
'Create a new role with specific permissions'
|
||||
}
|
||||
dialogTrigger={
|
||||
<button className="flex space-x-2 hover:cursor-pointer p-2 sm:p-1 sm:px-3 bg-black rounded-md font-bold items-center text-sm text-white w-full sm:w-auto justify-center hover:bg-gray-800 transition-colors shadow-sm">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Create a Role</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrgRoles
|
||||
599
apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx
Normal file
599
apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
'use client'
|
||||
import FormLayout, {
|
||||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import React from 'react'
|
||||
import { createRole } from '@services/roles/roles'
|
||||
import { mutate } from 'swr'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useFormik } from 'formik'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Shield, BookOpen, Users, UserCheck, FolderOpen, Building, FileText, Activity, Settings, Monitor, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
type AddRoleProps = {
|
||||
setCreateRoleModal: any
|
||||
}
|
||||
|
||||
interface Rights {
|
||||
courses: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_read_own: boolean;
|
||||
action_update: boolean;
|
||||
action_update_own: boolean;
|
||||
action_delete: boolean;
|
||||
action_delete_own: boolean;
|
||||
};
|
||||
users: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
usergroups: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
collections: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
organizations: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
coursechapters: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
activities: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
roles: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
dashboard: {
|
||||
action_access: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = 'Required'
|
||||
} else if (values.name.length < 2) {
|
||||
errors.name = 'Name must be at least 2 characters'
|
||||
}
|
||||
|
||||
if (!values.description) {
|
||||
errors.description = 'Required'
|
||||
} else if (values.description.length < 10) {
|
||||
errors.description = 'Description must be at least 10 characters'
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const defaultRights: Rights = {
|
||||
courses: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_read_own: false,
|
||||
action_update: false,
|
||||
action_update_own: false,
|
||||
action_delete: false,
|
||||
action_delete_own: false
|
||||
},
|
||||
users: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
usergroups: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
collections: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
organizations: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
coursechapters: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
activities: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
roles: {
|
||||
action_create: false,
|
||||
action_read: false,
|
||||
action_update: false,
|
||||
action_delete: false
|
||||
},
|
||||
dashboard: {
|
||||
action_access: false
|
||||
}
|
||||
}
|
||||
|
||||
const predefinedRoles = {
|
||||
'Admin': {
|
||||
name: 'Admin',
|
||||
description: 'Full platform control with all permissions',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: true, action_delete_own: true },
|
||||
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
organizations: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
roles: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Course Manager': {
|
||||
name: 'Course Manager',
|
||||
description: 'Can manage courses, chapters, and activities',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Instructor': {
|
||||
name: 'Instructor',
|
||||
description: 'Can create and manage their own courses',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: false, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Viewer': {
|
||||
name: 'Viewer',
|
||||
description: 'Read-only access to courses and content',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Content Creator': {
|
||||
name: 'Content Creator',
|
||||
description: 'Can create and edit content but not manage users',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'User Manager': {
|
||||
name: 'User Manager',
|
||||
description: 'Can manage users and user groups',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Moderator': {
|
||||
name: 'Moderator',
|
||||
description: 'Can moderate content and manage activities',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Analyst': {
|
||||
name: 'Analyst',
|
||||
description: 'Read-only access with analytics capabilities',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Guest': {
|
||||
name: 'Guest',
|
||||
description: 'Limited access for external users',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: false, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function AddRole(props: AddRoleProps) {
|
||||
const org = useOrg() as any;
|
||||
const session = useLHSession() as any
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const [rights, setRights] = React.useState<Rights>(defaultRights)
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
org_id: org.id,
|
||||
rights: defaultRights
|
||||
},
|
||||
validate,
|
||||
onSubmit: async (values) => {
|
||||
const toastID = toast.loading("Creating...")
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Ensure rights object is properly structured
|
||||
const formattedRights = {
|
||||
courses: {
|
||||
action_create: rights.courses?.action_create || false,
|
||||
action_read: rights.courses?.action_read || false,
|
||||
action_read_own: rights.courses?.action_read_own || false,
|
||||
action_update: rights.courses?.action_update || false,
|
||||
action_update_own: rights.courses?.action_update_own || false,
|
||||
action_delete: rights.courses?.action_delete || false,
|
||||
action_delete_own: rights.courses?.action_delete_own || false
|
||||
},
|
||||
users: {
|
||||
action_create: rights.users?.action_create || false,
|
||||
action_read: rights.users?.action_read || false,
|
||||
action_update: rights.users?.action_update || false,
|
||||
action_delete: rights.users?.action_delete || false
|
||||
},
|
||||
usergroups: {
|
||||
action_create: rights.usergroups?.action_create || false,
|
||||
action_read: rights.usergroups?.action_read || false,
|
||||
action_update: rights.usergroups?.action_update || false,
|
||||
action_delete: rights.usergroups?.action_delete || false
|
||||
},
|
||||
collections: {
|
||||
action_create: rights.collections?.action_create || false,
|
||||
action_read: rights.collections?.action_read || false,
|
||||
action_update: rights.collections?.action_update || false,
|
||||
action_delete: rights.collections?.action_delete || false
|
||||
},
|
||||
organizations: {
|
||||
action_create: rights.organizations?.action_create || false,
|
||||
action_read: rights.organizations?.action_read || false,
|
||||
action_update: rights.organizations?.action_update || false,
|
||||
action_delete: rights.organizations?.action_delete || false
|
||||
},
|
||||
coursechapters: {
|
||||
action_create: rights.coursechapters?.action_create || false,
|
||||
action_read: rights.coursechapters?.action_read || false,
|
||||
action_update: rights.coursechapters?.action_update || false,
|
||||
action_delete: rights.coursechapters?.action_delete || false
|
||||
},
|
||||
activities: {
|
||||
action_create: rights.activities?.action_create || false,
|
||||
action_read: rights.activities?.action_read || false,
|
||||
action_update: rights.activities?.action_update || false,
|
||||
action_delete: rights.activities?.action_delete || false
|
||||
},
|
||||
roles: {
|
||||
action_create: rights.roles?.action_create || false,
|
||||
action_read: rights.roles?.action_read || false,
|
||||
action_update: rights.roles?.action_update || false,
|
||||
action_delete: rights.roles?.action_delete || false
|
||||
},
|
||||
dashboard: {
|
||||
action_access: rights.dashboard?.action_access || false
|
||||
}
|
||||
}
|
||||
|
||||
const res = await createRole({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
org_id: values.org_id,
|
||||
rights: formattedRights
|
||||
}, access_token)
|
||||
if (res.status === 200 || res.status === 201) {
|
||||
setIsSubmitting(false)
|
||||
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||
props.setCreateRoleModal(false)
|
||||
toast.success("Created new role", {id:toastID})
|
||||
} else {
|
||||
setIsSubmitting(false)
|
||||
toast.error("Couldn't create new role", {id:toastID})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleRightChange = (section: keyof Rights, action: string, value: boolean) => {
|
||||
setRights(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[action]: value
|
||||
} as any
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAll = (section: keyof Rights, value: boolean) => {
|
||||
setRights(prev => ({
|
||||
...prev,
|
||||
[section]: Object.keys(prev[section]).reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: value
|
||||
}), {} as any)
|
||||
}))
|
||||
}
|
||||
|
||||
const handlePredefinedRole = (roleKey: string) => {
|
||||
const role = predefinedRoles[roleKey as keyof typeof predefinedRoles]
|
||||
if (role) {
|
||||
formik.setFieldValue('name', role.name)
|
||||
formik.setFieldValue('description', role.description)
|
||||
setRights(role.rights as Rights)
|
||||
}
|
||||
}
|
||||
|
||||
const PermissionSection = ({ title, icon: Icon, section, permissions }: { title: string, icon: any, section: keyof Rights, permissions: string[] }) => {
|
||||
const sectionRights = rights[section] as any
|
||||
const allSelected = permissions.every(perm => sectionRights[perm])
|
||||
const someSelected = permissions.some(perm => sectionRights[perm]) && !allSelected
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="font-semibold text-gray-800 text-sm sm:text-base">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAll(section, !allSelected)}
|
||||
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-700 font-medium self-start sm:self-auto transition-colors"
|
||||
>
|
||||
{allSelected ? <CheckSquare className="w-4 h-4" /> : someSelected ? <Square className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{allSelected ? 'Deselect All' : 'Select All'}</span>
|
||||
<span className="sm:hidden">{allSelected ? 'Deselect' : 'Select'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission} className="flex items-center space-x-2 cursor-pointer p-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rights[section]?.[permission as keyof typeof rights[typeof section]] || false}
|
||||
onChange={(e) => handleRightChange(section, permission, e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 capitalize">
|
||||
{permission.replace('action_', '').replace('_', ' ')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-3 max-w-6xl mx-auto px-2 sm:px-0">
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage label="Role Name" message={formik.errors.name} />
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Course Manager"
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage label="Description" message={formik.errors.description} />
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.description}
|
||||
required
|
||||
placeholder="Describe what this role can do..."
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Predefined Rights</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.keys(predefinedRoles).map((roleKey) => (
|
||||
<button
|
||||
key={roleKey}
|
||||
type="button"
|
||||
onClick={() => handlePredefinedRole(roleKey)}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 text-left bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="font-medium text-gray-900 text-sm sm:text-base">{predefinedRoles[roleKey as keyof typeof predefinedRoles].name}</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">{predefinedRoles[roleKey as keyof typeof predefinedRoles].description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Permissions</h3>
|
||||
|
||||
<PermissionSection
|
||||
title="Courses"
|
||||
icon={BookOpen}
|
||||
section="courses"
|
||||
permissions={['action_create', 'action_read', 'action_read_own', 'action_update', 'action_update_own', 'action_delete', 'action_delete_own']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Users"
|
||||
icon={Users}
|
||||
section="users"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="User Groups"
|
||||
icon={UserCheck}
|
||||
section="usergroups"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Collections"
|
||||
icon={FolderOpen}
|
||||
section="collections"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Organizations"
|
||||
icon={Building}
|
||||
section="organizations"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Course Chapters"
|
||||
icon={FileText}
|
||||
section="coursechapters"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Activities"
|
||||
icon={Activity}
|
||||
section="activities"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Roles"
|
||||
icon={Shield}
|
||||
section="roles"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Dashboard"
|
||||
icon={Monitor}
|
||||
section="dashboard"
|
||||
permissions={['action_access']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setCreateRoleModal(false)}
|
||||
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors w-full sm:w-auto font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Form.Submit asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 w-full sm:w-auto font-medium shadow-sm"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Role'}
|
||||
</button>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
</FormLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRole
|
||||
548
apps/web/components/Objects/Modals/Dash/OrgRoles/EditRole.tsx
Normal file
548
apps/web/components/Objects/Modals/Dash/OrgRoles/EditRole.tsx
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
'use client'
|
||||
import FormLayout, {
|
||||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import React from 'react'
|
||||
import { updateRole } from '@services/roles/roles'
|
||||
import { mutate } from 'swr'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useFormik } from 'formik'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Shield, BookOpen, Users, UserCheck, FolderOpen, Building, FileText, Activity, Settings, Monitor, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
type EditRoleProps = {
|
||||
role: {
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
rights: any
|
||||
}
|
||||
setEditRoleModal: any
|
||||
}
|
||||
|
||||
interface Rights {
|
||||
courses: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_read_own: boolean;
|
||||
action_update: boolean;
|
||||
action_update_own: boolean;
|
||||
action_delete: boolean;
|
||||
action_delete_own: boolean;
|
||||
};
|
||||
users: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
usergroups: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
collections: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
organizations: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
coursechapters: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
activities: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
roles: {
|
||||
action_create: boolean;
|
||||
action_read: boolean;
|
||||
action_update: boolean;
|
||||
action_delete: boolean;
|
||||
};
|
||||
dashboard: {
|
||||
action_access: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = 'Required'
|
||||
} else if (values.name.length < 2) {
|
||||
errors.name = 'Name must be at least 2 characters'
|
||||
}
|
||||
|
||||
if (!values.description) {
|
||||
errors.description = 'Required'
|
||||
} else if (values.description.length < 10) {
|
||||
errors.description = 'Description must be at least 10 characters'
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const predefinedRoles = {
|
||||
'Admin': {
|
||||
name: 'Admin',
|
||||
description: 'Full platform control with all permissions',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: true, action_delete_own: true },
|
||||
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
organizations: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
roles: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Course Manager': {
|
||||
name: 'Course Manager',
|
||||
description: 'Can manage courses, chapters, and activities',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Instructor': {
|
||||
name: 'Instructor',
|
||||
description: 'Can create and manage their own courses',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: false, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Viewer': {
|
||||
name: 'Viewer',
|
||||
description: 'Read-only access to courses and content',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Content Creator': {
|
||||
name: 'Content Creator',
|
||||
description: 'Can create and edit content but not manage users',
|
||||
rights: {
|
||||
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'User Manager': {
|
||||
name: 'User Manager',
|
||||
description: 'Can manage users and user groups',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Moderator': {
|
||||
name: 'Moderator',
|
||||
description: 'Can moderate content and manage activities',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Analyst': {
|
||||
name: 'Analyst',
|
||||
description: 'Read-only access with analytics capabilities',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: true }
|
||||
}
|
||||
},
|
||||
'Guest': {
|
||||
name: 'Guest',
|
||||
description: 'Limited access for external users',
|
||||
rights: {
|
||||
courses: { action_create: false, action_read: true, action_read_own: false, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||
dashboard: { action_access: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function EditRole(props: EditRoleProps) {
|
||||
const org = useOrg() as any;
|
||||
const session = useLHSession() as any
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const [rights, setRights] = React.useState<Rights>(props.role.rights || {})
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: props.role.name,
|
||||
description: props.role.description,
|
||||
org_id: org.id,
|
||||
rights: props.role.rights || {}
|
||||
},
|
||||
validate,
|
||||
onSubmit: async (values) => {
|
||||
const toastID = toast.loading("Updating...")
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Ensure rights object is properly structured
|
||||
const formattedRights = {
|
||||
courses: {
|
||||
action_create: rights.courses?.action_create || false,
|
||||
action_read: rights.courses?.action_read || false,
|
||||
action_read_own: rights.courses?.action_read_own || false,
|
||||
action_update: rights.courses?.action_update || false,
|
||||
action_update_own: rights.courses?.action_update_own || false,
|
||||
action_delete: rights.courses?.action_delete || false,
|
||||
action_delete_own: rights.courses?.action_delete_own || false
|
||||
},
|
||||
users: {
|
||||
action_create: rights.users?.action_create || false,
|
||||
action_read: rights.users?.action_read || false,
|
||||
action_update: rights.users?.action_update || false,
|
||||
action_delete: rights.users?.action_delete || false
|
||||
},
|
||||
usergroups: {
|
||||
action_create: rights.usergroups?.action_create || false,
|
||||
action_read: rights.usergroups?.action_read || false,
|
||||
action_update: rights.usergroups?.action_update || false,
|
||||
action_delete: rights.usergroups?.action_delete || false
|
||||
},
|
||||
collections: {
|
||||
action_create: rights.collections?.action_create || false,
|
||||
action_read: rights.collections?.action_read || false,
|
||||
action_update: rights.collections?.action_update || false,
|
||||
action_delete: rights.collections?.action_delete || false
|
||||
},
|
||||
organizations: {
|
||||
action_create: rights.organizations?.action_create || false,
|
||||
action_read: rights.organizations?.action_read || false,
|
||||
action_update: rights.organizations?.action_update || false,
|
||||
action_delete: rights.organizations?.action_delete || false
|
||||
},
|
||||
coursechapters: {
|
||||
action_create: rights.coursechapters?.action_create || false,
|
||||
action_read: rights.coursechapters?.action_read || false,
|
||||
action_update: rights.coursechapters?.action_update || false,
|
||||
action_delete: rights.coursechapters?.action_delete || false
|
||||
},
|
||||
activities: {
|
||||
action_create: rights.activities?.action_create || false,
|
||||
action_read: rights.activities?.action_read || false,
|
||||
action_update: rights.activities?.action_update || false,
|
||||
action_delete: rights.activities?.action_delete || false
|
||||
},
|
||||
roles: {
|
||||
action_create: rights.roles?.action_create || false,
|
||||
action_read: rights.roles?.action_read || false,
|
||||
action_update: rights.roles?.action_update || false,
|
||||
action_delete: rights.roles?.action_delete || false
|
||||
},
|
||||
dashboard: {
|
||||
action_access: rights.dashboard?.action_access || false
|
||||
}
|
||||
}
|
||||
|
||||
const res = await updateRole(props.role.id, {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
org_id: values.org_id,
|
||||
rights: formattedRights
|
||||
}, access_token)
|
||||
if (res.status === 200) {
|
||||
setIsSubmitting(false)
|
||||
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||
props.setEditRoleModal(false)
|
||||
toast.success("Updated role", {id:toastID})
|
||||
} else {
|
||||
setIsSubmitting(false)
|
||||
toast.error("Couldn't update role", {id:toastID})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleRightChange = (section: keyof Rights, action: string, value: boolean) => {
|
||||
setRights(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[action]: value
|
||||
} as any
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAll = (section: keyof Rights, value: boolean) => {
|
||||
setRights(prev => ({
|
||||
...prev,
|
||||
[section]: Object.keys(prev[section]).reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: value
|
||||
}), {} as any)
|
||||
}))
|
||||
}
|
||||
|
||||
const handlePredefinedRole = (roleKey: string) => {
|
||||
const role = predefinedRoles[roleKey as keyof typeof predefinedRoles]
|
||||
if (role) {
|
||||
formik.setFieldValue('name', role.name)
|
||||
formik.setFieldValue('description', role.description)
|
||||
setRights(role.rights as Rights)
|
||||
}
|
||||
}
|
||||
|
||||
const PermissionSection = ({ title, icon: Icon, section, permissions }: { title: string, icon: any, section: keyof Rights, permissions: string[] }) => {
|
||||
const sectionRights = rights[section] as any
|
||||
const allSelected = permissions.every(perm => sectionRights[perm])
|
||||
const someSelected = permissions.some(perm => sectionRights[perm]) && !allSelected
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="font-semibold text-gray-800 text-sm sm:text-base">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAll(section, !allSelected)}
|
||||
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-700 font-medium self-start sm:self-auto transition-colors"
|
||||
>
|
||||
{allSelected ? <CheckSquare className="w-4 h-4" /> : someSelected ? <Square className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{allSelected ? 'Deselect All' : 'Select All'}</span>
|
||||
<span className="sm:hidden">{allSelected ? 'Deselect' : 'Select'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission} className="flex items-center space-x-2 cursor-pointer p-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rights[section]?.[permission as keyof typeof rights[typeof section]] || false}
|
||||
onChange={(e) => handleRightChange(section, permission, e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 capitalize">
|
||||
{permission.replace('action_', '').replace('_', ' ')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-3 max-w-6xl mx-auto px-2 sm:px-0">
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage label="Role Name" message={formik.errors.name} />
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Course Manager"
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage label="Description" message={formik.errors.description} />
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.description}
|
||||
required
|
||||
placeholder="Describe what this role can do..."
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Predefined Rights</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.keys(predefinedRoles).map((roleKey) => (
|
||||
<button
|
||||
key={roleKey}
|
||||
type="button"
|
||||
onClick={() => handlePredefinedRole(roleKey)}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 text-left bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="font-medium text-gray-900 text-sm sm:text-base">{predefinedRoles[roleKey as keyof typeof predefinedRoles].name}</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">{predefinedRoles[roleKey as keyof typeof predefinedRoles].description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Permissions</h3>
|
||||
|
||||
<PermissionSection
|
||||
title="Courses"
|
||||
icon={BookOpen}
|
||||
section="courses"
|
||||
permissions={['action_create', 'action_read', 'action_read_own', 'action_update', 'action_update_own', 'action_delete', 'action_delete_own']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Users"
|
||||
icon={Users}
|
||||
section="users"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="User Groups"
|
||||
icon={UserCheck}
|
||||
section="usergroups"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Collections"
|
||||
icon={FolderOpen}
|
||||
section="collections"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Organizations"
|
||||
icon={Building}
|
||||
section="organizations"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Course Chapters"
|
||||
icon={FileText}
|
||||
section="coursechapters"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Activities"
|
||||
icon={Activity}
|
||||
section="activities"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Roles"
|
||||
icon={Shield}
|
||||
section="roles"
|
||||
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||
/>
|
||||
|
||||
<PermissionSection
|
||||
title="Dashboard"
|
||||
icon={Monitor}
|
||||
section="dashboard"
|
||||
permissions={['action_access']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setEditRoleModal(false)}
|
||||
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors w-full sm:w-auto font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Form.Submit asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 w-full sm:w-auto font-medium shadow-sm"
|
||||
>
|
||||
{isSubmitting ? 'Updating...' : 'Update Role'}
|
||||
</button>
|
||||
</Form.Submit>
|
||||
</div>
|
||||
</FormLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditRole
|
||||
|
|
@ -11,10 +11,12 @@ import * as Form from '@radix-ui/react-form'
|
|||
import { FormMessage } from '@radix-ui/react-form'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { updateUserRole } from '@services/organizations/orgs'
|
||||
import { swrFetcher } from '@services/utils/ts/requests'
|
||||
import React, { useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
import { mutate } from 'swr'
|
||||
import useSWR from 'swr'
|
||||
|
||||
interface Props {
|
||||
user: any
|
||||
|
|
@ -25,13 +27,19 @@ interface Props {
|
|||
function RolesUpdate(props: Props) {
|
||||
const org = useOrg() as any
|
||||
const session = useLHSession() as any
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const [assignedRole, setAssignedRole] = React.useState(
|
||||
props.alreadyAssignedRole
|
||||
)
|
||||
const [error, setError] = React.useState(null) as any
|
||||
|
||||
// Fetch available roles for the organization
|
||||
const { data: roles, error: rolesError } = useSWR(
|
||||
org ? `${getAPIUrl()}roles/org/${org.id}` : null,
|
||||
(url) => swrFetcher(url, access_token)
|
||||
)
|
||||
|
||||
const handleAssignedRole = (event: React.ChangeEvent<any>) => {
|
||||
setError(null)
|
||||
setAssignedRole(event.target.value)
|
||||
|
|
@ -80,10 +88,20 @@ function RolesUpdate(props: Props) {
|
|||
defaultValue={assignedRole}
|
||||
className="border border-gray-300 rounded-md p-2"
|
||||
required
|
||||
disabled={!roles || rolesError}
|
||||
>
|
||||
<option value="role_global_admin">Admin </option>
|
||||
<option value="role_global_maintainer">Maintainer</option>
|
||||
<option value="role_global_user">User</option>
|
||||
{!roles || rolesError ? (
|
||||
<option value="">Loading roles...</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">Select a role</option>
|
||||
{roles.map((role: any) => (
|
||||
<option key={role.id} value={role.role_uuid || role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ const Modal = (params: ModalParams) => {
|
|||
<DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className={cn(
|
||||
"overflow-auto",
|
||||
"w-[95vw] max-w-[95vw]",
|
||||
"max-h-[90vh]",
|
||||
"p-4",
|
||||
// Tablet and up
|
||||
"md:w-auto md:max-w-[90vw] md:p-6",
|
||||
"p-3 sm:p-4 md:p-6",
|
||||
// Mobile-first responsive design
|
||||
"sm:w-[90vw] sm:max-w-[90vw]",
|
||||
"md:w-auto md:max-w-[90vw]",
|
||||
"lg:max-w-[85vw]",
|
||||
"xl:max-w-[80vw]",
|
||||
getMinHeight(),
|
||||
getMinWidth(),
|
||||
params.customHeight,
|
||||
|
|
@ -60,15 +62,17 @@ const Modal = (params: ModalParams) => {
|
|||
)}>
|
||||
{params.dialogTitle && params.dialogDescription && (
|
||||
<DialogHeader className="text-center flex flex-col space-y-0.5 w-full">
|
||||
<DialogTitle>{params.dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{params.dialogDescription}</DialogDescription>
|
||||
<DialogTitle className="text-lg sm:text-xl md:text-2xl">{params.dialogTitle}</DialogTitle>
|
||||
<DialogDescription className="text-sm sm:text-base">{params.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div className="overflow-auto">
|
||||
{params.dialogContent}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)] scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
|
||||
<div className="pr-2">
|
||||
{params.dialogContent}
|
||||
</div>
|
||||
</div>
|
||||
{(params.dialogClose || params.addDefCloseButton) && (
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex flex-col sm:flex-row gap-2 sm:gap-0">
|
||||
{params.dialogClose}
|
||||
{params.addDefCloseButton && (
|
||||
<ButtonBlack type="submit">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ interface RoleInfo {
|
|||
description: string;
|
||||
}
|
||||
|
||||
interface CustomRoleInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const HeaderProfileBox = () => {
|
||||
const session = useLHSession() as any
|
||||
const { isAdmin, loading, userRoles, rights } = useAdminStatus()
|
||||
|
|
@ -103,6 +108,31 @@ export const HeaderProfileBox = () => {
|
|||
return roleConfigs[roleKey] || roleConfigs['role_global_user'];
|
||||
}, [userRoles, org?.id]);
|
||||
|
||||
const customRoles = useMemo((): CustomRoleInfo[] => {
|
||||
if (!userRoles || userRoles.length === 0) return [];
|
||||
|
||||
// Find roles for the current organization
|
||||
const orgRoles = userRoles.filter((role: any) => role.org.id === org?.id);
|
||||
|
||||
if (orgRoles.length === 0) return [];
|
||||
|
||||
// Filter for custom roles (not system roles)
|
||||
const customRoles = orgRoles.filter((role: any) => {
|
||||
// Check if it's a system role
|
||||
const isSystemRole =
|
||||
role.role.role_uuid?.startsWith('role_global_') ||
|
||||
[1, 2, 3, 4].includes(role.role.id) ||
|
||||
['Admin', 'Maintainer', 'Instructor', 'User'].includes(role.role.name);
|
||||
|
||||
return !isSystemRole;
|
||||
});
|
||||
|
||||
return customRoles.map((role: any) => ({
|
||||
name: role.role.name || 'Custom Role',
|
||||
description: role.role.description
|
||||
}));
|
||||
}, [userRoles, org?.id]);
|
||||
|
||||
return (
|
||||
<ProfileArea>
|
||||
{session.status == 'unauthenticated' && (
|
||||
|
|
@ -140,6 +170,20 @@ export const HeaderProfileBox = () => {
|
|||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Custom roles */}
|
||||
{customRoles.map((customRole, index) => (
|
||||
<Tooltip
|
||||
key={index}
|
||||
content={customRole.description || `Custom role: ${customRole.name}`}
|
||||
sideOffset={15}
|
||||
side="bottom"
|
||||
>
|
||||
<div className="text-[6px] bg-gray-500 text-white px-1 py-0.5 font-medium rounded-full flex items-center gap-0.5 w-fit">
|
||||
<Shield size={12} />
|
||||
{customRole.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<p className='text-xs text-gray-500'>{session.data.user.email}</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue