From 531e1863c09b704b2b6a45db93f0f9eb5539ae12 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 9 Aug 2025 14:26:48 +0200 Subject: [PATCH] feat: enhance role management API with organization-specific role creation and retrieval, including comprehensive RBAC checks for permissions --- apps/api/src/routers/roles.py | 36 +- apps/api/src/services/roles/roles.py | 543 +++++++++++++++- .../dash/users/settings/[subpage]/page.tsx | 25 +- .../Pages/Users/OrgRoles/OrgRoles.tsx | 295 +++++++++ .../Objects/Modals/Dash/OrgRoles/AddRole.tsx | 599 ++++++++++++++++++ .../Objects/Modals/Dash/OrgRoles/EditRole.tsx | 548 ++++++++++++++++ .../Modals/Dash/OrgUsers/RolesUpdate.tsx | 26 +- .../Objects/StyledElements/Modal/Modal.tsx | 22 +- .../components/Security/HeaderProfileBox.tsx | 44 ++ apps/web/services/roles/roles.ts | 68 ++ 10 files changed, 2174 insertions(+), 32 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx create mode 100644 apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx create mode 100644 apps/web/components/Objects/Modals/Dash/OrgRoles/EditRole.tsx create mode 100644 apps/web/services/roles/roles.ts diff --git a/apps/api/src/routers/roles.py b/apps/api/src/routers/roles.py index ef9350e0..5d5cbfff 100644 --- a/apps/api/src/routers/roles.py +++ b/apps/api/src/routers/roles.py @@ -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) diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index ea5d0715..7e5a4b97 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -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()) - db_session.add(role) - db_session.commit() - db_session.refresh(role) + # ============================================================================ + # 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) + + # 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 - role = RoleRead(**role.model_dump()) + # 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) - - 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.", + ) + + # 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() diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx index 71131cf8..6930f42d 100644 --- a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -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 }) { 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 }) { + +
+
+ +
Roles
+
+
+ }) { {params.subpage == 'signups' ? : ''} {params.subpage == 'add' ? : ''} {params.subpage == 'usergroups' ? : ''} + {params.subpage == 'roles' ? : ''} ) diff --git a/apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx b/apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx new file mode 100644 index 00000000..fc13b174 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx @@ -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 ( + <> +
+
+
+

Manage Roles & Permissions

+

+ {' '} + Roles define what users can do within your organization. Create custom roles with specific permissions for different user types.{' '} +

+
+ + {/* Mobile view - Cards */} +
+ {roles?.map((role: any) => { + const isSystem = isSystemRole(role) + return ( +
+
+
+ + {role.name} + {isSystem && ( + + + System-wide + + )} +
+ + {getRightsSummary(role.rights)} + +
+

{role.description || 'No description'}

+
+ {!isSystem ? ( + <> + + handleEditRoleModal(role) + } + minHeight="lg" + minWidth='xl' + customWidth="max-w-7xl" + dialogContent={ + + } + dialogTitle="Edit Role" + dialogDescription={ + 'Edit the role permissions and details' + } + dialogTrigger={ + + } + /> + + + Delete + + } + functionToExecute={() => { + deleteRoleUI(role.id) + }} + status="warning" + /> + + ) : null} +
+
+ ) + })} +
+ + {/* Desktop view - Table */} +
+ + + + + + + + + + <> + + {roles?.map((role: any) => { + const isSystem = isSystemRole(role) + return ( + + + + + + + ) + })} + + +
Role NameDescriptionPermissionsActions
+
+ + {role.name} + {isSystem && ( + + + System-wide + + )} +
+
{role.description || 'No description'} + + {getRightsSummary(role.rights)} + + +
+ {!isSystem ? ( + <> + + handleEditRoleModal(role) + } + minHeight="lg" + minWidth='xl' + customWidth="max-w-7xl" + dialogContent={ + + } + dialogTitle="Edit Role" + dialogDescription={ + 'Edit the role permissions and details' + } + dialogTrigger={ + + } + /> + + + Delete + + } + functionToExecute={() => { + deleteRoleUI(role.id) + }} + status="warning" + /> + + ) : null} +
+
+
+ +
+ setCreateRoleModal(!createRoleModal)} + minHeight="no-min" + minWidth='xl' + customWidth="max-w-7xl" + dialogContent={ + + } + dialogTitle="Create a Role" + dialogDescription={ + 'Create a new role with specific permissions' + } + dialogTrigger={ + + } + /> +
+
+ + ) +} + +export default OrgRoles \ No newline at end of file diff --git a/apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx b/apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx new file mode 100644 index 00000000..bb1ebac1 --- /dev/null +++ b/apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx @@ -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(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 ( +
+
+
+ +

{title}

+
+ +
+
+ {permissions.map((permission) => ( + + ))} +
+
+ ) + } + + return ( +
+ +
+
+ + + + + + + + + + +