feat: enhance role management API with organization-specific role creation and retrieval, including comprehensive RBAC checks for permissions

This commit is contained in:
swve 2025-08-09 14:26:48 +02:00
parent 3ce019abec
commit 531e1863c0
10 changed files with 2174 additions and 32 deletions

View file

@ -1,28 +1,45 @@
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, HTTPException
from sqlmodel import Session
from src.core.events.database import get_db_session
from src.db.roles import RoleCreate, RoleRead, RoleUpdate
from src.security.auth import get_current_user
from src.services.roles.roles import create_role, delete_role, read_role, update_role
from src.services.roles.roles import create_role, delete_role, read_role, update_role, get_roles_by_organization
from src.db.users import PublicUser
from typing import List
router = APIRouter()
@router.post("/")
@router.post("/org/{org_id}")
async def api_create_role(
request: Request,
org_id: int,
role_object: RoleCreate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
)-> RoleRead:
"""
Create new role
Create new role for a specific organization
"""
# Set the org_id in the role object
role_object.org_id = org_id
return await create_role(request, db_session, role_object, current_user)
@router.get("/org/{org_id}")
async def api_get_roles_by_organization(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
)-> List[RoleRead]:
"""
Get all roles for a specific organization, including global roles
"""
return await get_roles_by_organization(request, db_session, org_id, current_user)
@router.get("/{role_id}")
async def api_get_role(
request: Request,
@ -39,6 +56,7 @@ async def api_get_role(
@router.put("/{role_id}")
async def api_update_role(
request: Request,
role_id: str,
role_object: RoleUpdate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
@ -46,6 +64,16 @@ async def api_update_role(
"""
Update role by role_id
"""
# Convert role_id to integer and set it in the role_object
try:
role_id_int = int(role_id)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid role ID format. Role ID must be a number.",
)
role_object.role_id = role_id_int
return await update_role(request, db_session, role_object, current_user)

View file

@ -1,12 +1,15 @@
from typing import Literal
from typing import Literal, List
from uuid import uuid4
from sqlmodel import Session, select
from sqlmodel import Session, select, text
from sqlalchemy.exc import IntegrityError
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.users import AnonymousUser, PublicUser
from src.db.roles import Role, RoleCreate, RoleRead, RoleUpdate
from src.db.roles import Role, RoleCreate, RoleRead, RoleUpdate, RoleTypeEnum
from src.db.organizations import Organization
from src.db.user_organizations import UserOrganization
from fastapi import HTTPException, Request
from datetime import datetime
@ -22,24 +25,401 @@ async def create_role(
# RBAC check
await rbac_check(request, current_user, "create", "role_xxx", db_session)
# ============================================================================
# VERIFICATION 1: Ensure the role is created as TYPE_ORGANIZATION and has an org_id
# ============================================================================
if not role.org_id:
raise HTTPException(
status_code=400,
detail="Organization ID is required for role creation",
)
# Force the role type to be TYPE_ORGANIZATION for user-created roles
role.role_type = RoleTypeEnum.TYPE_ORGANIZATION
# ============================================================================
# VERIFICATION 2: Check if the organization exists
# ============================================================================
statement = select(Organization).where(Organization.id == role.org_id)
organization = db_session.exec(statement).first()
if not organization:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# ============================================================================
# VERIFICATION 3: Check if the current user is a member of the organization
# ============================================================================
statement = select(UserOrganization).where(
UserOrganization.user_id == current_user.id,
UserOrganization.org_id == role.org_id
)
user_org = db_session.exec(statement).first()
if not user_org:
raise HTTPException(
status_code=403,
detail="You are not a member of this organization",
)
# ============================================================================
# VERIFICATION 4: Check if the user has permission to create roles in this organization
# ============================================================================
# Get the user's role in this organization
statement = select(Role).where(Role.id == user_org.role_id)
user_role = db_session.exec(statement).first()
if not user_role:
raise HTTPException(
status_code=403,
detail="Your role in this organization could not be determined",
)
# Check if the user has role creation permissions
if user_role.rights and isinstance(user_role.rights, dict):
roles_rights = user_role.rights.get('roles', {})
if not roles_rights.get('action_create', False):
raise HTTPException(
status_code=403,
detail="You don't have permission to create roles in this organization",
)
else:
# If no rights are defined, check if user has admin role (role_id 1 or 2)
if user_role.id not in [1, 2]: # Admin and Maintainer roles
raise HTTPException(
status_code=403,
detail="You don't have permission to create roles in this organization. Admin or Maintainer role required.",
)
# ============================================================================
# VERIFICATION 5: Check if a role with the same name already exists in this organization
# ============================================================================
statement = select(Role).where(
Role.name == role.name,
Role.org_id == role.org_id,
Role.role_type == RoleTypeEnum.TYPE_ORGANIZATION
)
existing_role = db_session.exec(statement).first()
if existing_role:
raise HTTPException(
status_code=409,
detail=f"A role with the name '{role.name}' already exists in this organization",
)
# ============================================================================
# VERIFICATION 6: Validate role name and description
# ============================================================================
if not role.name or role.name.strip() == "":
raise HTTPException(
status_code=400,
detail="Role name is required and cannot be empty",
)
if len(role.name.strip()) > 100: # Assuming a reasonable limit
raise HTTPException(
status_code=400,
detail="Role name cannot exceed 100 characters",
)
# ============================================================================
# VERIFICATION 7: Validate rights structure if provided
# ============================================================================
if role.rights:
# Convert Rights model to dict if needed
if isinstance(role.rights, dict):
# It's already a dict
rights_dict = role.rights
else:
# It's likely a Pydantic model, try to convert to dict
try:
# Try dict() method first (for Pydantic v1)
rights_dict = role.rights.dict()
except AttributeError:
try:
# Try model_dump() method (for Pydantic v2)
rights_dict = role.rights.model_dump() # type: ignore
except AttributeError:
raise HTTPException(
status_code=400,
detail="Rights must be provided as a JSON object",
)
# Validate rights structure - check for required top-level keys
required_rights = [
'courses', 'users', 'usergroups', 'collections',
'organizations', 'coursechapters', 'activities',
'roles', 'dashboard'
]
for required_right in required_rights:
if required_right not in rights_dict:
raise HTTPException(
status_code=400,
detail=f"Missing required right: {required_right}",
)
# Validate the structure of each right
right_data = rights_dict[required_right]
if not isinstance(right_data, dict):
raise HTTPException(
status_code=400,
detail=f"Right '{required_right}' must be a JSON object",
)
# Validate courses permissions (has additional 'own' permissions)
if required_right == 'courses':
required_course_permissions = [
'action_create', 'action_read', 'action_read_own',
'action_update', 'action_update_own', 'action_delete', 'action_delete_own'
]
for perm in required_course_permissions:
if perm not in right_data:
raise HTTPException(
status_code=400,
detail=f"Missing required course permission: {perm}",
)
if not isinstance(right_data[perm], bool):
raise HTTPException(
status_code=400,
detail=f"Course permission '{perm}' must be a boolean",
)
# Validate other permissions (standard permissions)
elif required_right in ['users', 'usergroups', 'collections', 'organizations', 'coursechapters', 'activities', 'roles']:
required_permissions = ['action_create', 'action_read', 'action_update', 'action_delete']
for perm in required_permissions:
if perm not in right_data:
raise HTTPException(
status_code=400,
detail=f"Missing required permission '{perm}' for '{required_right}'",
)
if not isinstance(right_data[perm], bool):
raise HTTPException(
status_code=400,
detail=f"Permission '{perm}' for '{required_right}' must be a boolean",
)
# Validate dashboard permissions
elif required_right == 'dashboard':
if 'action_access' not in right_data:
raise HTTPException(
status_code=400,
detail="Missing required dashboard permission: action_access",
)
if not isinstance(right_data['action_access'], bool):
raise HTTPException(
status_code=400,
detail="Dashboard permission 'action_access' must be a boolean",
)
# Convert back to dict if it was a model
if not isinstance(role.rights, dict):
role.rights = rights_dict
# ============================================================================
# VERIFICATION 8: Ensure user cannot create a role with higher permissions than they have
# ============================================================================
if role.rights and isinstance(role.rights, dict) and user_role.rights and isinstance(user_role.rights, dict):
# Check if the new role has any permissions that the user doesn't have
for right_key, right_permissions in role.rights.items():
if right_key in user_role.rights:
user_right_permissions = user_role.rights[right_key]
# Check each permission in the right
for perm_key, perm_value in right_permissions.items():
if isinstance(perm_value, bool) and perm_value: # If the new role has this permission enabled
if isinstance(user_right_permissions, dict) and perm_key in user_right_permissions:
user_has_perm = user_right_permissions[perm_key]
if not user_has_perm:
raise HTTPException(
status_code=403,
detail=f"You cannot create a role with '{perm_key}' permission for '{right_key}' as you don't have this permission yourself",
)
else:
raise HTTPException(
status_code=403,
detail=f"You cannot create a role with '{perm_key}' permission for '{right_key}' as you don't have this permission yourself",
)
# Complete the role object
role.role_uuid = f"role_{uuid4()}"
role.creation_date = str(datetime.now())
role.update_date = str(datetime.now())
# ============================================================================
# VERIFICATION 9: Handle ID sequence issue (existing logic)
# ============================================================================
try:
db_session.add(role)
db_session.commit()
db_session.refresh(role)
except IntegrityError as e:
if "duplicate key value violates unique constraint" in str(e) and "role_pkey" in str(e):
# Handle the sequence issue by finding the next available ID
db_session.rollback()
# Get the maximum ID from the role table using raw SQL
result = db_session.execute(text("SELECT COALESCE(MAX(id), 0) as max_id FROM role"))
max_id_result = result.scalar()
max_id = max_id_result if max_id_result is not None else 0
# Set the next available ID
role.id = max_id + 1
# Try to insert again
db_session.add(role)
db_session.commit()
db_session.refresh(role)
role = RoleRead(**role.model_dump())
# Update the sequence to the correct value for future inserts
try:
# Use raw SQL to update the sequence
db_session.execute(text(f"SELECT setval('role_id_seq', {max_id + 1}, true)"))
db_session.commit()
except Exception:
# If sequence doesn't exist or can't be updated, that's okay
# The manual ID assignment above will handle it
pass
else:
# Re-raise the original exception if it's not the sequence issue
raise e
# Create RoleRead object with all required fields
role_data = role.model_dump()
# Ensure org_id is properly handled
if role_data.get('org_id') is None:
role_data['org_id'] = 0
role = RoleRead(**role_data)
return role
async def get_roles_by_organization(
request: Request,
db_session: Session,
org_id: int,
current_user: PublicUser,
) -> List[RoleRead]:
"""
Get all roles for a specific organization, including global roles.
Args:
request: FastAPI request object
db_session: Database session
org_id: Organization ID
current_user: Current authenticated user
Returns:
List[RoleRead]: List of roles for the organization (including global roles)
Raises:
HTTPException: If organization not found or user lacks permissions
"""
# ============================================================================
# VERIFICATION 1: Check if the organization exists
# ============================================================================
statement = select(Organization).where(Organization.id == org_id)
organization = db_session.exec(statement).first()
if not organization:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# ============================================================================
# VERIFICATION 2: Check if the current user is a member of the organization
# ============================================================================
statement = select(UserOrganization).where(
UserOrganization.user_id == current_user.id,
UserOrganization.org_id == org_id
)
user_org = db_session.exec(statement).first()
if not user_org:
raise HTTPException(
status_code=403,
detail="You are not a member of this organization",
)
# ============================================================================
# VERIFICATION 3: Check if the user has permission to read roles in this organization
# ============================================================================
# Get the user's role in this organization
statement = select(Role).where(Role.id == user_org.role_id)
user_role = db_session.exec(statement).first()
if not user_role:
raise HTTPException(
status_code=403,
detail="Your role in this organization could not be determined",
)
# Check if the user has role reading permissions
if user_role.rights and isinstance(user_role.rights, dict):
roles_rights = user_role.rights.get('roles', {})
if not roles_rights.get('action_read', False):
raise HTTPException(
status_code=403,
detail="You don't have permission to read roles in this organization",
)
else:
# If no rights are defined, check if user has admin role (role_id 1 or 2)
if user_role.id not in [1, 2]: # Admin and Maintainer roles
raise HTTPException(
status_code=403,
detail="You don't have permission to read roles in this organization. Admin or Maintainer role required.",
)
# ============================================================================
# GET ROLES: Fetch all roles for the organization AND global roles
# ============================================================================
# Get global roles first
global_roles_statement = select(Role).where(
Role.role_type == RoleTypeEnum.TYPE_GLOBAL
).order_by(Role.id) # type: ignore
global_roles = list(db_session.exec(global_roles_statement).all())
# Get organization-specific roles
org_roles_statement = select(Role).where(
Role.org_id == org_id,
Role.role_type == RoleTypeEnum.TYPE_ORGANIZATION
).order_by(Role.id) # type: ignore
org_roles = list(db_session.exec(org_roles_statement).all())
# Combine lists with global roles first, then organization roles
all_roles = global_roles + org_roles
# Convert to RoleRead objects
role_reads = []
for role in all_roles:
role_data = role.model_dump()
# Ensure org_id is properly handled
if role_data.get('org_id') is None:
role_data['org_id'] = 0
role_reads.append(RoleRead(**role_data))
return role_reads
async def read_role(
request: Request, db_session: Session, role_id: str, current_user: PublicUser
):
statement = select(Role).where(Role.id == role_id)
# Convert role_id to integer
try:
role_id_int = int(role_id)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid role ID format. Role ID must be a number.",
)
statement = select(Role).where(Role.id == role_id_int)
result = db_session.exec(statement)
role = result.first()
@ -75,6 +455,15 @@ async def update_role(
detail="Role not found",
)
# ============================================================================
# VERIFICATION: Prevent updating TYPE_GLOBAL roles
# ============================================================================
if role.role_type == RoleTypeEnum.TYPE_GLOBAL:
raise HTTPException(
status_code=403,
detail="Global roles cannot be updated. These are system-defined roles that must remain unchanged.",
)
# RBAC check
await rbac_check(request, current_user, "update", role.role_uuid, db_session)
@ -85,9 +474,116 @@ async def update_role(
del role_object.role_id
# Update only the fields that were passed in
for var, value in vars(role_object).items():
# Use model_dump() to get the data as a dictionary
try:
update_data = role_object.model_dump(exclude_unset=True)
except AttributeError:
# Fallback to dict() method for older Pydantic versions
try:
update_data = role_object.dict(exclude_unset=True)
except AttributeError:
# Fallback to vars() for SQLModel
update_data = {k: v for k, v in vars(role_object).items() if v is not None}
# Update the role with the new data
for key, value in update_data.items():
if value is not None:
setattr(role, var, value)
setattr(role, key, value)
# ============================================================================
# VALIDATE RIGHTS STRUCTURE if rights are being updated
# ============================================================================
if role.rights:
# Convert Rights model to dict if needed
if isinstance(role.rights, dict):
# It's already a dict
rights_dict = role.rights
else:
# It's likely a Pydantic model, try to convert to dict
try:
# Try dict() method first (for Pydantic v1)
rights_dict = role.rights.dict()
except AttributeError:
try:
# Try model_dump() method (for Pydantic v2)
rights_dict = role.rights.model_dump() # type: ignore
except AttributeError:
raise HTTPException(
status_code=400,
detail="Rights must be provided as a JSON object",
)
# Validate rights structure - check for required top-level keys
required_rights = [
'courses', 'users', 'usergroups', 'collections',
'organizations', 'coursechapters', 'activities',
'roles', 'dashboard'
]
for required_right in required_rights:
if required_right not in rights_dict:
raise HTTPException(
status_code=400,
detail=f"Missing required right: {required_right}",
)
# Validate the structure of each right
right_data = rights_dict[required_right]
if not isinstance(right_data, dict):
raise HTTPException(
status_code=400,
detail=f"Right '{required_right}' must be a JSON object",
)
# Validate courses permissions (has additional 'own' permissions)
if required_right == 'courses':
required_course_permissions = [
'action_create', 'action_read', 'action_read_own',
'action_update', 'action_update_own', 'action_delete', 'action_delete_own'
]
for perm in required_course_permissions:
if perm not in right_data:
raise HTTPException(
status_code=400,
detail=f"Missing required course permission: {perm}",
)
if not isinstance(right_data[perm], bool):
raise HTTPException(
status_code=400,
detail=f"Course permission '{perm}' must be a boolean",
)
# Validate other permissions (standard permissions)
elif required_right in ['users', 'usergroups', 'collections', 'organizations', 'coursechapters', 'activities', 'roles']:
required_permissions = ['action_create', 'action_read', 'action_update', 'action_delete']
for perm in required_permissions:
if perm not in right_data:
raise HTTPException(
status_code=400,
detail=f"Missing required permission '{perm}' for '{required_right}'",
)
if not isinstance(right_data[perm], bool):
raise HTTPException(
status_code=400,
detail=f"Permission '{perm}' for '{required_right}' must be a boolean",
)
# Validate dashboard permissions
elif required_right == 'dashboard':
if 'action_access' not in right_data:
raise HTTPException(
status_code=400,
detail="Missing required dashboard permission: action_access",
)
if not isinstance(right_data['action_access'], bool):
raise HTTPException(
status_code=400,
detail="Dashboard permission 'action_access' must be a boolean",
)
# Convert back to dict if it was a model
if not isinstance(role.rights, dict):
role.rights = rights_dict
db_session.add(role)
db_session.commit()
@ -101,10 +597,17 @@ async def update_role(
async def delete_role(
request: Request, db_session: Session, role_id: str, current_user: PublicUser
):
# RBAC check
await rbac_check(request, current_user, "delete", role_id, db_session)
# Convert role_id to integer
try:
role_id_int = int(role_id)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid role ID format. Role ID must be a number.",
)
statement = select(Role).where(Role.id == role_id)
# First, get the role to check if it exists and get its UUID
statement = select(Role).where(Role.id == role_id_int)
result = db_session.exec(statement)
role = result.first()
@ -115,6 +618,18 @@ async def delete_role(
detail="Role not found",
)
# ============================================================================
# VERIFICATION: Prevent deleting TYPE_GLOBAL roles
# ============================================================================
if role.role_type == RoleTypeEnum.TYPE_GLOBAL:
raise HTTPException(
status_code=403,
detail="Global roles cannot be deleted. These are system-defined roles that must remain unchanged.",
)
# RBAC check using the role's UUID
await rbac_check(request, current_user, "delete", role.role_uuid, db_session)
db_session.delete(role)
db_session.commit()

View file

@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
import Link from 'next/link'
import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config'
import { Monitor, ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react'
import { Monitor, ScanEye, SquareUserRound, UserPlus, Users, Shield } from 'lucide-react'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
@ -12,6 +12,7 @@ import OrgUsers from '@components/Dashboard/Pages/Users/OrgUsers/OrgUsers'
import OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups'
import OrgRoles from '@components/Dashboard/Pages/Users/OrgRoles/OrgRoles'
export type SettingsParams = {
subpage: string
@ -43,6 +44,10 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
setH1Label('UserGroups')
setH2Label('Create and manage user groups')
}
if (params.subpage == 'roles') {
setH1Label('Roles')
setH2Label('Create and manage roles with specific permissions')
}
}
useEffect(() => {
@ -112,6 +117,23 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/roles`
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'roles'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Shield size={16} />
<div>Roles</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/signups`
@ -160,6 +182,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
{params.subpage == 'signups' ? <OrgAccess /> : ''}
{params.subpage == 'add' ? <OrgUsersAdd /> : ''}
{params.subpage == 'usergroups' ? <OrgUserGroups /> : ''}
{params.subpage == 'roles' ? <OrgRoles /> : ''}
</motion.div>
</div>
)

View 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

View 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

View 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

View file

@ -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
@ -32,6 +34,12 @@ function RolesUpdate(props: Props) {
)
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>

View file

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

View file

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

View file

@ -0,0 +1,68 @@
import { getAPIUrl } from '@services/config/config'
import { RequestBodyWithAuthHeader, getResponseMetadata } from '@services/utils/ts/requests'
/*
Roles service matching available endpoints:
- GET roles/org/{org_id}
- POST roles/org/{org_id}
- GET roles/{role_id}
- PUT roles/{role_id}
- DELETE roles/{role_id}
Note: GET requests are usually fetched with SWR directly from components.
*/
export type CreateOrUpdateRoleBody = {
name: string
description?: string
rights: any
org_id?: number
}
export async function createRole(body: CreateOrUpdateRoleBody, access_token: string) {
const { org_id, ...payload } = body
if (!org_id) throw new Error('createRole requires org_id in body')
const result = await fetch(
`${getAPIUrl()}roles/org/${org_id}`,
RequestBodyWithAuthHeader('POST', payload, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function getRole(role_id: number | string, access_token?: string) {
const result = await fetch(
`${getAPIUrl()}roles/${role_id}`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function updateRole(
role_id: number | string,
body: CreateOrUpdateRoleBody,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}roles/${role_id}`,
RequestBodyWithAuthHeader('PUT', body, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}
export async function deleteRole(
role_id: number | string,
_org_id: number | string | undefined,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}roles/${role_id}`,
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
)
const res = await getResponseMetadata(result)
return res
}