Merge pull request #500 from learnhouse/feat/rbac-improvements

RBAC Improvements
This commit is contained in:
Badr B. 2025-08-09 22:46:49 +02:00 committed by GitHub
commit 6a13703560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 5980 additions and 2237 deletions

132
.dockerignore Normal file
View file

@ -0,0 +1,132 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Production builds
.next
out
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
.pytest_cache/
.coverage
htmlcov/
.tox/
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Database
*.db
*.sqlite3
# Media files (can be large)
uploads/
media/
# Documentation
README.md
docs/
*.md

View file

@ -1,5 +1,5 @@
# Base image # Base image for Python backend
FROM python:3.12.3-slim-bookworm as base FROM python:3.12.3-slim-bookworm AS base
# Install Nginx, curl, and build-essential # Install Nginx, curl, and build-essential
RUN apt update && apt install -y nginx curl build-essential \ RUN apt update && apt install -y nginx curl build-essential \
@ -10,32 +10,80 @@ RUN apt update && apt install -y nginx curl build-essential \
# Install Node tools # Install Node tools
RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& npm install -g corepack pm2 && npm install -g pm2
# Frontend Build # Frontend Build - Using Node.js Alpine for better performance
FROM base AS deps FROM node:22-alpine AS frontend-base
# Install dependencies only when needed
FROM frontend-base AS frontend-deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY apps/web/package.json apps/web/pnpm-lock.yaml* ./
RUN \
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM frontend-base AS frontend-builder
WORKDIR /app
COPY --from=frontend-deps /app/node_modules ./node_modules
COPY apps/web .
# Set environment variables for the build
ENV NEXT_PUBLIC_LEARNHOUSE_API_URL=http://localhost/api/v1/ ENV NEXT_PUBLIC_LEARNHOUSE_API_URL=http://localhost/api/v1/
ENV NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL=http://localhost/ ENV NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL=http://localhost/
ENV NEXT_PUBLIC_LEARNHOUSE_DOMAIN=localhost ENV NEXT_PUBLIC_LEARNHOUSE_DOMAIN=localhost
WORKDIR /app/web # Next.js collects completely anonymous telemetry data about general usage.
COPY ./apps/web/package.json ./apps/web/pnpm-lock.yaml* ./ # Learn more here: https://nextjs.org/telemetry
COPY ./apps/web /app/web # Uncomment the following line in case you want to disable telemetry during the build.
RUN rm -f .env* # ENV NEXT_TELEMETRY_DISABLED 1
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Final image # Remove .env files from the final image
FROM base as runner # This is a good practice to avoid leaking sensitive data
RUN addgroup --system --gid 1001 system \ # Learn more about it in the Next.js documentation: https://nextjs.org/docs/basic-features/environment-variables
&& adduser --system --uid 1001 app \ RUN rm -f .env*
&& mkdir .next \
&& chown app:system .next RUN \
COPY --from=deps /app/web/public ./app/web/public if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
COPY --from=deps --chown=app:system /app/web/.next/standalone ./app/web/ else echo "Lockfile not found." && exit 1; \
COPY --from=deps --chown=app:system /app/web/.next/static ./app/web/.next/static fi
# Production image, copy all the files and run next
FROM frontend-base AS frontend-runner
WORKDIR /app
# Install curl
RUN apk add --no-cache curl
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=frontend-builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Final image combining frontend and backend
FROM base AS runner
# Copy the frontend standalone build
COPY --from=frontend-runner /app /app/web
# Backend Build # Backend Build
WORKDIR /app/api WORKDIR /app/api
@ -51,4 +99,5 @@ WORKDIR /app
COPY ./extra/nginx.conf /etc/nginx/conf.d/default.conf COPY ./extra/nginx.conf /etc/nginx/conf.d/default.conf
ENV PORT=8000 LEARNHOUSE_PORT=9000 HOSTNAME=0.0.0.0 ENV PORT=8000 LEARNHOUSE_PORT=9000 HOSTNAME=0.0.0.0
COPY ./extra/start.sh /app/start.sh COPY ./extra/start.sh /app/start.sh
CMD ["sh", "start.sh"] RUN chmod +x /app/start.sh
CMD ["sh", "/app/start.sh"]

View file

@ -11,8 +11,6 @@ from fastapi_jwt_auth.exceptions import AuthJWTException
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
# from src.services.mocks.initial import create_initial_data
######################## ########################
# Pre-Alpha Version 0.1.0 # Pre-Alpha Version 0.1.0
# Author: @swve # Author: @swve
@ -39,8 +37,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
logfire.configure(console=False, service_name=learnhouse_config.site_name,) # Only enable logfire if explicitly configured
logfire.instrument_fastapi(app) if learnhouse_config.general_config.logfire_enabled:
logfire.configure(console=False, service_name=learnhouse_config.site_name,)
logfire.instrument_fastapi(app)
# Instrument database after logfire is configured
from src.core.events.database import engine
logfire.instrument_sqlalchemy(engine=engine)
# Gzip Middleware (will add brotli later) # Gzip Middleware (will add brotli later)
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)

View file

@ -49,6 +49,8 @@ def install(
email="", email="",
logo_image="", logo_image="",
thumbnail_image="", thumbnail_image="",
about="",
label="",
) )
install_create_organization(org, db_session) install_create_organization(org, db_session)
print("Default organization created ✅") print("Default organization created ✅")
@ -91,6 +93,8 @@ def install(
email="", email="",
logo_image="", logo_image="",
thumbnail_image="", thumbnail_image="",
about="",
label="",
) )
install_create_organization(org, db_session) install_create_organization(org, db_session)
print(orgname + " Organization created ✅") print(orgname + " Organization created ✅")

View file

@ -12,6 +12,7 @@ class CookieConfig(BaseModel):
class GeneralConfig(BaseModel): class GeneralConfig(BaseModel):
development_mode: bool development_mode: bool
install_mode: bool install_mode: bool
logfire_enabled: bool
class SecurityConfig(BaseModel): class SecurityConfig(BaseModel):
@ -118,6 +119,13 @@ def get_learnhouse_config() -> LearnHouseConfig:
else yaml_config.get("general", {}).get("install_mode") else yaml_config.get("general", {}).get("install_mode")
) )
# Logfire config
env_logfire_enabled = os.environ.get("LEARNHOUSE_LOGFIRE_ENABLED", "None")
logfire_enabled = (
env_logfire_enabled.lower() == "true" if env_logfire_enabled != "None"
else yaml_config.get("general", {}).get("logfire_enabled", False)
)
# Security Config # Security Config
env_auth_jwt_secret_key = os.environ.get("LEARNHOUSE_AUTH_JWT_SECRET_KEY") env_auth_jwt_secret_key = os.environ.get("LEARNHOUSE_AUTH_JWT_SECRET_KEY")
auth_jwt_secret_key = env_auth_jwt_secret_key or yaml_config.get( auth_jwt_secret_key = env_auth_jwt_secret_key or yaml_config.get(
@ -295,7 +303,9 @@ def get_learnhouse_config() -> LearnHouseConfig:
site_description=site_description, site_description=site_description,
contact_email=contact_email, contact_email=contact_email,
general_config=GeneralConfig( general_config=GeneralConfig(
development_mode=bool(development_mode), install_mode=bool(install_mode) development_mode=bool(development_mode),
install_mode=bool(install_mode),
logfire_enabled=bool(logfire_enabled)
), ),
hosting_config=hosting_config, hosting_config=hosting_config,
database_config=database_config, database_config=database_config,

View file

@ -7,6 +7,7 @@ contact_email: hi@learnhouse.app
general: general:
development_mode: true development_mode: true
install_mode: true install_mode: true
logfire_enabled: false
security: security:
auth_jwt_secret_key: secret auth_jwt_secret_key: secret

View file

@ -1,5 +1,4 @@
import logging import logging
import logfire
import os import os
import importlib import importlib
from config.config import get_learnhouse_config from config.config import get_learnhouse_config
@ -58,7 +57,7 @@ else:
# Only create tables if not in test mode (tests will handle this themselves) # Only create tables if not in test mode (tests will handle this themselves)
if not is_testing: if not is_testing:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
logfire.instrument_sqlalchemy(engine=engine) # Note: logfire instrumentation will be handled in app.py after configuration
async def connect_to_db(app: FastAPI): async def connect_to_db(app: FastAPI):
app.db_engine = engine # type: ignore app.db_engine = engine # type: ignore

View file

@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Field, SQLModel, JSON, Column from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
from src.db.roles import RoleRead from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig from src.db.organization_config import OrganizationConfig

View file

@ -16,14 +16,36 @@ class Permission(BaseModel):
return getattr(self, item) return getattr(self, item)
class PermissionsWithOwn(BaseModel):
action_create: bool
action_read: bool
action_read_own: bool
action_update: bool
action_update_own: bool
action_delete: bool
action_delete_own: bool
def __getitem__(self, item):
return getattr(self, item)
class DashboardPermission(BaseModel):
action_access: bool
def __getitem__(self, item):
return getattr(self, item)
class Rights(BaseModel): class Rights(BaseModel):
courses: Permission courses: PermissionsWithOwn
users: Permission users: Permission
usergroups : Permission usergroups : Permission
collections: Permission collections: Permission
organizations: Permission organizations: Permission
coursechapters: Permission coursechapters: Permission
activities: Permission activities: Permission
roles: Permission
dashboard: DashboardPermission
def __getitem__(self, item): def __getitem__(self, item):
return getattr(self, item) return getattr(self, item)

View file

@ -26,6 +26,7 @@ from src.services.courses.courses import (
delete_course, delete_course,
update_course_thumbnail, update_course_thumbnail,
search_courses, search_courses,
get_course_user_rights,
) )
from src.services.courses.updates import ( from src.services.courses.updates import (
create_update, create_update,
@ -358,12 +359,94 @@ async def api_remove_bulk_course_contributors(
): ):
""" """
Remove multiple contributors from a course by their usernames Remove multiple contributors from a course by their usernames
Only administrators can perform this action
""" """
return await remove_bulk_course_contributors( return await remove_bulk_course_contributors(
request, request, course_uuid, usernames, current_user, db_session
course_uuid,
usernames,
current_user,
db_session
) )
@router.get("/{course_uuid}/rights")
async def api_get_course_user_rights(
request: Request,
course_uuid: str,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
) -> dict:
"""
Get detailed user rights for a specific course.
This endpoint returns comprehensive rights information that can be used
by the UI to enable/disable features based on user permissions.
**Response Structure:**
```json
{
"course_uuid": "course_123",
"user_id": 456,
"is_anonymous": false,
"permissions": {
"read": true,
"create": false,
"update": true,
"delete": false,
"create_content": true,
"update_content": true,
"delete_content": true,
"manage_contributors": true,
"manage_access": true,
"grade_assignments": true,
"mark_activities_done": true,
"create_certifications": true
},
"ownership": {
"is_owner": true,
"is_creator": true,
"is_maintainer": false,
"is_contributor": false,
"authorship_status": "ACTIVE"
},
"roles": {
"is_admin": false,
"is_maintainer_role": false,
"is_instructor": true,
"is_user": true
}
}
```
**Permissions Explained:**
- `read`: Can read the course content
- `create`: Can create new courses (instructor role or higher)
- `update`: Can update course settings (title, description, etc.)
- `delete`: Can delete the course
- `create_content`: Can create activities, assignments, chapters, etc.
- `update_content`: Can update course content
- `delete_content`: Can delete course content
- `manage_contributors`: Can add/remove contributors
- `manage_access`: Can change course access settings (public, open_to_contributors)
- `grade_assignments`: Can grade student assignments
- `mark_activities_done`: Can mark activities as done for other users
- `create_certifications`: Can create course certifications
**Ownership Information:**
- `is_owner`: Is course owner (CREATOR, MAINTAINER, or CONTRIBUTOR)
- `is_creator`: Is course creator
- `is_maintainer`: Is course maintainer
- `is_contributor`: Is course contributor
- `authorship_status`: Current authorship status (ACTIVE, PENDING, INACTIVE)
**Role Information:**
- `is_admin`: Has admin role (role 1)
- `is_maintainer_role`: Has maintainer role (role 2)
- `is_instructor`: Has instructor role (role 3)
- `is_user`: Has basic user role (role 4)
**Security Notes:**
- Returns rights based on course ownership and user roles
- Safe to expose to UI as it only returns permission information
- Anonymous users can only read public courses
- All permissions are calculated based on current user context
"""
return await get_course_user_rights(request, course_uuid, current_user, db_session)

View file

@ -1,28 +1,45 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, HTTPException
from sqlmodel import Session from sqlmodel import Session
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.db.roles import RoleCreate, RoleRead, RoleUpdate from src.db.roles import RoleCreate, RoleRead, RoleUpdate
from src.security.auth import get_current_user 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 src.db.users import PublicUser
from typing import List
router = APIRouter() router = APIRouter()
@router.post("/") @router.post("/org/{org_id}")
async def api_create_role( async def api_create_role(
request: Request, request: Request,
org_id: int,
role_object: RoleCreate, role_object: RoleCreate,
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
)-> RoleRead: )-> 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) 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}") @router.get("/{role_id}")
async def api_get_role( async def api_get_role(
request: Request, request: Request,
@ -39,6 +56,7 @@ async def api_get_role(
@router.put("/{role_id}") @router.put("/{role_id}")
async def api_update_role( async def api_update_role(
request: Request, request: Request,
role_id: str,
role_object: RoleUpdate, role_object: RoleUpdate,
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
@ -46,6 +64,16 @@ async def api_update_role(
""" """
Update role by role_id 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) return await update_role(request, db_session, role_object, current_user)

View file

@ -0,0 +1,410 @@
"""
SECURITY DOCUMENTATION FOR COURSES RBAC SYSTEM
This module provides unified RBAC (Role-Based Access Control) checks for all courses-related operations.
SECURITY MEASURES IMPLEMENTED:
1. COURSE OWNERSHIP REQUIREMENTS:
- All non-read operations (create, update, delete) require course ownership
- Course ownership is determined by ResourceAuthor table with ACTIVE status
- Valid ownership roles: CREATOR, MAINTAINER, CONTRIBUTOR
- Admin/maintainer roles are also accepted for course operations
2. COURSE CREATION VS COURSE CONTENT CREATION:
- COURSE CREATION: Allow if user has instructor role (3) or higher
- COURSE CONTENT CREATION (activities, assignments, chapters, etc.): Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- This distinction allows instructors to create courses but prevents them from creating content in courses they don't own
3. STRICT ACCESS CONTROLS:
- Activities: Require course ownership for all non-read operations
- Assignments: Require course ownership for all non-read operations
- Chapters: Require course ownership for all non-read operations
- Certifications: Require course ownership for all non-read operations
- Collections: Use organization-level permissions
4. GRADING AND SUBMISSION SECURITY:
- Only course owners or instructors can grade assignments
- Users can only submit their own work
- Users cannot update grades unless they are instructors
- Users can only update their own submissions
5. CERTIFICATE SECURITY:
- Certificates can only be created by course owners or instructors
- System-generated certificates (from course completion) are properly secured
- Certificate creation requires proper RBAC checks
6. ACTIVITY MARKING SECURITY:
- Only course owners or instructors can mark activities as done for other users
- Users can only mark their own activities as done
7. COLLECTION SECURITY:
- Users can only add courses to collections if they have read access to those courses
- Collection operations require appropriate organization-level permissions
8. ANONYMOUS USER HANDLING:
- Anonymous users can only read public courses
- All non-read operations require authentication
9. ERROR HANDLING:
- Clear error messages for security violations
- Proper HTTP status codes (401, 403, 404)
- Comprehensive logging of security events
10. COURSE ACCESS MANAGEMENT SECURITY:
- Sensitive fields (public, open_to_contributors) require additional validation
- Only course owners (CREATOR, MAINTAINER) or admins can change access settings
- Course creation requires proper organization-level permissions
- Course updates require course ownership or admin role
11. CONTRIBUTOR MANAGEMENT SECURITY:
- Only course owners (CREATOR, MAINTAINER) or admins can add/remove contributors
- Only course owners (CREATOR, MAINTAINER) or admins can update contributor roles
- Cannot modify the role of the course creator
- Contributor applications are created with PENDING status
- Only course owners or admins can approve contributor applications
SECURITY BEST PRACTICES:
- Always check course ownership before allowing modifications
- Validate user permissions at multiple levels
- Use proper RBAC checks for all operations
- Implement principle of least privilege
- Provide clear error messages for security violations
- Log security events for audit purposes
- Additional validation for sensitive access control fields
- Strict ownership requirements for contributor management
- Distinguish between course creation and course content creation permissions
CRITICAL SECURITY FIXES:
- Fixed: Users could create certifications for courses they don't own
- Fixed: Users could grade assignments without proper permissions
- Fixed: Users could mark activities as done for other users without permissions
- Fixed: Collections could be created with courses the user doesn't have access to
- Fixed: Assignment submissions could be modified by unauthorized users
- Fixed: Users could change course access settings (public, open_to_contributors) without proper permissions
- Fixed: Users could add/remove contributors from courses they don't own
- Fixed: Users could update contributor roles without course ownership
- Fixed: Course creation used hardcoded RBAC check
- Fixed: Contributor management used permissive RBAC checks instead of strict ownership requirements
- Fixed: Instructors could create content in courses they don't own (now they can only create courses)
"""
from typing import Literal
from fastapi import HTTPException, Request, status
from sqlmodel import Session, select
from src.db.users import AnonymousUser, PublicUser
from src.db.courses.courses import Course
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
authorization_verify_based_on_org_admin_status,
)
async def courses_rbac_check(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
require_course_ownership: bool = False,
) -> bool:
"""
Unified RBAC check for courses-related operations.
SECURITY NOTES:
- READ operations: Allow if user has read access to the course (public courses or user has permissions)
- COURSE CREATION: Allow if user has instructor role (3) or higher
- COURSE CONTENT CREATION (activities, assignments, chapters, etc.): Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- UPDATE/DELETE operations: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- Course ownership is determined by ResourceAuthor table with ACTIVE status
- Admin/maintainer roles are checked via authorization_verify_based_on_org_admin_status
Args:
request: FastAPI request object
course_uuid: UUID of the course (or "course_x" for course creation)
current_user: Current user (PublicUser or AnonymousUser)
action: Action to perform (create, read, update, delete)
db_session: Database session
require_course_ownership: If True, requires course ownership for non-read actions
Returns:
bool: True if authorized, raises HTTPException otherwise
Raises:
HTTPException: 403 Forbidden if user lacks required permissions
HTTPException: 401 Unauthorized if user is anonymous for non-read actions
"""
if action == "read":
if current_user.id == 0: # Anonymous user
return await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
else:
return await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
else:
# For non-read actions, proceed with strict RBAC checks
await authorization_verify_if_user_is_anon(current_user.id)
# SECURITY: Special handling for course creation vs course content creation
if action == "create" and course_uuid == "course_x":
# This is course creation - allow instructors (role 3) or higher
# Check if user has instructor role or higher
from src.security.rbac.rbac import authorization_verify_based_on_roles
has_create_permission = await authorization_verify_based_on_roles(
request, current_user.id, "create", "course_x", db_session
)
if has_create_permission:
return True
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You must have instructor role or higher to create courses",
)
# SECURITY: For course content creation and other operations, require course ownership
# This prevents users without course ownership from creating/modifying course content
if require_course_ownership or action in ["create", "update", "delete"]:
# Check if user is course owner (CREATOR, MAINTAINER, or CONTRIBUTOR)
statement = select(ResourceAuthor).where(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == current_user.id
)
resource_author = db_session.exec(statement).first()
is_course_owner = False
if resource_author:
if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or
(resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or
(resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \
resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
is_course_owner = True
# Check if user has admin or maintainer role
is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status(
request, current_user.id, action, course_uuid, db_session
)
# SECURITY: For creating, updating, and deleting course content, user MUST be either:
# 1. Course owner (CREATOR, MAINTAINER, or CONTRIBUTOR with ACTIVE status)
# 2. Admin or maintainer role
# General role permissions are NOT sufficient for these actions
if not (is_course_owner or is_admin_or_maintainer):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be the course owner (CREATOR, MAINTAINER, or CONTRIBUTOR) or have admin/maintainer role to {action} in this course",
)
return True
else:
# For other actions, use the existing RBAC check
return await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
)
async def courses_rbac_check_with_course_lookup(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
require_course_ownership: bool = False,
) -> Course:
"""
Unified RBAC check for courses-related operations with course lookup.
SECURITY NOTES:
- First validates that the course exists
- Then performs RBAC check using courses_rbac_check
- Returns the course object if authorized
Args:
request: FastAPI request object
course_uuid: UUID of the course
current_user: Current user (PublicUser or AnonymousUser)
action: Action to perform (create, read, update, delete)
db_session: Database session
require_course_ownership: If True, requires course ownership for non-read actions
Returns:
Course: The course object if authorized, raises HTTPException otherwise
Raises:
HTTPException: 404 Not Found if course doesn't exist
HTTPException: 403 Forbidden if user lacks required permissions
"""
# First check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Perform RBAC check
await courses_rbac_check(
request, course_uuid, current_user, action, db_session, require_course_ownership
)
return course
async def courses_rbac_check_for_activities(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
) -> bool:
"""
Specialized RBAC check for activities that requires course ownership for non-read actions.
SECURITY NOTES:
- Activities are core course content and require strict ownership controls
- READ: Allow if user has read access to the course
- CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- This prevents unauthorized users from creating/modifying course activities
- Instructors can create courses but cannot create activities in courses they don't own
"""
return await courses_rbac_check(
request, course_uuid, current_user, action, db_session, require_course_ownership=True
)
async def courses_rbac_check_for_assignments(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
) -> bool:
"""
Specialized RBAC check for assignments that requires course ownership for non-read actions.
SECURITY NOTES:
- Assignments are course content and require strict ownership controls
- READ: Allow if user has read access to the course
- CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- This prevents unauthorized users from creating/modifying course assignments
- Instructors can create courses but cannot create assignments in courses they don't own
"""
return await courses_rbac_check(
request, course_uuid, current_user, action, db_session, require_course_ownership=True
)
async def courses_rbac_check_for_chapters(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
) -> bool:
"""
Specialized RBAC check for chapters that requires course ownership for non-read actions.
SECURITY NOTES:
- Chapters are course structure and require strict ownership controls
- READ: Allow if user has read access to the course
- CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- This prevents unauthorized users from creating/modifying course chapters
- Instructors can create courses but cannot create chapters in courses they don't own
"""
return await courses_rbac_check(
request, course_uuid, current_user, action, db_session, require_course_ownership=True
)
async def courses_rbac_check_for_certifications(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
) -> bool:
"""
Specialized RBAC check for certifications that requires course ownership for non-read actions.
SECURITY NOTES:
- Certifications are course credentials and require strict ownership controls
- READ: Allow if user has read access to the course
- CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role
- This prevents unauthorized users from creating/modifying course certifications
- CRITICAL: Without this check, users could create certifications for courses they don't own
- Instructors can create courses but cannot create certifications in courses they don't own
"""
return await courses_rbac_check(
request, course_uuid, current_user, action, db_session, require_course_ownership=True
)
async def courses_rbac_check_for_collections(
request: Request,
collection_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
) -> bool:
"""
Specialized RBAC check for collections.
SECURITY NOTES:
- Collections are course groupings and require appropriate access controls
- READ: Allow if collection is public or user has read access
- CREATE/UPDATE/DELETE: Require appropriate permissions based on collection ownership
- Collections may have different ownership models than courses
Args:
request: FastAPI request object
collection_uuid: UUID of the collection
current_user: Current user (PublicUser or AnonymousUser)
action: Action to perform (create, read, update, delete)
db_session: Database session
Returns:
bool: True if authorized, raises HTTPException otherwise
"""
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, collection_uuid, action, db_session
)
if res == False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You are not allowed to read this collection",
)
return res
else:
return await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, collection_uuid, db_session
)
else:
await authorization_verify_if_user_is_anon(current_user.id)
return await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
collection_uuid,
db_session,
)

View file

@ -7,7 +7,7 @@ from src.db.courses.courses import Course
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.db.roles import Role from src.db.roles import Role
from src.db.user_organizations import UserOrganization from src.db.user_organizations import UserOrganization
from src.security.rbac.utils import check_element_type from src.security.rbac.utils import check_element_type, check_course_permissions_with_own
# Tested and working # Tested and working
@ -106,14 +106,30 @@ async def authorization_verify_based_on_roles(
user_roles_in_organization_and_standard_roles = db_session.exec(statement).all() user_roles_in_organization_and_standard_roles = db_session.exec(statement).all()
# Check if user is the author of the resource for "own" permissions
is_author = False
if action in ["update", "delete", "read"]:
is_author = await authorization_verify_if_user_is_author(
request, user_id, action, element_uuid, db_session
)
# Check all roles until we find one that grants the permission # Check all roles until we find one that grants the permission
for role in user_roles_in_organization_and_standard_roles: for role in user_roles_in_organization_and_standard_roles:
role = Role.model_validate(role) role = Role.model_validate(role)
if role.rights: if role.rights:
rights = role.rights rights = role.rights
element_rights = getattr(rights, element_type, None) element_rights = getattr(rights, element_type, None)
if element_rights and getattr(element_rights, f"action_{action}", False): if element_rights:
return True # Special handling for courses with PermissionsWithOwn
if element_type == "courses":
if await check_course_permissions_with_own(element_rights, action, is_author):
return True
else:
# For non-course resources, only check general permissions
# (regular Permission class no longer has "own" permissions)
if getattr(element_rights, f"action_{action}", False):
return True
# If we get here, no role granted the permission # If we get here, no role granted the permission
return False return False

View file

@ -30,6 +30,38 @@ async def check_element_type(element_uuid):
) )
async def check_course_permissions_with_own(
element_rights,
action: str,
is_author: bool = False
) -> bool:
"""
Check course-specific permissions including "own" permissions.
Args:
element_rights: The rights object for courses (PermissionsWithOwn)
action: The action to check ("read", "update", "delete", "create")
is_author: Whether the user is the author of the course
Returns:
bool: True if permission is granted, False otherwise
"""
if not element_rights:
return False
# Check for general permission first
if getattr(element_rights, f"action_{action}", False):
return True
# Check for "own" permission if user is the author
if is_author:
own_action = f"action_{action}_own"
if getattr(element_rights, own_action, False):
return True
return False
async def get_singular_form_of_element(element_uuid): async def get_singular_form_of_element(element_uuid):
element_type = await check_element_type(element_uuid) element_type = await check_element_type(element_uuid)

View file

@ -1,12 +1,6 @@
from typing import Literal
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.courses.chapters import Chapter from src.db.courses.chapters import Chapter
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
from src.db.courses.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate from src.db.courses.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate
from src.db.courses.chapter_activities import ChapterActivity from src.db.courses.chapter_activities import ChapterActivity
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser
@ -15,6 +9,7 @@ from uuid import uuid4
from datetime import datetime from datetime import datetime
from src.services.payments.payments_access import check_activity_paid_access from src.services.payments.payments_access import check_activity_paid_access
from src.security.courses_security import courses_rbac_check_for_activities
#################################################### ####################################################
@ -49,7 +44,7 @@ async def create_activity(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "create", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session)
# Create Activity # Create Activity
activity = Activity(**activity_object.model_dump()) activity = Activity(**activity_object.model_dump())
@ -118,7 +113,7 @@ async def get_activity(
activity, course = result activity, course = result
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session)
# Paid access check # Paid access check
has_paid_access = await check_activity_paid_access( has_paid_access = await check_activity_paid_access(
@ -156,7 +151,7 @@ async def get_activityby_id(
activity, course = result activity, course = result
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session)
return ActivityRead.model_validate(activity) return ActivityRead.model_validate(activity)
@ -187,7 +182,7 @@ async def update_activity(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(activity_object).items(): for var, value in vars(activity_object).items():
@ -228,7 +223,7 @@ async def delete_activity(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "delete", db_session)
# Delete activity from chapter # Delete activity from chapter
statement = select(ChapterActivity).where( statement = select(ChapterActivity).where(
@ -296,46 +291,8 @@ async def get_activities(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session)
activities = [ActivityRead.model_validate(activity) for activity in activities] activities = [ActivityRead.model_validate(activity) for activity in activities]
return activities return activities
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
element_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, element_uuid, action, db_session
)
return res
else:
res = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, element_uuid, db_session
)
return res
else:
# For non-read actions, proceed with regular RBAC checks
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
element_uuid,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -1,5 +1,4 @@
from datetime import datetime from datetime import datetime
from typing import Literal
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException, Request, UploadFile from fastapi import HTTPException, Request, UploadFile
from sqlmodel import Session, select from sqlmodel import Session, select
@ -34,9 +33,6 @@ from src.security.features_utils.usage import (
increase_feature_usage, increase_feature_usage,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
authorization_verify_based_on_roles, authorization_verify_based_on_roles,
) )
from src.services.courses.activities.uploads.sub_file import upload_submission_file from src.services.courses.activities.uploads.sub_file import upload_submission_file
@ -45,6 +41,7 @@ from src.services.courses.activities.uploads.tasks_ref_files import (
) )
from src.services.trail.trail import check_trail_presence from src.services.trail.trail import check_trail_presence
from src.services.courses.certifications import check_course_completion_and_create_certificate from src.services.courses.certifications import check_course_completion_and_create_certificate
from src.security.courses_security import courses_rbac_check_for_assignments
## > Assignments CRUD ## > Assignments CRUD
@ -66,7 +63,7 @@ async def create_assignment(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "create", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "create", db_session)
# Usage check # Usage check
check_limits_with_usage("assignments", course.org_id, db_session) check_limits_with_usage("assignments", course.org_id, db_session)
@ -118,7 +115,7 @@ async def read_assignment(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment read # return assignment read
return AssignmentRead.model_validate(assignment) return AssignmentRead.model_validate(assignment)
@ -161,7 +158,7 @@ async def read_assignment_from_activity_uuid(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment read # return assignment read
return AssignmentRead.model_validate(assignment) return AssignmentRead.model_validate(assignment)
@ -195,7 +192,7 @@ async def update_assignment(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(assignment_object).items(): for var, value in vars(assignment_object).items():
@ -239,7 +236,7 @@ async def delete_assignment(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage # Feature usage
decrease_feature_usage("assignments", course.org_id, db_session) decrease_feature_usage("assignments", course.org_id, db_session)
@ -289,7 +286,7 @@ async def delete_assignment_from_activity_uuid(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage # Feature usage
decrease_feature_usage("assignments", course.org_id, db_session) decrease_feature_usage("assignments", course.org_id, db_session)
@ -333,7 +330,7 @@ async def create_assignment_task(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "create", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "create", db_session)
# Create Assignment Task # Create Assignment Task
assignment_task = AssignmentTask(**assignment_task_object.model_dump()) assignment_task = AssignmentTask(**assignment_task_object.model_dump())
@ -388,7 +385,7 @@ async def read_assignment_tasks(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment tasks read # return assignment tasks read
return [ return [
@ -436,7 +433,7 @@ async def read_assignment_task(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment task read # return assignment task read
return AssignmentTaskRead.model_validate(assignmenttask) return AssignmentTaskRead.model_validate(assignmenttask)
@ -490,7 +487,7 @@ async def put_assignment_task_reference_file(
org = db_session.exec(org_statement).first() org = db_session.exec(org_statement).first()
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
# Upload reference file # Upload reference file
if reference_file and reference_file.filename and activity and org: if reference_file and reference_file.filename and activity and org:
@ -568,7 +565,7 @@ async def put_assignment_task_submission_file(
org = db_session.exec(org_statement).first() org = db_session.exec(org_statement).first()
# RBAC check - only need read permission to submit files # RBAC check - only need read permission to submit files
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# Check if user is enrolled in the course # Check if user is enrolled in the course
if not await authorization_verify_based_on_roles(request, current_user.id, "read", course.course_uuid, db_session): if not await authorization_verify_based_on_roles(request, current_user.id, "read", course.course_uuid, db_session):
@ -633,7 +630,7 @@ async def update_assignment_task(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(assignment_task_object).items(): for var, value in vars(assignment_task_object).items():
@ -689,7 +686,7 @@ async def delete_assignment_task(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session)
# Delete Assignment Task # Delete Assignment Task
db_session.delete(assignment_task) db_session.delete(assignment_task)
@ -741,7 +738,7 @@ async def handle_assignment_task_submission(
detail="Course not found", detail="Course not found",
) )
# Check if user has instructor/admin permissions # SECURITY: Check if user has instructor/admin permissions for grading
is_instructor = await authorization_verify_based_on_roles(request, current_user.id, "update", course.course_uuid, db_session) is_instructor = await authorization_verify_based_on_roles(request, current_user.id, "update", course.course_uuid, db_session)
# For regular users, ensure they can only submit their own work # For regular users, ensure they can only submit their own work
@ -753,7 +750,7 @@ async def handle_assignment_task_submission(
detail="You must be enrolled in this course to submit assignments" detail="You must be enrolled in this course to submit assignments"
) )
# Regular users cannot update grades - only check if actual values are being set # SECURITY: Regular users cannot update grades - only check if actual values are being set
if (assignment_task_submission_object.grade is not None and assignment_task_submission_object.grade != 0) or \ if (assignment_task_submission_object.grade is not None and assignment_task_submission_object.grade != 0) or \
(assignment_task_submission_object.task_submission_grade_feedback is not None and assignment_task_submission_object.task_submission_grade_feedback != ""): (assignment_task_submission_object.task_submission_grade_feedback is not None and assignment_task_submission_object.task_submission_grade_feedback != ""):
raise HTTPException( raise HTTPException(
@ -762,10 +759,10 @@ async def handle_assignment_task_submission(
) )
# Only need read permission for submissions # Only need read permission for submissions
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
else: else:
# Instructors/admins need update permission to grade # SECURITY: Instructors/admins need update permission to grade
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
# Try to find existing submission if UUID is provided # Try to find existing submission if UUID is provided
assignment_task_submission = None assignment_task_submission = None
@ -777,7 +774,7 @@ async def handle_assignment_task_submission(
# If submission exists, update it # If submission exists, update it
if assignment_task_submission: if assignment_task_submission:
# For regular users, ensure they can only update their own submissions # SECURITY: For regular users, ensure they can only update their own submissions
if not is_instructor and assignment_task_submission.user_id != current_user.id: if not is_instructor and assignment_task_submission.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
@ -880,7 +877,7 @@ async def read_user_assignment_task_submissions(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment task submission read # return assignment task submission read
return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission)
@ -953,7 +950,7 @@ async def read_assignment_task_submissions(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment task submission read # return assignment task submission read
return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission)
@ -1012,7 +1009,7 @@ async def update_assignment_task_submission(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(assignment_task_submission_object).items(): for var, value in vars(assignment_task_submission_object).items():
@ -1081,7 +1078,7 @@ async def delete_assignment_task_submission(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session)
# Delete Assignment Task Submission # Delete Assignment Task Submission
db_session.delete(assignment_task_submission) db_session.delete(assignment_task_submission)
@ -1147,7 +1144,7 @@ async def create_assignment_submission(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# Create Assignment User Submission # Create Assignment User Submission
assignment_user_submission = AssignmentUserSubmission( assignment_user_submission = AssignmentUserSubmission(
@ -1280,7 +1277,7 @@ async def read_assignment_submissions(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment tasks read # return assignment tasks read
return [ return [
@ -1323,7 +1320,7 @@ async def read_user_assignment_submissions(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignment tasks read # return assignment tasks read
return [ return [
@ -1389,7 +1386,7 @@ async def update_assignment_submission(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(assignment_user_submission_object).items(): for var, value in vars(assignment_user_submission_object).items():
@ -1447,7 +1444,7 @@ async def delete_assignment_submission(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session)
# Delete Assignment User Submission # Delete Assignment User Submission
db_session.delete(assignment_user_submission) db_session.delete(assignment_user_submission)
@ -1464,7 +1461,7 @@ async def grade_assignment_submission(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
): ):
# SECURITY: This function should only be accessible by course owners or instructors
# Check if assignment exists # Check if assignment exists
statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid)
assignment = db_session.exec(statement).first() assignment = db_session.exec(statement).first()
@ -1484,7 +1481,8 @@ async def grade_assignment_submission(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "update", db_session) # SECURITY: Require course ownership or instructor role for grading
await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
# Check if assignment user submission exists # Check if assignment user submission exists
statement = select(AssignmentUserSubmission).where( statement = select(AssignmentUserSubmission).where(
@ -1602,6 +1600,7 @@ async def mark_activity_as_done_for_user(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
): ):
# SECURITY: This function should only be accessible by course owners or instructors
# Get Assignment # Get Assignment
statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid)
assignment = db_session.exec(statement).first() assignment = db_session.exec(statement).first()
@ -1625,7 +1624,8 @@ async def mark_activity_as_done_for_user(
detail="Course not found", detail="Course not found",
) )
await rbac_check(request, course.course_uuid, current_user, "update", db_session) # SECURITY: Require course ownership or instructor role for marking activities as done
await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session)
if not activity: if not activity:
raise HTTPException( raise HTTPException(
@ -1704,46 +1704,7 @@ async def get_assignments_from_course(
assignments.append(assignment) assignments.append(assignment)
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session)
# return assignments read # return assignments read
return [AssignmentRead.model_validate(assignment) for assignment in assignments] return [AssignmentRead.model_validate(assignment) for assignment in assignments]
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
return res
else:
res = (
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
)
return res
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -1,11 +1,6 @@
from typing import Literal
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.organizations import Organization from src.db.organizations import Organization
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.courses.chapters import Chapter from src.db.courses.chapters import Chapter
from src.db.courses.activities import ( from src.db.courses.activities import (
Activity, Activity,
@ -20,6 +15,7 @@ from src.services.courses.activities.uploads.pdfs import upload_pdf
from fastapi import HTTPException, status, UploadFile, Request from fastapi import HTTPException, status, UploadFile, Request
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from src.security.courses_security import courses_rbac_check_for_activities
async def create_documentpdf_activity( async def create_documentpdf_activity(
@ -30,9 +26,6 @@ async def create_documentpdf_activity(
db_session: Session, db_session: Session,
pdf_file: UploadFile | None = None, pdf_file: UploadFile | None = None,
): ):
# RBAC check
await rbac_check(request, "activity_x", current_user, "create", db_session)
# get chapter_id # get chapter_id
statement = select(Chapter).where(Chapter.id == chapter_id) statement = select(Chapter).where(Chapter.id == chapter_id)
chapter = db_session.exec(statement).first() chapter = db_session.exec(statement).first()
@ -52,6 +45,19 @@ async def create_documentpdf_activity(
detail="CourseChapter not found", detail="CourseChapter not found",
) )
# Get course_uuid for RBAC check
statement = select(Course).where(Course.id == coursechapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session)
# get org_id # get org_id
org_id = coursechapter.org_id org_id = coursechapter.org_id
@ -59,10 +65,6 @@ async def create_documentpdf_activity(
statement = select(Organization).where(Organization.id == coursechapter.org_id) statement = select(Organization).where(Organization.id == coursechapter.org_id)
organization = db_session.exec(statement).first() organization = db_session.exec(statement).first()
# Get course_uuid
statement = select(Course).where(Course.id == coursechapter.course_id)
course = db_session.exec(statement).first()
# create activity uuid # create activity uuid
activity_uuid = f"activity_{uuid4()}" activity_uuid = f"activity_{uuid4()}"
@ -94,9 +96,7 @@ async def create_documentpdf_activity(
content={ content={
"filename": "documentpdf." + pdf_format, "filename": "documentpdf." + pdf_format,
"activity_uuid": activity_uuid, "activity_uuid": activity_uuid,
}, },
published_version=1,
version=1,
org_id=org_id if org_id else 0, org_id=org_id if org_id else 0,
course_id=coursechapter.course_id, course_id=coursechapter.course_id,
activity_uuid=activity_uuid, activity_uuid=activity_uuid,
@ -121,7 +121,7 @@ async def create_documentpdf_activity(
) )
# upload pdf # upload pdf
if pdf_file: if pdf_file and organization and course:
# get pdffile format # get pdffile format
await upload_pdf( await upload_pdf(
pdf_file, pdf_file,
@ -136,27 +136,3 @@ async def create_documentpdf_activity(
db_session.refresh(activity_chapter) db_session.refresh(activity_chapter)
return ActivityRead.model_validate(activity) return ActivityRead.model_validate(activity)
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
course_id: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_id,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -5,10 +5,6 @@ from src.db.organizations import Organization
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.courses.chapters import Chapter from src.db.courses.chapters import Chapter
from src.db.courses.activities import ( from src.db.courses.activities import (
Activity, Activity,
@ -23,6 +19,7 @@ from src.services.courses.activities.uploads.videos import upload_video
from fastapi import HTTPException, status, UploadFile, Request from fastapi import HTTPException, status, UploadFile, Request
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from src.security.courses_security import courses_rbac_check_for_activities
async def create_video_activity( async def create_video_activity(
@ -34,9 +31,6 @@ async def create_video_activity(
video_file: UploadFile | None = None, video_file: UploadFile | None = None,
details: str = "{}", details: str = "{}",
): ):
# RBAC check
await rbac_check(request, "activity_x", current_user, "create", db_session)
# get chapter_id # get chapter_id
statement = select(Chapter).where(Chapter.id == chapter_id) statement = select(Chapter).where(Chapter.id == chapter_id)
chapter = db_session.exec(statement).first() chapter = db_session.exec(statement).first()
@ -59,14 +53,23 @@ async def create_video_activity(
detail="CourseChapter not found", detail="CourseChapter not found",
) )
# Get course_uuid for RBAC check
statement = select(Course).where(Course.id == coursechapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session)
# Get org_uuid # Get org_uuid
statement = select(Organization).where(Organization.id == coursechapter.org_id) statement = select(Organization).where(Organization.id == coursechapter.org_id)
organization = db_session.exec(statement).first() organization = db_session.exec(statement).first()
# Get course_uuid
statement = select(Course).where(Course.id == coursechapter.course_id)
course = db_session.exec(statement).first()
# generate activity_uuid # generate activity_uuid
activity_uuid = str(f"activity_{uuid4()}") activity_uuid = str(f"activity_{uuid4()}")
@ -99,13 +102,11 @@ async def create_video_activity(
activity_uuid=activity_uuid, activity_uuid=activity_uuid,
org_id=coursechapter.org_id, org_id=coursechapter.org_id,
course_id=coursechapter.course_id, course_id=coursechapter.course_id,
published_version=1,
content={ content={
"filename": "video." + video_format, "filename": "video." + video_format,
"activity_uuid": activity_uuid, "activity_uuid": activity_uuid,
}, },
details=details, details=details if isinstance(details, dict) else json.loads(details),
version=1,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
) )
@ -117,7 +118,7 @@ async def create_video_activity(
db_session.refresh(activity) db_session.refresh(activity)
# upload video # upload video
if video_file: if video_file and organization and course:
# get videofile format # get videofile format
await upload_video( await upload_video(
video_file, video_file,
@ -163,9 +164,6 @@ async def create_external_video_activity(
data: ExternalVideo, data: ExternalVideo,
db_session: Session, db_session: Session,
): ):
# RBAC check
await rbac_check(request, "activity_x", current_user, "create", db_session)
# get chapter_id # get chapter_id
statement = select(Chapter).where(Chapter.id == data.chapter_id) statement = select(Chapter).where(Chapter.id == data.chapter_id)
chapter = db_session.exec(statement).first() chapter = db_session.exec(statement).first()
@ -185,6 +183,19 @@ async def create_external_video_activity(
detail="CourseChapter not found", detail="CourseChapter not found",
) )
# Get course_uuid for RBAC check
statement = select(Course).where(Course.id == coursechapter.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# RBAC check
await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session)
# generate activity_uuid # generate activity_uuid
activity_uuid = str(f"activity_{uuid4()}") activity_uuid = str(f"activity_{uuid4()}")
@ -198,14 +209,12 @@ async def create_external_video_activity(
activity_uuid=activity_uuid, activity_uuid=activity_uuid,
course_id=coursechapter.course_id, course_id=coursechapter.course_id,
org_id=coursechapter.org_id, org_id=coursechapter.org_id,
published_version=1,
content={ content={
"uri": data.uri, "uri": data.uri,
"type": data.type, "type": data.type,
"activity_uuid": activity_uuid, "activity_uuid": activity_uuid,
}, },
details=details, details=details,
version=1,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
) )
@ -234,22 +243,4 @@ async def create_external_video_activity(
return ActivityRead.model_validate(activity) return ActivityRead.model_validate(activity)
async def rbac_check(
request: Request,
course_id: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_id,
db_session,
)
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -1,4 +1,4 @@
from typing import List, Literal from typing import List
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from sqlmodel import Session, select from sqlmodel import Session, select
@ -15,11 +15,7 @@ from src.db.courses.courses import Course
from src.db.courses.chapter_activities import ChapterActivity from src.db.courses.chapter_activities import ChapterActivity
from src.db.trail_steps import TrailStep from src.db.trail_steps import TrailStep
from src.db.users import PublicUser, AnonymousUser from src.db.users import PublicUser, AnonymousUser
from src.security.rbac.rbac import ( from src.security.courses_security import courses_rbac_check_for_certifications
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
#################################################### ####################################################
@ -46,7 +42,7 @@ async def create_certification(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "create", db_session) await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "create", db_session)
# Create certification # Create certification
certification = Certifications( certification = Certifications(
@ -93,7 +89,7 @@ async def get_certification(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "read", db_session)
return CertificationRead(**certification.model_dump()) return CertificationRead(**certification.model_dump())
@ -117,7 +113,7 @@ async def get_certifications_by_course(
) )
# RBAC check # RBAC check
await rbac_check(request, course_uuid, current_user, "read", db_session) await courses_rbac_check_for_certifications(request, course_uuid, current_user, "read", db_session)
# Get certifications for this course # Get certifications for this course
statement = select(Certifications).where(Certifications.course_id == course.id) statement = select(Certifications).where(Certifications.course_id == course.id)
@ -155,7 +151,7 @@ async def update_certification(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "update", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(certification_object).items(): for var, value in vars(certification_object).items():
@ -200,7 +196,7 @@ async def delete_certification(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "delete", db_session)
db_session.delete(certification) db_session.delete(certification)
db_session.commit() db_session.commit()
@ -218,8 +214,16 @@ async def create_certificate_user(
user_id: int, user_id: int,
certification_id: int, certification_id: int,
db_session: Session, db_session: Session,
current_user: PublicUser | AnonymousUser | None = None,
) -> CertificateUserRead: ) -> CertificateUserRead:
"""Create a certificate user link""" """
Create a certificate user link
SECURITY NOTES:
- This function should only be called by authorized users (course owners, instructors, or system)
- When called from check_course_completion_and_create_certificate, it's a system operation
- When called directly, requires proper RBAC checks
"""
# Check if certification exists # Check if certification exists
statement = select(Certifications).where(Certifications.id == certification_id) statement = select(Certifications).where(Certifications.id == certification_id)
@ -231,6 +235,21 @@ async def create_certificate_user(
detail="Certification not found", detail="Certification not found",
) )
# SECURITY: If current_user is provided, perform RBAC check
if current_user:
# Get course for RBAC check
statement = select(Course).where(Course.id == certification.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Require course ownership or instructor role for creating certificates
await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "create", db_session)
# Check if certificate user already exists # Check if certificate user already exists
statement = select(CertificateUser).where( statement = select(CertificateUser).where(
CertificateUser.user_id == user_id, CertificateUser.user_id == user_id,
@ -316,7 +335,7 @@ async def get_user_certificates_for_course(
) )
# RBAC check # RBAC check
await rbac_check(request, course_uuid, current_user, "read", db_session) await courses_rbac_check_for_certifications(request, course_uuid, current_user, "read", db_session)
# Get all certifications for this course # Get all certifications for this course
statement = select(Certifications).where(Certifications.course_id == course.id) statement = select(Certifications).where(Certifications.course_id == course.id)
@ -357,7 +376,14 @@ async def check_course_completion_and_create_certificate(
course_id: int, course_id: int,
db_session: Session, db_session: Session,
) -> bool: ) -> bool:
"""Check if all activities in a course are completed and create certificate if so""" """
Check if all activities in a course are completed and create certificate if so
SECURITY NOTES:
- This function is called by the system when activities are completed
- It should only create certificates for users who have actually completed the course
- The function is called from mark_activity_as_done_for_user which already has RBAC checks
"""
# Get all activities in the course # Get all activities in the course
statement = select(ChapterActivity).where(ChapterActivity.course_id == course_id) statement = select(ChapterActivity).where(ChapterActivity.course_id == course_id)
@ -381,7 +407,8 @@ async def check_course_completion_and_create_certificate(
certification = db_session.exec(statement).first() certification = db_session.exec(statement).first()
if certification and certification.id: if certification and certification.id:
# Create certificate user link # SECURITY: Create certificate user link (system operation, no RBAC needed here)
# This is called from mark_activity_as_done_for_user which already has proper RBAC checks
try: try:
await create_certificate_user(request, user_id, certification.id, db_session) await create_certificate_user(request, user_id, certification.id, db_session)
return True return True
@ -506,36 +533,3 @@ async def get_all_user_certificates(
}) })
return result return result
####################################################
# RBAC Utils
####################################################
async def rbac_check(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
else:
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
)

View file

@ -1,13 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import List, Literal from typing import List
from uuid import uuid4 from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser, PublicUser
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
from src.db.courses.course_chapters import CourseChapter from src.db.courses.course_chapters import CourseChapter
from src.db.courses.activities import Activity, ActivityRead from src.db.courses.activities import Activity, ActivityRead
from src.db.courses.chapter_activities import ChapterActivity from src.db.courses.chapter_activities import ChapterActivity
@ -18,9 +13,9 @@ from src.db.courses.chapters import (
ChapterUpdate, ChapterUpdate,
ChapterUpdateOrder, ChapterUpdateOrder,
) )
from src.services.courses.courses import Course from src.db.courses.courses import Course
from src.services.users.users import PublicUser
from fastapi import HTTPException, status, Request from fastapi import HTTPException, status, Request
from src.security.courses_security import courses_rbac_check_for_chapters
#################################################### ####################################################
@ -42,7 +37,7 @@ async def create_chapter(
course = db_session.exec(statement).one() course = db_session.exec(statement).one()
# RBAC check # RBAC check
await rbac_check(request, "chapter_x", current_user, "create", db_session) await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "create", db_session)
# complete chapter object # complete chapter object
chapter.course_id = chapter_object.course_id chapter.course_id = chapter_object.course_id
@ -55,7 +50,7 @@ async def create_chapter(
statement = ( statement = (
select(CourseChapter) select(CourseChapter)
.where(CourseChapter.course_id == chapter.course_id) .where(CourseChapter.course_id == chapter.course_id)
.order_by(CourseChapter.order) .order_by(CourseChapter.order) # type: ignore
) )
course_chapters = db_session.exec(statement).all() course_chapters = db_session.exec(statement).all()
@ -122,14 +117,14 @@ async def get_chapter(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session)
# Get activities for this chapter # Get activities for this chapter
statement = ( statement = (
select(Activity) select(Activity)
.join(ChapterActivity, Activity.id == ChapterActivity.activity_id) .join(ChapterActivity, Activity.id == ChapterActivity.activity_id) # type: ignore
.where(ChapterActivity.chapter_id == chapter_id) .where(ChapterActivity.chapter_id == chapter_id)
.distinct(Activity.id) .distinct(Activity.id) # type: ignore
) )
activities = db_session.exec(statement).all() activities = db_session.exec(statement).all()
@ -158,7 +153,7 @@ async def update_chapter(
) )
# RBAC check # RBAC check
await rbac_check(request, chapter.chapter_uuid, current_user, "update", db_session) await courses_rbac_check_for_chapters(request, chapter.chapter_uuid, current_user, "update", db_session)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(chapter_object).items(): for var, value in vars(chapter_object).items():
@ -193,7 +188,7 @@ async def delete_chapter(
) )
# RBAC check # RBAC check
await rbac_check(request, chapter.chapter_uuid, current_user, "delete", db_session) await courses_rbac_check_for_chapters(request, chapter.chapter_uuid, current_user, "delete", db_session)
# Remove all linked chapter activities # Remove all linked chapter activities
statement = select(ChapterActivity).where(ChapterActivity.chapter_id == chapter.id) statement = select(ChapterActivity).where(ChapterActivity.chapter_id == chapter.id)
@ -224,26 +219,26 @@ async def get_course_chapters(
statement = ( statement = (
select(Chapter) select(Chapter)
.join(CourseChapter, Chapter.id == CourseChapter.chapter_id) .join(CourseChapter, Chapter.id == CourseChapter.chapter_id) # type: ignore
.where(CourseChapter.course_id == course_id) .where(CourseChapter.course_id == course_id)
.where(Chapter.course_id == course_id) .where(Chapter.course_id == course_id)
.order_by(CourseChapter.order) .order_by(CourseChapter.order) # type: ignore
.group_by(Chapter.id, CourseChapter.order) .group_by(Chapter.id, CourseChapter.order) # type: ignore
) )
chapters = db_session.exec(statement).all() chapters = db_session.exec(statement).all()
chapters = [ChapterRead(**chapter.model_dump(), activities=[]) for chapter in chapters] chapters = [ChapterRead(**chapter.model_dump(), activities=[]) for chapter in chapters]
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) # type: ignore await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session) # type: ignore
# Get activities for each chapter # Get activities for each chapter
for chapter in chapters: for chapter in chapters:
statement = ( statement = (
select(ChapterActivity) select(ChapterActivity)
.where(ChapterActivity.chapter_id == chapter.id) .where(ChapterActivity.chapter_id == chapter.id)
.order_by(ChapterActivity.order) .order_by(ChapterActivity.order) # type: ignore
.distinct(ChapterActivity.id, ChapterActivity.order) .distinct(ChapterActivity.id, ChapterActivity.order) # type: ignore
) )
chapter_activities = db_session.exec(statement).all() chapter_activities = db_session.exec(statement).all()
@ -251,7 +246,7 @@ async def get_course_chapters(
statement = ( statement = (
select(Activity) select(Activity)
.where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True) .where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True)
.distinct(Activity.id) .distinct(Activity.id) # type: ignore
) )
activity = db_session.exec(statement).first() activity = db_session.exec(statement).first()
@ -279,7 +274,7 @@ async def DEPRECEATED_get_course_chapters(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session)
chapters_in_db = await get_course_chapters(request, course.id, db_session, current_user) # type: ignore chapters_in_db = await get_course_chapters(request, course.id, db_session, current_user) # type: ignore
@ -306,9 +301,9 @@ async def DEPRECEATED_get_course_chapters(
activities_list = {} activities_list = {}
statement = ( statement = (
select(Activity) select(Activity)
.join(ChapterActivity, ChapterActivity.activity_id == Activity.id) .join(ChapterActivity, ChapterActivity.activity_id == Activity.id) # type: ignore
.where(ChapterActivity.activity_id == Activity.id) .where(ChapterActivity.activity_id == Activity.id)
.group_by(Activity.id) .group_by(Activity.id) # type: ignore
) )
activities_in_db = db_session.exec(statement).all() activities_in_db = db_session.exec(statement).all()
@ -324,10 +319,10 @@ async def DEPRECEATED_get_course_chapters(
# get chapter order # get chapter order
statement = ( statement = (
select(Chapter) select(Chapter)
.join(CourseChapter, CourseChapter.chapter_id == Chapter.id) .join(CourseChapter, CourseChapter.chapter_id == Chapter.id) # type: ignore
.where(CourseChapter.chapter_id == Chapter.id) .where(CourseChapter.chapter_id == Chapter.id)
.group_by(Chapter.id, CourseChapter.order) .group_by(Chapter.id, CourseChapter.order) # type: ignore
.order_by(CourseChapter.order) .order_by(CourseChapter.order) # type: ignore
) )
chapters_in_db = db_session.exec(statement).all() chapters_in_db = db_session.exec(statement).all()
@ -361,7 +356,7 @@ async def reorder_chapters_and_activities(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "update", db_session)
########### ###########
# Chapters # Chapters
@ -458,39 +453,3 @@ async def reorder_chapters_and_activities(
db_session.commit() db_session.commit()
return {"detail": "Chapters and activities reordered successfully"} return {"detail": "Chapters and activities reordered successfully"}
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
course_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
return res
else:
res = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
return res
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -1,13 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import List, Literal from typing import List
from uuid import uuid4 from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser, PublicUser
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
from src.db.collections import ( from src.db.collections import (
Collection, Collection,
CollectionCreate, CollectionCreate,
@ -16,8 +11,8 @@ from src.db.collections import (
) )
from src.db.collections_courses import CollectionCourse from src.db.collections_courses import CollectionCourse
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.services.users.users import PublicUser
from fastapi import HTTPException, status, Request from fastapi import HTTPException, status, Request
from src.security.courses_security import courses_rbac_check_for_collections
#################################################### ####################################################
@ -40,7 +35,7 @@ async def get_collection(
) )
# RBAC check # RBAC check
await rbac_check( await courses_rbac_check_for_collections(
request, collection.collection_uuid, current_user, "read", db_session request, collection.collection_uuid, current_user, "read", db_session
) )
@ -86,8 +81,10 @@ async def create_collection(
) -> CollectionRead: ) -> CollectionRead:
collection = Collection.model_validate(collection_object) collection = Collection.model_validate(collection_object)
# RBAC check # SECURITY: Check if user has permission to create collections in this organization
await rbac_check(request, "collection_x", current_user, "create", db_session) # Since collections are organization-level resources, we need to check org permissions
# For now, we'll use the existing RBAC check but with proper organization context
await courses_rbac_check_for_collections(request, "collection_x", current_user, "create", db_session)
# Complete the collection object # Complete the collection object
collection.collection_uuid = f"collection_{uuid4()}" collection.collection_uuid = f"collection_{uuid4()}"
@ -99,18 +96,32 @@ async def create_collection(
db_session.commit() db_session.commit()
db_session.refresh(collection) db_session.refresh(collection)
# Link courses to collection # SECURITY: Link courses to collection - ensure user has access to all courses being added
if collection: if collection:
for course_id in collection_object.courses: for course_id in collection_object.courses:
collection_course = CollectionCourse( # Check if user has access to this course
collection_id=int(collection.id), # type: ignore statement = select(Course).where(Course.id == course_id)
course_id=course_id, course = db_session.exec(statement).first()
org_id=int(collection_object.org_id),
creation_date=str(datetime.now()), if course:
update_date=str(datetime.now()), # Verify user has read access to the course before adding it to collection
) try:
# Add collection_course to database await courses_rbac_check_for_collections(request, course.course_uuid, current_user, "read", db_session)
db_session.add(collection_course) except HTTPException:
raise HTTPException(
status_code=403,
detail=f"You don't have permission to add course {course.name} to this collection"
)
collection_course = CollectionCourse(
collection_id=int(collection.id), # type: ignore
course_id=course_id,
org_id=int(collection_object.org_id),
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
# Add collection_course to database
db_session.add(collection_course)
db_session.commit() db_session.commit()
db_session.refresh(collection) db_session.refresh(collection)
@ -145,7 +156,7 @@ async def update_collection(
) )
# RBAC check # RBAC check
await rbac_check( await courses_rbac_check_for_collections(
request, collection.collection_uuid, current_user, "update", db_session request, collection.collection_uuid, current_user, "update", db_session
) )
@ -219,7 +230,7 @@ async def delete_collection(
) )
# RBAC check # RBAC check
await rbac_check( await courses_rbac_check_for_collections(
request, collection.collection_uuid, current_user, "delete", db_session request, collection.collection_uuid, current_user, "delete", db_session
) )
@ -248,7 +259,7 @@ async def get_collections(
Collection.org_id == org_id, Collection.public == True Collection.org_id == org_id, Collection.public == True
) )
statement_all = ( statement_all = (
select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) # type: ignore
) )
if current_user.id == 0: if current_user.id == 0:
@ -288,49 +299,7 @@ async def get_collections(
courses = db_session.exec(statement).all() courses = db_session.exec(statement).all()
collection = CollectionRead(**collection.model_dump(), courses=courses) collection = CollectionRead(**collection.model_dump(), courses=list(courses))
collections_with_courses.append(collection) collections_with_courses.append(collection)
return collections_with_courses return collections_with_courses
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
collection_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, collection_uuid, action, db_session
)
if res == False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User rights : You are not allowed to read this collection",
)
else:
res = (
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, collection_uuid, db_session
)
)
return res
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
collection_uuid,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -1,10 +1,11 @@
from datetime import datetime from datetime import datetime
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request
from sqlmodel import Session, select, and_ from sqlmodel import Session, select, and_
from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.users import PublicUser, AnonymousUser, User, UserRead
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_roles_and_authorship from src.security.rbac.rbac import authorization_verify_if_user_is_anon
from src.security.courses_security import courses_rbac_check
from typing import List from typing import List
@ -14,6 +15,14 @@ async def apply_course_contributor(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
): ):
"""
Apply to become a course contributor
SECURITY NOTES:
- Any authenticated user can apply to become a contributor
- Applications are created with PENDING status
- Only course owners (CREATOR, MAINTAINER) or admins can approve applications
"""
# Verify user is not anonymous # Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
@ -73,21 +82,17 @@ async def update_course_contributor(
): ):
""" """
Update a course contributor's role and status Update a course contributor's role and status
Only administrators can perform this action
SECURITY NOTES:
- Only course owners (CREATOR, MAINTAINER) or admins can update contributors
- Cannot modify the role of the course creator
- Requires strict course ownership checks
""" """
# Verify user is not anonymous # Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights # SECURITY: Require course ownership or admin role for updating contributors
authorized = await authorization_verify_based_on_roles_and_authorship( await courses_rbac_check(request, course_uuid, current_user, "update", db_session)
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to update course contributors",
)
# Check if course exists # Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
@ -115,7 +120,7 @@ async def update_course_contributor(
detail="Contributor not found for this course", detail="Contributor not found for this course",
) )
# Don't allow changing the role of the creator # SECURITY: Don't allow changing the role of the creator
if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -144,6 +149,10 @@ async def get_course_contributors(
) -> List[dict]: ) -> List[dict]:
""" """
Get all contributors for a course with their user information Get all contributors for a course with their user information
SECURITY NOTES:
- Requires read access to the course
- Contributors are visible to anyone with course read access
""" """
# Check if course exists # Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
@ -155,6 +164,9 @@ async def get_course_contributors(
detail="Course not found", detail="Course not found",
) )
# SECURITY: Require read access to the course
await courses_rbac_check(request, course_uuid, current_user, "read", db_session)
# Get all contributors for this course with user information # Get all contributors for this course with user information
statement = ( statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
@ -184,21 +196,17 @@ async def add_bulk_course_contributors(
): ):
""" """
Add multiple contributors to a course by their usernames Add multiple contributors to a course by their usernames
Only administrators can perform this action
SECURITY NOTES:
- Only course owners (CREATOR, MAINTAINER) or admins can add contributors
- Requires strict course ownership checks
- Cannot add contributors to courses the user doesn't own
""" """
# Verify user is not anonymous # Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights # SECURITY: Require course ownership or admin role for adding contributors
authorized = await authorization_verify_based_on_roles_and_authorship( await courses_rbac_check(request, course_uuid, current_user, "update", db_session)
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to add contributors",
)
# Check if course exists # Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
@ -284,21 +292,18 @@ async def remove_bulk_course_contributors(
): ):
""" """
Remove multiple contributors from a course by their usernames Remove multiple contributors from a course by their usernames
Only administrators can perform this action
SECURITY NOTES:
- Only course owners (CREATOR, MAINTAINER) or admins can remove contributors
- Requires strict course ownership checks
- Cannot remove contributors from courses the user doesn't own
- Cannot remove the course creator
""" """
# Verify user is not anonymous # Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights # SECURITY: Require course ownership or admin role for removing contributors
authorized = await authorization_verify_based_on_roles_and_authorship( await courses_rbac_check(request, course_uuid, current_user, "update", db_session)
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to remove contributors",
)
# Check if course exists # Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
@ -346,7 +351,7 @@ async def remove_bulk_course_contributors(
}) })
continue continue
# Don't allow removing the creator # SECURITY: Don't allow removing the creator
if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR:
results["failed"].append({ results["failed"].append({
"username": username, "username": username,

View file

@ -1,4 +1,4 @@
from typing import Literal, List from typing import List
from uuid import uuid4 from uuid import uuid4
from sqlmodel import Session, select, or_, and_, text from sqlmodel import Session, select, or_, and_, text
from src.db.usergroup_resources import UserGroupResource from src.db.usergroup_resources import UserGroupResource
@ -21,13 +21,13 @@ from src.db.courses.courses import (
ThumbnailType, ThumbnailType,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
authorization_verify_based_on_org_admin_status,
) )
from src.services.courses.thumbnails import upload_thumbnail from src.services.courses.thumbnails import upload_thumbnail
from fastapi import HTTPException, Request, UploadFile from fastapi import HTTPException, Request, UploadFile, status
from datetime import datetime from datetime import datetime
from src.security.courses_security import courses_rbac_check
async def get_course( async def get_course(
@ -46,15 +46,15 @@ async def get_course(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -92,15 +92,15 @@ async def get_course_by_id(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -153,7 +153,7 @@ async def get_course_meta(
author_results = [(ra, u) for _, ra, u in results if ra is not None and u is not None] author_results = [(ra, u) for _, ra, u in results if ra is not None and u is not None]
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get course chapters # Get course chapters
chapters = [] chapters = []
@ -241,7 +241,7 @@ async def get_courses_orgslug(
.join(User, ResourceAuthor.user_id == User.id) # type: ignore .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore .where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
@ -349,10 +349,10 @@ async def search_courses(
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -399,10 +399,20 @@ async def create_course(
thumbnail_file: UploadFile | None = None, thumbnail_file: UploadFile | None = None,
thumbnail_type: ThumbnailType = ThumbnailType.IMAGE, thumbnail_type: ThumbnailType = ThumbnailType.IMAGE,
): ):
"""
Create a new course
SECURITY NOTES:
- Requires proper permissions to create courses in the organization
- User becomes the CREATOR of the course automatically
- Course creation is subject to organization limits and permissions
"""
course = Course.model_validate(course_object) course = Course.model_validate(course_object)
# RBAC check # SECURITY: Check if user has permission to create courses in this organization
await rbac_check(request, "course_x", current_user, "create", db_session) # Since this is a new course, we need to check organization-level permissions
# For now, we'll use the existing RBAC check but with proper organization context
await courses_rbac_check(request, "course_x", current_user, "create", db_session)
# Usage check # Usage check
check_limits_with_usage("courses", org_id, db_session) check_limits_with_usage("courses", org_id, db_session)
@ -440,7 +450,7 @@ async def create_course(
db_session.commit() db_session.commit()
db_session.refresh(course) db_session.refresh(course)
# Make the user the creator of the course # SECURITY: Make the user the creator of the course
resource_author = ResourceAuthor( resource_author = ResourceAuthor(
resource_uuid=course.course_uuid, resource_uuid=course.course_uuid,
user_id=current_user.id, user_id=current_user.id,
@ -458,10 +468,10 @@ async def create_course(
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -506,7 +516,7 @@ async def update_course_thumbnail(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Get org uuid # Get org uuid
org_statement = select(Organization).where(Organization.id == course.org_id) org_statement = select(Organization).where(Organization.id == course.org_id)
@ -543,10 +553,10 @@ async def update_course_thumbnail(
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -575,6 +585,14 @@ async def update_course(
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
): ):
"""
Update a course
SECURITY NOTES:
- Requires course ownership (CREATOR, MAINTAINER) or admin role
- Sensitive fields (public, open_to_contributors) require additional validation
- Cannot change course access settings without proper permissions
"""
statement = select(Course).where(Course.course_uuid == course_uuid) statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first() course = db_session.exec(statement).first()
@ -584,8 +602,46 @@ async def update_course(
detail="Course not found", detail="Course not found",
) )
# RBAC check # SECURITY: Require course ownership or admin role for updating courses
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session)
# SECURITY: Additional checks for sensitive access control fields
sensitive_fields_updated = []
# Check if sensitive fields are being updated
if course_object.public is not None:
sensitive_fields_updated.append("public")
if course_object.open_to_contributors is not None:
sensitive_fields_updated.append("open_to_contributors")
# If sensitive fields are being updated, require additional validation
if sensitive_fields_updated:
# SECURITY: For sensitive access control changes, require CREATOR or MAINTAINER role
# Check if user is course owner (CREATOR or MAINTAINER)
statement = select(ResourceAuthor).where(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == current_user.id
)
resource_author = db_session.exec(statement).first()
is_course_owner = False
if resource_author:
if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or
(resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER)) and \
resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
is_course_owner = True
# Check if user has admin or maintainer role
is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status(
request, current_user.id, "update", course_uuid, db_session
)
# SECURITY: Only course owners (CREATOR, MAINTAINER) or admins can change access settings
if not (is_course_owner or is_admin_or_maintainer):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be the course owner (CREATOR or MAINTAINER) or have admin role to change access settings: {', '.join(sensitive_fields_updated)}",
)
# Update only the fields that were passed in # Update only the fields that were passed in
for var, value in vars(course_object).items(): for var, value in vars(course_object).items():
@ -602,10 +658,10 @@ async def update_course(
# Get course authors with their roles # Get course authors with their roles
authors_statement = ( authors_statement = (
select(ResourceAuthor, User) select(ResourceAuthor, User)
.join(User, ResourceAuthor.user_id == User.id) .join(User, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid) .where(ResourceAuthor.resource_uuid == course.course_uuid)
.order_by( .order_by(
ResourceAuthor.id.asc() ResourceAuthor.id.asc() # type: ignore
) )
) )
author_results = db_session.exec(authors_statement).all() author_results = db_session.exec(authors_statement).all()
@ -643,7 +699,7 @@ async def delete_course(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage # Feature usage
decrease_feature_usage("courses", course.org_id, db_session) decrease_feature_usage("courses", course.org_id, db_session)
@ -681,7 +737,7 @@ async def get_user_courses(
return [] return []
# Get courses with the extracted UUIDs # Get courses with the extracted UUIDs
statement = select(Course).where(Course.course_uuid.in_(course_uuids)) statement = select(Course).where(Course.course_uuid.in_(course_uuids)) # type: ignore
# Apply pagination # Apply pagination
statement = statement.offset((page - 1) * limit).limit(limit) statement = statement.offset((page - 1) * limit).limit(limit)
@ -738,39 +794,177 @@ async def get_user_courses(
return result return result
## 🔒 RBAC Utils ## async def get_course_user_rights(
async def rbac_check(
request: Request, request: Request,
course_uuid: str, course_uuid: str,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session, db_session: Session,
): ) -> dict:
if action == "read": """
if current_user.id == 0: # Anonymous user Get detailed user rights for a specific course.
res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
return res
else:
res = (
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
)
return res
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( This function returns comprehensive rights information that can be used
request, by the UI to enable/disable features based on user permissions.
current_user.id,
action, SECURITY NOTES:
course_uuid, - Returns rights based on course ownership and user roles
db_session, - Includes both course-level and content-level permissions
- Safe to expose to UI as it only returns permission information
"""
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
) )
# Initialize rights object
rights = {
"course_uuid": course_uuid,
"user_id": current_user.id,
"is_anonymous": current_user.id == 0,
"permissions": {
"read": False,
"create": False,
"update": False,
"delete": False,
"create_content": False,
"update_content": False,
"delete_content": False,
"manage_contributors": False,
"manage_access": False,
"grade_assignments": False,
"mark_activities_done": False,
"create_certifications": False,
},
"ownership": {
"is_owner": False,
"is_creator": False,
"is_maintainer": False,
"is_contributor": False,
"authorship_status": None,
},
"roles": {
"is_admin": False,
"is_maintainer_role": False,
"is_instructor": False,
"is_user": False,
}
}
## 🔒 RBAC Utils ## # Handle anonymous users
if current_user.id == 0:
# Anonymous users can only read public courses
if course.public:
rights["permissions"]["read"] = True
return rights
# Check course ownership
statement = select(ResourceAuthor).where(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == current_user.id
)
resource_author = db_session.exec(statement).first()
if resource_author:
rights["ownership"]["authorship_status"] = resource_author.authorship_status
if resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE:
if resource_author.authorship == ResourceAuthorshipEnum.CREATOR:
rights["ownership"]["is_creator"] = True
rights["ownership"]["is_owner"] = True
elif resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER:
rights["ownership"]["is_maintainer"] = True
rights["ownership"]["is_owner"] = True
elif resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR:
rights["ownership"]["is_contributor"] = True
rights["ownership"]["is_owner"] = True
# Check user roles
from src.security.rbac.rbac import authorization_verify_based_on_org_admin_status
from src.security.rbac.rbac import authorization_verify_based_on_roles
# Check admin/maintainer role
is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status(
request, current_user.id, "update", course_uuid, db_session
)
if is_admin_or_maintainer:
rights["roles"]["is_admin"] = True
rights["roles"]["is_maintainer_role"] = True
# Check instructor role
has_instructor_permissions = await authorization_verify_based_on_roles(
request, current_user.id, "create", "course_x", db_session
)
if has_instructor_permissions:
rights["roles"]["is_instructor"] = True
# Check user role (basic permissions)
has_user_permissions = await authorization_verify_based_on_roles(
request, current_user.id, "read", course_uuid, db_session
)
if has_user_permissions:
rights["roles"]["is_user"] = True
# Determine permissions based on ownership and roles
is_course_owner = rights["ownership"]["is_owner"]
is_admin = rights["roles"]["is_admin"]
is_maintainer_role = rights["roles"]["is_maintainer_role"]
is_instructor = rights["roles"]["is_instructor"]
# READ permissions
if course.public or is_course_owner or is_admin or is_maintainer_role or is_instructor or has_user_permissions:
rights["permissions"]["read"] = True
# CREATE permissions (course creation)
if is_instructor or is_admin or is_maintainer_role:
rights["permissions"]["create"] = True
# UPDATE permissions (course-level updates)
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["update"] = True
# DELETE permissions (course deletion)
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["delete"] = True
# CONTENT CREATION permissions (activities, assignments, chapters, etc.)
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["create_content"] = True
# CONTENT UPDATE permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["update_content"] = True
# CONTENT DELETE permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["delete_content"] = True
# CONTRIBUTOR MANAGEMENT permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["manage_contributors"] = True
# ACCESS MANAGEMENT permissions (public, open_to_contributors)
if (rights["ownership"]["is_creator"] or rights["ownership"]["is_maintainer"] or
is_admin or is_maintainer_role):
rights["permissions"]["manage_access"] = True
# GRADING permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["grade_assignments"] = True
# ACTIVITY MARKING permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["mark_activities_done"] = True
# CERTIFICATION permissions
if is_course_owner or is_admin or is_maintainer_role:
rights["permissions"]["create_certifications"] = True
return rights

View file

@ -12,7 +12,7 @@ from src.db.courses.course_updates import (
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.organizations import Organization from src.db.organizations import Organization
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser
from src.services.courses.courses import rbac_check from src.security.courses_security import courses_rbac_check
async def create_update( async def create_update(
@ -41,7 +41,7 @@ async def create_update(
) )
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Generate UUID # Generate UUID
courseupdate_uuid = str(f"courseupdate_{uuid4()}") courseupdate_uuid = str(f"courseupdate_{uuid4()}")
@ -81,7 +81,7 @@ async def update_update(
) )
# RBAC check # RBAC check
await rbac_check( await courses_rbac_check(
request, update.courseupdate_uuid, current_user, "update", db_session request, update.courseupdate_uuid, current_user, "update", db_session
) )
@ -115,7 +115,7 @@ async def delete_update(
) )
# RBAC check # RBAC check
await rbac_check( await courses_rbac_check(
request, update.courseupdate_uuid, current_user, "delete", db_session request, update.courseupdate_uuid, current_user, "delete", db_session
) )

View file

@ -24,7 +24,7 @@ from src.db.organization_config import (
UserGroupOrgConfig, UserGroupOrgConfig,
) )
from src.db.organizations import Organization, OrganizationCreate from src.db.organizations import Organization, OrganizationCreate
from src.db.roles import Permission, Rights, Role, RoleTypeEnum from src.db.roles import DashboardPermission, Permission, PermissionsWithOwn, Rights, Role, RoleTypeEnum
from src.db.user_organizations import UserOrganization from src.db.user_organizations import UserOrganization
from src.db.users import User, UserCreate, UserRead from src.db.users import User, UserCreate, UserRead
from config.config import get_learnhouse_config from config.config import get_learnhouse_config
@ -127,7 +127,7 @@ def install_default_elements(db_session: Session):
statement = select(Role).where(Role.role_type == RoleTypeEnum.TYPE_GLOBAL) statement = select(Role).where(Role.role_type == RoleTypeEnum.TYPE_GLOBAL)
roles = db_session.exec(statement).all() roles = db_session.exec(statement).all()
if roles and len(roles) == 3: if roles and len(roles) == 4:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="Default roles already exist", detail="Default roles already exist",
@ -136,16 +136,19 @@ def install_default_elements(db_session: Session):
# Create default roles # Create default roles
role_global_admin = Role( role_global_admin = Role(
name="Admin", name="Admin",
description="Standard Admin Role", description="Full platform control",
id=1, id=1,
role_type=RoleTypeEnum.TYPE_GLOBAL, role_type=RoleTypeEnum.TYPE_GLOBAL,
role_uuid="role_global_admin", role_uuid="role_global_admin",
rights=Rights( rights=Rights(
courses=Permission( courses=PermissionsWithOwn(
action_create=True, action_create=True,
action_read=True, action_read=True,
action_read_own=True,
action_update=True, action_update=True,
action_update_own=True,
action_delete=True, action_delete=True,
action_delete_own=True,
), ),
users=Permission( users=Permission(
action_create=True, action_create=True,
@ -183,6 +186,15 @@ def install_default_elements(db_session: Session):
action_update=True, action_update=True,
action_delete=True, action_delete=True,
), ),
roles=Permission(
action_create=True,
action_read=True,
action_update=True,
action_delete=True,
),
dashboard=DashboardPermission(
action_access=True,
),
), ),
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
@ -190,22 +202,25 @@ def install_default_elements(db_session: Session):
role_global_maintainer = Role( role_global_maintainer = Role(
name="Maintainer", name="Maintainer",
description="Standard Maintainer Role", description="Mid-level manager, wide permissions but no platform control",
id=2, id=2,
role_type=RoleTypeEnum.TYPE_GLOBAL, role_type=RoleTypeEnum.TYPE_GLOBAL,
role_uuid="role_global_maintainer", role_uuid="role_global_maintainer",
rights=Rights( rights=Rights(
courses=Permission( courses=PermissionsWithOwn(
action_create=True, action_create=True,
action_read=True, action_read=True,
action_read_own=True,
action_update=True, action_update=True,
action_update_own=True,
action_delete=True, action_delete=True,
action_delete_own=True,
), ),
users=Permission( users=Permission(
action_create=True, action_create=True,
action_read=True, action_read=True,
action_update=True, action_update=True,
action_delete=True, action_delete=False,
), ),
usergroups=Permission( usergroups=Permission(
action_create=True, action_create=True,
@ -220,10 +235,10 @@ def install_default_elements(db_session: Session):
action_delete=True, action_delete=True,
), ),
organizations=Permission( organizations=Permission(
action_create=True, action_create=False,
action_read=True, action_read=True,
action_update=True, action_update=False,
action_delete=True, action_delete=False,
), ),
coursechapters=Permission( coursechapters=Permission(
action_create=True, action_create=True,
@ -237,6 +252,81 @@ def install_default_elements(db_session: Session):
action_update=True, action_update=True,
action_delete=True, action_delete=True,
), ),
roles=Permission(
action_create=False,
action_read=True,
action_update=False,
action_delete=False,
),
dashboard=DashboardPermission(
action_access=True,
),
),
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
role_global_instructor = Role(
name="Instructor",
description="Can manage their own content",
id=3,
role_type=RoleTypeEnum.TYPE_GLOBAL,
role_uuid="role_global_instructor",
rights=Rights(
courses=PermissionsWithOwn(
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=Permission(
action_create=False,
action_read=False,
action_update=False,
action_delete=False,
),
usergroups=Permission(
action_create=False,
action_read=True,
action_update=False,
action_delete=False,
),
collections=Permission(
action_create=True,
action_read=True,
action_update=False,
action_delete=False,
),
organizations=Permission(
action_create=False,
action_read=False,
action_update=False,
action_delete=False,
),
coursechapters=Permission(
action_create=True,
action_read=True,
action_update=False,
action_delete=False,
),
activities=Permission(
action_create=True,
action_read=True,
action_update=False,
action_delete=False,
),
roles=Permission(
action_create=False,
action_read=False,
action_update=False,
action_delete=False,
),
dashboard=DashboardPermission(
action_access=True,
),
), ),
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
@ -244,20 +334,23 @@ def install_default_elements(db_session: Session):
role_global_user = Role( role_global_user = Role(
name="User", name="User",
description="Standard User Role", description="Read-Only Learner",
role_type=RoleTypeEnum.TYPE_GLOBAL, role_type=RoleTypeEnum.TYPE_GLOBAL,
role_uuid="role_global_user", role_uuid="role_global_user",
id=3, id=4,
rights=Rights( rights=Rights(
courses=Permission( courses=PermissionsWithOwn(
action_create=False, action_create=False,
action_read=True, action_read=True,
action_read_own=True,
action_update=False, action_update=False,
action_delete=False, action_update_own=False,
action_delete=True,
action_delete_own=True,
), ),
users=Permission( users=Permission(
action_create=True, action_create=False,
action_read=True, action_read=False,
action_update=False, action_update=False,
action_delete=False, action_delete=False,
), ),
@ -275,7 +368,7 @@ def install_default_elements(db_session: Session):
), ),
organizations=Permission( organizations=Permission(
action_create=False, action_create=False,
action_read=True, action_read=False,
action_update=False, action_update=False,
action_delete=False, action_delete=False,
), ),
@ -291,6 +384,15 @@ def install_default_elements(db_session: Session):
action_update=False, action_update=False,
action_delete=False, action_delete=False,
), ),
roles=Permission(
action_create=False,
action_read=False,
action_update=False,
action_delete=False,
),
dashboard=DashboardPermission(
action_access=False,
),
), ),
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
@ -299,11 +401,13 @@ def install_default_elements(db_session: Session):
# Serialize rights to JSON # Serialize rights to JSON
role_global_admin.rights = role_global_admin.rights.dict() # type: ignore role_global_admin.rights = role_global_admin.rights.dict() # type: ignore
role_global_maintainer.rights = role_global_maintainer.rights.dict() # type: ignore role_global_maintainer.rights = role_global_maintainer.rights.dict() # type: ignore
role_global_instructor.rights = role_global_instructor.rights.dict() # type: ignore
role_global_user.rights = role_global_user.rights.dict() # type: ignore role_global_user.rights = role_global_user.rights.dict() # type: ignore
# Insert roles in DB # Insert roles in DB
db_session.add(role_global_admin) db_session.add(role_global_admin)
db_session.add(role_global_maintainer) db_session.add(role_global_maintainer)
db_session.add(role_global_instructor)
db_session.add(role_global_user) db_session.add(role_global_user)
# commit changes # commit changes

View file

@ -80,7 +80,7 @@ async def join_org(
user_organization = UserOrganization( user_organization = UserOrganization(
user_id=user.id, user_id=user.id,
org_id=org.id, org_id=org.id,
role_id=3, role_id=4,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
) )
@ -102,7 +102,7 @@ async def join_org(
user_organization = UserOrganization( user_organization = UserOrganization(
user_id=user.id, user_id=user.id,
org_id=org.id, org_id=org.id,
role_id=3, role_id=4,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
) )

View file

@ -4,7 +4,7 @@ from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_products import PaymentsProduct from src.db.payments.payments_products import PaymentsProduct
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.users import PublicUser, AnonymousUser from src.db.users import PublicUser, AnonymousUser
from src.services.courses.courses import rbac_check from src.security.courses_security import courses_rbac_check
async def link_course_to_product( async def link_course_to_product(
request: Request, request: Request,
@ -22,7 +22,7 @@ async def link_course_to_product(
raise HTTPException(status_code=404, detail="Course not found") raise HTTPException(status_code=404, detail="Course not found")
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Check if product exists # Check if product exists
statement = select(PaymentsProduct).where( statement = select(PaymentsProduct).where(
@ -71,7 +71,7 @@ async def unlink_course_from_product(
raise HTTPException(status_code=404, detail="Course not found") raise HTTPException(status_code=404, detail="Course not found")
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session) await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Find and delete the payment course link # Find and delete the payment course link
statement = select(PaymentsCourse).where( statement = select(PaymentsCourse).where(

View file

@ -1,12 +1,15 @@
from typing import Literal from typing import Literal, List
from uuid import uuid4 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 ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.users import AnonymousUser, PublicUser 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 fastapi import HTTPException, Request
from datetime import datetime from datetime import datetime
@ -22,24 +25,401 @@ async def create_role(
# RBAC check # RBAC check
await rbac_check(request, current_user, "create", "role_xxx", db_session) 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 # Complete the role object
role.role_uuid = f"role_{uuid4()}" role.role_uuid = f"role_{uuid4()}"
role.creation_date = str(datetime.now()) role.creation_date = str(datetime.now())
role.update_date = str(datetime.now()) role.update_date = str(datetime.now())
db_session.add(role) # ============================================================================
db_session.commit() # VERIFICATION 9: Handle ID sequence issue (existing logic)
db_session.refresh(role) # ============================================================================
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()
role = RoleRead(**role.model_dump()) # 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
# 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 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( async def read_role(
request: Request, db_session: Session, role_id: str, current_user: PublicUser 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) result = db_session.exec(statement)
role = result.first() role = result.first()
@ -75,6 +455,15 @@ async def update_role(
detail="Role not found", 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 # RBAC check
await rbac_check(request, current_user, "update", role.role_uuid, db_session) 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 del role_object.role_id
# Update only the fields that were passed in # 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: 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.add(role)
db_session.commit() db_session.commit()
@ -101,10 +597,17 @@ async def update_role(
async def delete_role( async def delete_role(
request: Request, db_session: Session, role_id: str, current_user: PublicUser request: Request, db_session: Session, role_id: str, current_user: PublicUser
): ):
# RBAC check # Convert role_id to integer
await rbac_check(request, current_user, "delete", role_id, db_session) 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) result = db_session.exec(statement)
role = result.first() role = result.first()
@ -115,6 +618,18 @@ async def delete_role(
detail="Role not found", 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.delete(role)
db_session.commit() db_session.commit()

View file

@ -103,7 +103,7 @@ async def create_user(
user_organization = UserOrganization( user_organization = UserOrganization(
user_id=user.id if user.id else 0, user_id=user.id if user.id else 0,
org_id=int(org_id), org_id=int(org_id),
role_id=3, role_id=4,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
) )

View file

@ -57,57 +57,69 @@ class TestRBAC:
@pytest.fixture @pytest.fixture
def mock_role(self): def mock_role(self):
"""Create a mock role object""" """Create a mock role object"""
from src.db.roles import RoleTypeEnum from src.db.roles import RoleTypeEnum, Rights, PermissionsWithOwn, Permission, DashboardPermission
role = Mock(spec=Role) role = Mock(spec=Role)
role.id = 1 role.id = 1
role.org_id = 1 role.org_id = 1
role.name = "Test Role" role.name = "Test Role"
role.description = "A test role." role.description = "A test role."
# Rights should be a dictionary for validation # Rights should be a Rights object with proper Permission objects
role.rights = { role.rights = Rights(
"courses": { courses=PermissionsWithOwn(
"action_create": False, action_create=False,
"action_read": True, action_read=True,
"action_update": False, action_read_own=False,
"action_delete": False, action_update=False,
}, action_update_own=False,
"users": { action_delete=False,
"action_create": False, action_delete_own=False,
"action_read": True, ),
"action_update": False, users=Permission(
"action_delete": False, action_create=False,
}, action_read=True,
"usergroups": { action_update=False,
"action_create": False, action_delete=False,
"action_read": True, ),
"action_update": False, usergroups=Permission(
"action_delete": False, action_create=False,
}, action_read=True,
"collections": { action_update=False,
"action_create": False, action_delete=False,
"action_read": True, ),
"action_update": False, collections=Permission(
"action_delete": False, action_create=False,
}, action_read=True,
"organizations": { action_update=False,
"action_create": False, action_delete=False,
"action_read": True, ),
"action_update": False, organizations=Permission(
"action_delete": False, action_create=False,
}, action_read=True,
"coursechapters": { action_update=False,
"action_create": False, action_delete=False,
"action_read": True, ),
"action_update": False, coursechapters=Permission(
"action_delete": False, action_create=False,
}, action_read=True,
"activities": { action_update=False,
"action_create": False, action_delete=False,
"action_read": True, ),
"action_update": False, activities=Permission(
"action_delete": False, action_create=False,
} action_read=True,
} action_update=False,
action_delete=False,
),
roles=Permission(
action_create=False,
action_read=True,
action_update=False,
action_delete=False,
),
dashboard=DashboardPermission(
action_access=True,
)
)
role.role_type = RoleTypeEnum.TYPE_GLOBAL role.role_type = RoleTypeEnum.TYPE_GLOBAL
role.role_uuid = "role_test" role.role_uuid = "role_test"
role.creation_date = "2024-01-01T00:00:00" role.creation_date = "2024-01-01T00:00:00"
@ -277,7 +289,7 @@ class TestRBAC:
mock_check_type.return_value = "courses" mock_check_type.return_value = "courses"
# Mock role without permission # Mock role without permission
mock_role.rights["courses"]["action_read"] = False mock_role.rights.courses.action_read = False
# Mock database query # Mock database query
mock_db_session.exec.return_value.all.return_value = [mock_role] mock_db_session.exec.return_value.all.return_value = [mock_role]

View file

@ -91,12 +91,12 @@ export const nextAuthOptions = {
token.user = userFromOAuth.data; token.user = userFromOAuth.data;
} }
// Refresh token only if it's close to expiring (5 minutes before expiry) // Refresh token only if it's close to expiring (1 minute before expiry)
if (token?.user?.tokens) { if (token?.user?.tokens) {
const tokenExpiry = token.user.tokens.expiry || 0; const tokenExpiry = token.user.tokens.expiry || 0;
const fiveMinutes = 5 * 60 * 1000; const oneMinute = 1 * 60 * 1000;
if (Date.now() + fiveMinutes >= tokenExpiry) { if (Date.now() + oneMinute >= tokenExpiry) {
const RefreshedToken = await getNewAccessTokenUsingRefreshTokenServer( const RefreshedToken = await getNewAccessTokenUsingRefreshTokenServer(
token?.user?.tokens?.refresh_token token?.user?.tokens?.refresh_token
); );
@ -118,11 +118,11 @@ export const nextAuthOptions = {
async session({ session, token }: any) { async session({ session, token }: any) {
// Include user information in the session // Include user information in the session
if (token.user) { if (token.user) {
// Cache the session for 5 minutes to avoid frequent API calls // Cache the session for 1 minute to refresh every minute
const cacheKey = `user_session_${token.user.tokens.access_token}`; const cacheKey = `user_session_${token.user.tokens.access_token}`;
let cachedSession = global.sessionCache?.[cacheKey]; let cachedSession = global.sessionCache?.[cacheKey];
if (cachedSession && Date.now() - cachedSession.timestamp < 5 * 60 * 1000) { if (cachedSession && Date.now() - cachedSession.timestamp < 1 * 60 * 1000) {
return cachedSession.data; return cachedSession.data;
} }

View file

@ -22,7 +22,7 @@ export default function RootLayout({
<head /> <head />
<body> <body>
{isDevEnv ? '' : <Script data-website-id="a1af6d7a-9286-4a1f-8385-ddad2a29fcbb" src="/umami/script.js" />} {isDevEnv ? '' : <Script data-website-id="a1af6d7a-9286-4a1f-8385-ddad2a29fcbb" src="/umami/script.js" />}
<SessionProvider key="session-provider"> <SessionProvider key="session-provider" refetchInterval={60000}>
<LHSessionProvider> <LHSessionProvider>
<StyledComponentsRegistry> <StyledComponentsRegistry>
<motion.main <motion.main
@ -30,7 +30,7 @@ export default function RootLayout({
initial="hidden" // Set the initial state to variants.hidden initial="hidden" // Set the initial state to variants.hidden
animate="enter" // Animated state to variants.enter animate="enter" // Animated state to variants.enter
exit="exit" // Exit state (used later) to variants.exit exit="exit" // Exit state (used later) to variants.exit
transition={{ type: 'linear' }} // Set the transition to linear transition={{ type: 'tween' }} // Set the transition to tween
> >
{children} {children}
</motion.main> </motion.main>

View file

@ -8,6 +8,10 @@ import Modal from '@components/Objects/StyledElements/Modal/Modal'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import React from 'react' import React from 'react'
import useAdminStatus from '@components/Hooks/useAdminStatus' import useAdminStatus from '@components/Hooks/useAdminStatus'
import { getUriWithOrg } from '@services/config/config'
import { useOrg } from '@components/Contexts/OrgContext'
import { BookOpen } from 'lucide-react'
import Link from 'next/link'
type CourseProps = { type CourseProps = {
orgslug: string orgslug: string
@ -22,6 +26,7 @@ function CoursesHome(params: CourseProps) {
const orgslug = params.orgslug const orgslug = params.orgslug
const courses = params.courses const courses = params.courses
const isUserAdmin = useAdminStatus() as any const isUserAdmin = useAdminStatus() as any
const org = useOrg() as any
async function closeNewCourseModal() { async function closeNewCourseModal() {
setNewCourseModal(false) setNewCourseModal(false)
@ -32,7 +37,16 @@ function CoursesHome(params: CourseProps) {
<div className="mb-6"> <div className="mb-6">
<BreadCrumbs type="courses" /> <BreadCrumbs type="courses" />
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mt-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mt-4">
<h1 className="text-3xl font-bold mb-4 sm:mb-0">Courses</h1> <div className="flex items-center space-x-4">
<h1 className="text-3xl font-bold mb-4 sm:mb-0">Courses</h1>
<Link
href={getUriWithOrg(org?.slug, '/dash/documentation/rights')}
className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased p-2 px-5 font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center"
>
<BookOpen className="w-4 h-4" />
<span>Rights Guide</span>
</Link>
</div>
<AuthenticatedClientElement <AuthenticatedClientElement
checkMethod="roles" checkMethod="roles"
action="create" action="create"

View file

@ -1,16 +1,20 @@
'use client' 'use client'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import React, { use } from 'react'; import React, { use, useEffect } from 'react';
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext' import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
import Link from 'next/link' import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award } from 'lucide-react' import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award, Lock } from 'lucide-react'
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification' import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification'
import { useCourseRights } from '@hooks/useCourseRights'
import { useRouter } from 'next/navigation'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
export type CourseOverviewParams = { export type CourseOverviewParams = {
orgslug: string orgslug: string
courseuuid: string courseuuid: string
@ -19,110 +23,146 @@ export type CourseOverviewParams = {
function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) { function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
const params = use(props.params); const params = use(props.params);
const router = useRouter();
function getEntireCourseUUID(courseuuid: string) { function getEntireCourseUUID(courseuuid: string) {
// add course_ to uuid // add course_ to uuid
return `course_${courseuuid}` return `course_${courseuuid}`
} }
const courseuuid = getEntireCourseUUID(params.courseuuid)
const { hasPermission, isLoading: rightsLoading } = useCourseRights(courseuuid)
// Define tab configurations with their required permissions
const tabs = [
{
key: 'general',
label: 'General',
icon: Info,
href: `/dash/courses/course/${params.courseuuid}/general`,
requiredPermission: 'update' as const
},
{
key: 'content',
label: 'Content',
icon: GalleryVerticalEnd,
href: `/dash/courses/course/${params.courseuuid}/content`,
requiredPermission: 'update_content' as const
},
{
key: 'access',
label: 'Access',
icon: Globe,
href: `/dash/courses/course/${params.courseuuid}/access`,
requiredPermission: 'manage_access' as const
},
{
key: 'contributors',
label: 'Contributors',
icon: UserPen,
href: `/dash/courses/course/${params.courseuuid}/contributors`,
requiredPermission: 'manage_contributors' as const
},
{
key: 'certification',
label: 'Certification',
icon: Award,
href: `/dash/courses/course/${params.courseuuid}/certification`,
requiredPermission: 'create_certifications' as const
}
]
// Filter tabs based on permissions
const visibleTabs = tabs.filter(tab => hasPermission(tab.requiredPermission))
// Check if current subpage is accessible
const currentTab = tabs.find(tab => tab.key === params.subpage)
const hasAccessToCurrentPage = currentTab ? hasPermission(currentTab.requiredPermission) : false
// Redirect to first available tab if current page is not accessible
useEffect(() => {
if (!rightsLoading && !hasAccessToCurrentPage && visibleTabs.length > 0) {
const firstAvailableTab = visibleTabs[0]
router.replace(getUriWithOrg(params.orgslug, '') + firstAvailableTab.href)
}
}, [rightsLoading, hasAccessToCurrentPage, visibleTabs, router, params.orgslug])
// Show loading state while rights are being fetched
if (rightsLoading) {
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
)
}
// Show access denied if no tabs are available
if (!rightsLoading && visibleTabs.length === 0) {
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center">
<div className="text-center">
<Lock className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Access Denied</h3>
<p className="text-gray-500">You don't have permission to access this course.</p>
</div>
</div>
)
}
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]"> <div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)} withUnpublishedActivities={true}> <CourseProvider courseuuid={courseuuid} withUnpublishedActivities={true}>
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow"> <div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} /> <CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm"> <div className="flex space-x-3 font-black text-sm">
<Link {tabs.map((tab) => {
href={ const IconComponent = tab.icon
getUriWithOrg(params.orgslug, '') + const isActive = params.subpage.toString() === tab.key
`/dash/courses/course/${params.courseuuid}/general` const hasAccess = hasPermission(tab.requiredPermission)
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Info size={16} />
<div>General</div>
</div>
</div>
</Link>
<Link if (!hasAccess) {
href={ // Show disabled tab with subtle visual cues and tooltip
getUriWithOrg(params.orgslug, '') + return (
`/dash/courses/course/${params.courseuuid}/content` <ToolTip
key={tab.key}
content={
<div className="text-center">
<div className="font-medium text-gray-900">Access Restricted</div>
<div className="text-sm text-gray-600">
You don't have permission to access {tab.label}
</div>
</div>
}
>
<div className="flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear opacity-30 cursor-not-allowed">
<div className="flex items-center space-x-2.5 mx-2">
<IconComponent size={16} />
<div>{tab.label}</div>
</div>
</div>
</ToolTip>
)
} }
>
<div return (
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content' <Link
? 'border-b-4' key={tab.key}
: 'opacity-50' href={getUriWithOrg(params.orgslug, '') + tab.href}
} cursor-pointer`} >
> <div
<div className="flex items-center space-x-2.5 mx-2"> className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${
<GalleryVerticalEnd size={16} /> isActive ? 'border-b-4' : 'opacity-50 hover:opacity-75'
<div>Content</div> } cursor-pointer`}
</div> >
</div> <div className="flex items-center space-x-2.5 mx-2">
</Link> <IconComponent size={16} />
<Link <div>{tab.label}</div>
href={ </div>
getUriWithOrg(params.orgslug, '') + </div>
`/dash/courses/course/${params.courseuuid}/access` </Link>
} )
> })}
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Globe size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/contributors`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'contributors'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPen size={16} />
<div>Contributors</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/certification`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'certification'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Award size={16} />
<div>Certification</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -132,12 +172,21 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
className="h-full overflow-y-auto relative" className="h-full overflow-y-auto relative"
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')} {params.subpage == 'content' && hasPermission('update_content') ? (
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')} <EditCourseStructure orgslug={params.orgslug} />
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')} ) : null}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')} {params.subpage == 'general' && hasPermission('update') ? (
{params.subpage == 'certification' ? (<EditCourseCertification orgslug={params.orgslug} />) : ('')} <EditCourseGeneral orgslug={params.orgslug} />
) : null}
{params.subpage == 'access' && hasPermission('manage_access') ? (
<EditCourseAccess orgslug={params.orgslug} />
) : null}
{params.subpage == 'contributors' && hasPermission('manage_contributors') ? (
<EditCourseContributors orgslug={params.orgslug} />
) : null}
{params.subpage == 'certification' && hasPermission('create_certifications') ? (
<EditCourseCertification orgslug={params.orgslug} />
) : null}
</div> </div>
</motion.div> </motion.div>
</CourseProvider> </CourseProvider>

View file

@ -0,0 +1,9 @@
import React from 'react'
export default function DocumentationLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View file

@ -0,0 +1,217 @@
'use client'
import React from 'react'
import { getUriWithOrg } from '@services/config/config'
import { useOrg } from '@components/Contexts/OrgContext'
import {
Shield,
Users,
BookOpen,
UserCheck,
Lock,
Globe,
Award,
FileText,
Settings,
Crown,
User,
UserCog,
GraduationCap,
Eye,
Edit,
Trash2,
Plus,
CheckCircle,
XCircle,
AlertCircle,
Info,
ArrowLeft,
AlertTriangle,
Key,
UserCheck as UserCheckIcon
} from 'lucide-react'
import Link from 'next/link'
import { motion } from 'framer-motion'
interface RightsDocumentationProps {
params: Promise<{ orgslug: string }>
}
const RightsDocumentation = ({ params }: RightsDocumentationProps) => {
const org = useOrg() as any
const roleHierarchy = [
{
name: 'Admin',
icon: <Crown className="w-6 h-6 text-purple-600" />,
color: 'bg-purple-50 border-purple-200',
description: 'Full platform control with all permissions',
permissions: ['All permissions', 'Manage organization', 'Manage users', 'Manage courses', 'Manage roles'],
level: 4
},
{
name: 'Maintainer',
icon: <Shield className="w-6 h-6 text-blue-600" />,
color: 'bg-blue-50 border-blue-200',
description: 'Mid-level manager with wide permissions',
permissions: ['Manage courses', 'Manage users', 'Manage assignments', ],
level: 3
},
{
name: 'Instructor',
icon: <GraduationCap className="w-6 h-6 text-green-600" />,
color: 'bg-green-50 border-green-200',
description: 'Can create courses but need ownership for content creation',
permissions: ['Create courses', 'Manage own courses', 'Create assignments', 'Grade assignments'],
level: 2
},
{
name: 'User',
icon: <User className="w-6 h-6 text-gray-600" />,
color: 'bg-gray-50 border-gray-200',
description: 'Read-Only Learner',
permissions: ['View courses', 'Submit assignments', 'Take assessments'],
level: 1
}
]
const courseOwnershipTypes = [
{
name: 'Creator',
icon: <Crown className="w-5 h-5 text-yellow-600" />,
color: 'bg-yellow-50 border-yellow-200',
description: 'Original course creator with full control',
permissions: ['Full course control', 'Manage contributors', 'Change access settings', 'Delete course']
},
{
name: 'Maintainer',
icon: <Shield className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-50 border-blue-200',
description: 'Course maintainer with extensive permissions',
permissions: ['Manage course content', 'Manage contributors', 'Change access settings', 'Cannot delete course']
},
{
name: 'Contributor',
icon: <UserCog className="w-5 h-5 text-green-600" />,
color: 'bg-green-50 border-green-200',
description: 'Course contributor with limited permissions',
permissions: ['Edit course content', 'Create activities', 'Cannot manage contributors', 'Cannot change access']
}
]
return (
<div className="min-h-screen bg-[#f8f8f8] flex items-center justify-center p-6 pt-16 w-full">
<div className="w-full max-w-none mx-auto px-4 sm:px-6 lg:px-8">
{/* Top Icon */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-full shadow-sm border border-gray-200 mb-6">
<Shield className="w-8 h-8 text-blue-500" />
</div>
</motion.div>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-center mb-12"
>
<Link
href={getUriWithOrg(org?.slug, '/dash')}
className="inline-flex items-center space-x-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="font-medium">Back to Dashboard</span>
</Link>
<div className="flex items-center justify-center space-x-3 mb-4">
<h1 className="text-4xl font-bold text-gray-900">Authorizations & Rights Guide</h1>
</div>
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
Understanding LearnHouse permissions, roles, and access controls based on RBAC system
</p>
</motion.div>
{/* Role Hierarchy Section */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mb-16"
>
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center space-x-2">
<Crown className="w-6 h-6 text-purple-600" />
<span>Role Hierarchy</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{roleHierarchy.map((role, index) => (
<motion.div
key={role.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
className={`bg-white rounded-xl border ${role.color} shadow-sm hover:shadow-lg transition-all duration-200 p-6 text-center`}
>
<div className="flex items-center justify-center space-x-3 mb-4">
{role.icon}
<h3 className="text-lg font-semibold text-gray-900">{role.name}</h3>
</div>
<p className="text-gray-600 text-sm mb-4">{role.description}</p>
<ul className="space-y-2 text-left">
{role.permissions.map((permission, permIndex) => (
<li key={permIndex} className="flex items-center space-x-2 text-sm text-gray-700">
<CheckCircle className="w-3 h-3 text-green-600 flex-shrink-0" />
<span>{permission}</span>
</li>
))}
</ul>
</motion.div>
))}
</div>
</motion.section>
{/* Course Ownership Types */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-16"
>
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center space-x-2">
<Users className="w-6 h-6 text-blue-600" />
<span>Course Ownership Types</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
{courseOwnershipTypes.map((type, index) => (
<motion.div
key={type.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className={`bg-white rounded-xl border ${type.color} shadow-sm hover:shadow-lg transition-all duration-200 p-6 text-center`}
>
<div className="flex items-center justify-center space-x-3 mb-4">
{type.icon}
<h3 className="text-lg font-semibold text-gray-900">{type.name}</h3>
</div>
<p className="text-gray-600 text-sm mb-4">{type.description}</p>
<ul className="space-y-2 text-left">
{type.permissions.map((permission, permIndex) => (
<li key={permIndex} className="flex items-center space-x-2 text-sm text-gray-700">
<CheckCircle className="w-3 h-3 text-green-600 flex-shrink-0" />
<span>{permission}</span>
</li>
))}
</ul>
</motion.div>
))}
</div>
</motion.section>
</div>
</div>
)
}
export default RightsDocumentation

View file

@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
import Link from 'next/link' import Link from 'next/link'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config' 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 BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' 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 OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd' import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups' import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups'
import OrgRoles from '@components/Dashboard/Pages/Users/OrgRoles/OrgRoles'
export type SettingsParams = { export type SettingsParams = {
subpage: string subpage: string
@ -43,6 +44,10 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
setH1Label('UserGroups') setH1Label('UserGroups')
setH2Label('Create and manage user groups') setH2Label('Create and manage user groups')
} }
if (params.subpage == 'roles') {
setH1Label('Roles')
setH2Label('Create and manage roles with specific permissions')
}
} }
useEffect(() => { useEffect(() => {
@ -112,6 +117,23 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
</div> </div>
</div> </div>
</Link> </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 <Link
href={ href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/signups` getUriWithOrg(params.orgslug, '') + `/dash/users/settings/signups`
@ -160,6 +182,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
{params.subpage == 'signups' ? <OrgAccess /> : ''} {params.subpage == 'signups' ? <OrgAccess /> : ''}
{params.subpage == 'add' ? <OrgUsersAdd /> : ''} {params.subpage == 'add' ? <OrgUsersAdd /> : ''}
{params.subpage == 'usergroups' ? <OrgUserGroups /> : ''} {params.subpage == 'usergroups' ? <OrgUserGroups /> : ''}
{params.subpage == 'roles' ? <OrgRoles /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -9,6 +9,7 @@ import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import EmptyThumbnailImage from '../../../public/empty_thumbnail.png' import EmptyThumbnailImage from '../../../public/empty_thumbnail.png'
import { BookOpen } from 'lucide-react'
export function CourseOverviewTop({ export function CourseOverviewTop({
params, params,
@ -57,7 +58,14 @@ export function CourseOverviewTop({
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center space-x-4">
<Link
href={getUriWithOrg(org?.slug, '/dash/documentation/rights')}
className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased p-2 px-5 font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center"
>
<BookOpen className="w-4 h-4" />
<span>Rights Guide</span>
</Link>
<SaveState orgslug={params.orgslug} /> <SaveState orgslug={params.orgslug} />
</div> </div>
</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

@ -3,40 +3,193 @@ import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
interface Role { interface Role {
org: { id: number }; org: { id: number; org_uuid: string };
role: { id: number; role_uuid: string }; role: {
id: number;
role_uuid: string;
rights?: {
[key: string]: {
[key: string]: boolean;
};
};
};
} }
function useAdminStatus() { 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;
};
}
interface UseAdminStatusReturn {
isAdmin: boolean | null;
loading: boolean;
userRoles: Role[];
rights: Rights | null;
}
function useAdminStatus(): UseAdminStatusReturn {
const session = useLHSession() as any; const session = useLHSession() as any;
const org = useOrg() as any; const org = useOrg() as any;
const [isAdmin, setIsAdmin] = useState<boolean | null>(null); const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [rights, setRights] = useState<Rights | null>(null);
const userRoles = useMemo(() => session?.data?.roles || [], [session?.data?.roles]); const userRoles = useMemo(() => session?.data?.roles || [], [session?.data?.roles]);
useEffect(() => { useEffect(() => {
if (session.status === 'authenticated' && org?.id) { if (session.status === 'authenticated' && org?.id) {
const isAdminVar = userRoles.some((role: Role) => { // Extract rights from the backend session data
return ( const extractRightsFromRoles = (): Rights | null => {
role.org.id === org.id && if (!userRoles || userRoles.length === 0) return null;
(
role.role.id === 1 || // Find roles for the current organization
role.role.id === 2 || const orgRoles = userRoles.filter((role: Role) => role.org.id === org.id);
role.role.role_uuid === 'role_global_admin' || if (orgRoles.length === 0) return null;
role.role.role_uuid === 'role_global_maintainer'
) // Merge rights from all roles for this organization
); const mergedRights: 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
}
};
// Merge rights from all roles
orgRoles.forEach((role: Role) => {
if (role.role.rights) {
Object.keys(role.role.rights).forEach((resourceType) => {
if (mergedRights[resourceType as keyof Rights]) {
Object.keys(role.role.rights![resourceType]).forEach((action) => {
if (role.role.rights![resourceType][action] === true) {
(mergedRights[resourceType as keyof Rights] as any)[action] = true;
}
});
}
});
}
});
return mergedRights;
};
const extractedRights = extractRightsFromRoles();
setRights(extractedRights);
// User is admin only if they have dashboard access
const isAdminVar = extractedRights?.dashboard?.action_access === true;
setIsAdmin(isAdminVar); setIsAdmin(isAdminVar);
setLoading(false); // Set loading to false once the status is determined
setLoading(false);
} else { } else {
setIsAdmin(false); setIsAdmin(false);
setLoading(false); // Set loading to false if not authenticated or org not found setRights(null);
setLoading(false);
} }
}, [session.status, userRoles, org.id]); }, [session.status, userRoles, org.id]);
return { isAdmin, loading }; return { isAdmin, loading, userRoles, rights };
} }
export default useAdminStatus; export default useAdminStatus;

View file

@ -0,0 +1,64 @@
'use client'
import { getAPIUrl } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests'
import useSWR from 'swr'
import { useLHSession } from '@components/Contexts/LHSessionContext'
export interface CourseRights {
course_uuid: string
user_id: number
is_anonymous: boolean
permissions: {
read: boolean
create: boolean
update: boolean
delete: boolean
create_content: boolean
update_content: boolean
delete_content: boolean
manage_contributors: boolean
manage_access: boolean
grade_assignments: boolean
mark_activities_done: boolean
create_certifications: boolean
}
ownership: {
is_owner: boolean
is_creator: boolean
is_maintainer: boolean
is_contributor: boolean
authorship_status: string
}
roles: {
is_admin: boolean
is_maintainer_role: boolean
is_instructor: boolean
is_user: boolean
}
}
export function useCourseRights(courseuuid: string) {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { data: rights, error, isLoading } = useSWR<CourseRights>(
courseuuid ? `${getAPIUrl()}courses/${courseuuid}/rights` : null,
(url: string) => swrFetcher(url, access_token)
)
return {
rights,
error,
isLoading,
hasPermission: (permission: keyof CourseRights['permissions']) => {
return rights?.permissions?.[permission] ?? false
},
hasRole: (role: keyof CourseRights['roles']) => {
return rights?.roles?.[role] ?? false
},
isOwner: rights?.ownership?.is_owner ?? false,
isCreator: rights?.ownership?.is_creator ?? false,
isMaintainer: rights?.ownership?.is_maintainer ?? false,
isContributor: rights?.ownership?.is_contributor ?? false
}
}

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 { FormMessage } from '@radix-ui/react-form'
import { getAPIUrl } from '@services/config/config' import { getAPIUrl } from '@services/config/config'
import { updateUserRole } from '@services/organizations/orgs' import { updateUserRole } from '@services/organizations/orgs'
import { swrFetcher } from '@services/utils/ts/requests'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { BarLoader } from 'react-spinners' import { BarLoader } from 'react-spinners'
import { mutate } from 'swr' import { mutate } from 'swr'
import useSWR from 'swr'
interface Props { interface Props {
user: any user: any
@ -25,13 +27,19 @@ interface Props {
function RolesUpdate(props: Props) { function RolesUpdate(props: Props) {
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() as any const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token;
const [isSubmitting, setIsSubmitting] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false)
const [assignedRole, setAssignedRole] = React.useState( const [assignedRole, setAssignedRole] = React.useState(
props.alreadyAssignedRole props.alreadyAssignedRole
) )
const [error, setError] = React.useState(null) as any 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>) => { const handleAssignedRole = (event: React.ChangeEvent<any>) => {
setError(null) setError(null)
setAssignedRole(event.target.value) setAssignedRole(event.target.value)
@ -80,10 +88,20 @@ function RolesUpdate(props: Props) {
defaultValue={assignedRole} defaultValue={assignedRole}
className="border border-gray-300 rounded-md p-2" className="border border-gray-300 rounded-md p-2"
required required
disabled={!roles || rolesError}
> >
<option value="role_global_admin">Admin </option> {!roles || rolesError ? (
<option value="role_global_maintainer">Maintainer</option> <option value="">Loading roles...</option>
<option value="role_global_user">User</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> </select>
</Form.Control> </Form.Control>
</FormField> </FormField>

View file

@ -47,12 +47,14 @@ const Modal = (params: ModalParams) => {
<DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger> <DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger>
)} )}
<DialogContent className={cn( <DialogContent className={cn(
"overflow-auto",
"w-[95vw] max-w-[95vw]", "w-[95vw] max-w-[95vw]",
"max-h-[90vh]", "max-h-[90vh]",
"p-4", "p-3 sm:p-4 md:p-6",
// Tablet and up // Mobile-first responsive design
"md:w-auto md:max-w-[90vw] md:p-6", "sm:w-[90vw] sm:max-w-[90vw]",
"md:w-auto md:max-w-[90vw]",
"lg:max-w-[85vw]",
"xl:max-w-[80vw]",
getMinHeight(), getMinHeight(),
getMinWidth(), getMinWidth(),
params.customHeight, params.customHeight,
@ -60,15 +62,17 @@ const Modal = (params: ModalParams) => {
)}> )}>
{params.dialogTitle && params.dialogDescription && ( {params.dialogTitle && params.dialogDescription && (
<DialogHeader className="text-center flex flex-col space-y-0.5 w-full"> <DialogHeader className="text-center flex flex-col space-y-0.5 w-full">
<DialogTitle>{params.dialogTitle}</DialogTitle> <DialogTitle className="text-lg sm:text-xl md:text-2xl">{params.dialogTitle}</DialogTitle>
<DialogDescription>{params.dialogDescription}</DialogDescription> <DialogDescription className="text-sm sm:text-base">{params.dialogDescription}</DialogDescription>
</DialogHeader> </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">
{params.dialogContent} <div className="pr-2">
{params.dialogContent}
</div>
</div> </div>
{(params.dialogClose || params.addDefCloseButton) && ( {(params.dialogClose || params.addDefCloseButton) && (
<DialogFooter> <DialogFooter className="flex flex-col sm:flex-row gap-2 sm:gap-0">
{params.dialogClose} {params.dialogClose}
{params.addDefCloseButton && ( {params.addDefCloseButton && (
<ButtonBlack type="submit"> <ButtonBlack type="submit">

View file

@ -1,23 +1,138 @@
'use client' 'use client'
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Link from 'next/link' import Link from 'next/link'
import { Package2, Settings } from 'lucide-react' import { Package2, Settings, Crown, Shield, User, Users, Building, LogOut, User as UserIcon, Home, ChevronDown } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import useAdminStatus from '@components/Hooks/useAdminStatus' import useAdminStatus from '@components/Hooks/useAdminStatus'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { getUriWithoutOrg } from '@services/config/config' import { getUriWithoutOrg } from '@services/config/config'
import Tooltip from '@components/Objects/StyledElements/Tooltip/Tooltip' import Tooltip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/dropdown-menu"
import { signOut } from 'next-auth/react'
interface RoleInfo {
name: string;
icon: React.ReactNode;
bgColor: string;
textColor: string;
description: string;
}
interface CustomRoleInfo {
name: string;
description?: string;
}
export const HeaderProfileBox = () => { export const HeaderProfileBox = () => {
const session = useLHSession() as any const session = useLHSession() as any
const isUserAdmin = useAdminStatus() const { isAdmin, loading, userRoles, rights } = useAdminStatus()
const org = useOrg() as any const org = useOrg() as any
useEffect(() => { } useEffect(() => { }
, [session]) , [session])
const userRoleInfo = useMemo((): RoleInfo | null => {
if (!userRoles || userRoles.length === 0) return null;
// Find the highest priority role for the current organization
const orgRoles = userRoles.filter((role: any) => role.org.id === org?.id);
if (orgRoles.length === 0) return null;
// Sort by role priority (admin > maintainer > instructor > user)
const sortedRoles = orgRoles.sort((a: any, b: any) => {
const getRolePriority = (role: any) => {
if (role.role.role_uuid === 'role_global_admin' || role.role.id === 1) return 4;
if (role.role.role_uuid === 'role_global_maintainer' || role.role.id === 2) return 3;
if (role.role.role_uuid === 'role_global_instructor' || role.role.id === 3) return 2;
return 1;
};
return getRolePriority(b) - getRolePriority(a);
});
const highestRole = sortedRoles[0];
// Define role configurations based on actual database roles
const roleConfigs: { [key: string]: RoleInfo } = {
'role_global_admin': {
name: 'ADMIN',
icon: <Crown size={12} />,
bgColor: 'bg-purple-600',
textColor: 'text-white',
description: 'Full platform control with all permissions'
},
'role_global_maintainer': {
name: 'MAINTAINER',
icon: <Shield size={12} />,
bgColor: 'bg-blue-600',
textColor: 'text-white',
description: 'Mid-level manager with wide permissions'
},
'role_global_instructor': {
name: 'INSTRUCTOR',
icon: <Users size={12} />,
bgColor: 'bg-green-600',
textColor: 'text-white',
description: 'Can manage their own content'
},
'role_global_user': {
name: 'USER',
icon: <User size={12} />,
bgColor: 'bg-gray-500',
textColor: 'text-white',
description: 'Read-Only Learner'
}
};
// Determine role based on role_uuid or id
let roleKey = 'role_global_user'; // default
if (highestRole.role.role_uuid) {
roleKey = highestRole.role.role_uuid;
} else if (highestRole.role.id === 1) {
roleKey = 'role_global_admin';
} else if (highestRole.role.id === 2) {
roleKey = 'role_global_maintainer';
} else if (highestRole.role.id === 3) {
roleKey = 'role_global_instructor';
}
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 ( return (
<ProfileArea> <ProfileArea>
{session.status == 'unauthenticated' && ( {session.status == 'unauthenticated' && (
@ -35,35 +150,87 @@ export const HeaderProfileBox = () => {
)} )}
{session.status == 'authenticated' && ( {session.status == 'authenticated' && (
<AccountArea className="space-x-0"> <AccountArea className="space-x-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-3">
<div className='flex items-center space-x-2' > <DropdownMenu>
<p className='text-sm capitalize'>{session.data.user.username}</p> <DropdownMenuTrigger asChild>
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>} <button className="cursor-pointer flex items-center space-x-3 hover:bg-gray-50 rounded-lg p-2 transition-colors">
</div> <UserAvatar border="border-2" rounded="rounded-lg" width={30} />
<div className="flex flex-col space-y-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Tooltip <p className='text-sm font-semibold text-gray-900 capitalize'>{session.data.user.username}</p>
content={"Your Owned Courses"} {userRoleInfo && userRoleInfo.name !== 'USER' && (
sideOffset={15} <Tooltip
side="bottom" content={userRoleInfo.description}
> sideOffset={15}
<Link className="text-gray-600" href={'/dash/user-account/owned'}> side="bottom"
<Package2 size={14} /> >
</Link> <div className={`text-[6px] ${userRoleInfo.bgColor} ${userRoleInfo.textColor} px-1 py-0.5 font-medium rounded-full flex items-center gap-0.5 w-fit`}>
</Tooltip> {userRoleInfo.icon}
<Tooltip {userRoleInfo.name}
content={"Your Settings"} </div>
sideOffset={15} </Tooltip>
side="bottom" )}
> {/* Custom roles */}
<Link className="text-gray-600" href={'/dash'}> {customRoles.map((customRole, index) => (
<Settings size={14} /> <Tooltip
</Link> key={index}
</Tooltip> content={customRole.description || `Custom role: ${customRole.name}`}
</div> sideOffset={15}
<div className="py-4"> side="bottom"
<UserAvatar border="border-4" rounded="rounded-lg" width={30} /> >
</div> <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>
<ChevronDown size={16} className="text-gray-500" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>
<div className="flex items-center space-x-2">
<UserAvatar border="border-2" rounded="rounded-full" width={24} />
<div>
<p className="text-sm font-medium">{session.data.user.username}</p>
<p className="text-xs text-gray-500 capitalize">{session.data.user.email}</p>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{rights?.dashboard?.action_access && (
<DropdownMenuItem asChild>
<Link href="/dash" className="flex items-center space-x-2">
<Shield size={16} />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/dash/user-account/settings/general" className="flex items-center space-x-2">
<UserIcon size={16} />
<span>User Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dash/user-account/owned" className="flex items-center space-x-2">
<Package2 size={16} />
<span>My Courses</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/' })}
className="flex items-center space-x-2 text-red-600 focus:text-red-600"
>
<LogOut size={16} />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</AccountArea> </AccountArea>
)} )}

View file

@ -16,39 +16,39 @@
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@icons-pack/react-simple-icons": "^10.2.0", "@icons-pack/react-simple-icons": "^10.2.0",
"@radix-ui/colors": "^0.1.9", "@radix-ui/colors": "^0.1.9",
"@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-form": "^0.0.3", "@radix-ui/react-form": "^0.0.3",
"@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.2.7",
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^2.11.7", "@tiptap/core": "^2.26.1",
"@tiptap/extension-bullet-list": "^2.11.7", "@tiptap/extension-bullet-list": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.11.7", "@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-heading": "^2.12.0", "@tiptap/extension-heading": "^2.26.1",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.26.1",
"@tiptap/extension-list-item": "^2.11.7", "@tiptap/extension-list-item": "^2.26.1",
"@tiptap/extension-ordered-list": "^2.11.7", "@tiptap/extension-ordered-list": "^2.26.1",
"@tiptap/extension-table": "^2.11.7", "@tiptap/extension-table": "^2.26.1",
"@tiptap/extension-table-cell": "^2.11.7", "@tiptap/extension-table-cell": "^2.26.1",
"@tiptap/extension-table-header": "^2.11.7", "@tiptap/extension-table-header": "^2.26.1",
"@tiptap/extension-table-row": "^2.11.7", "@tiptap/extension-table-row": "^2.26.1",
"@tiptap/extension-youtube": "^2.11.7", "@tiptap/extension-youtube": "^2.26.1",
"@tiptap/html": "^2.11.7", "@tiptap/html": "^2.26.1",
"@tiptap/pm": "^2.11.7", "@tiptap/pm": "^2.26.1",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.26.1",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.26.1",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/html2canvas": "^1.0.0", "@types/html2canvas": "^1.0.0",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
@ -57,16 +57,16 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"currency-codes": "^2.2.0", "currency-codes": "^2.2.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.5", "dompurify": "^3.2.6",
"emblor": "^1.4.8", "emblor": "^1.4.8",
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^12.6.3", "framer-motion": "^12.23.12",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
"jspdf-html2canvas": "^1.5.2", "jspdf-html2canvas": "^1.5.2",
"katex": "^0.16.21", "katex": "^0.16.22",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"next": "15.3.5", "next": "15.3.5",
@ -81,23 +81,23 @@
"react-confetti": "^6.4.0", "react-confetti": "^6.4.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-katex": "^3.0.1", "react-katex": "^3.1.0",
"react-plyr": "^2.2.0", "react-plyr": "^2.2.0",
"react-spinners": "^0.13.8", "react-spinners": "^0.13.8",
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"styled-components": "^6.1.17", "styled-components": "^6.1.19",
"swr": "^2.3.3", "swr": "^2.3.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unsplash-js": "^7.0.19", "unsplash-js": "^7.0.19",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"yup": "^1.6.1" "yup": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.3", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "20.12.2", "@types/node": "20.12.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "19.0.10", "@types/react": "19.0.10",
@ -106,11 +106,11 @@
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"eslint": "^9.24.0", "eslint": "^9.32.0",
"eslint-config-next": "15.2.1", "eslint-config-next": "15.2.1",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.11",
"typescript": "5.4.4" "typescript": "5.4.4"
}, },
"pnpm": { "pnpm": {

2895
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -161,11 +161,20 @@ export async function bulkAddContributors(course_uuid: string, data: any, access
return res return res
} }
export async function bulkRemoveContributors(course_uuid: string, data: any, access_token:string | null | undefined) { export async function bulkRemoveContributors(course_uuid: string, data: any, access_token: string | null | undefined) {
const result: any = await fetch( const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/bulk-remove-contributors`, `${getAPIUrl()}courses/${course_uuid}/bulk-remove-contributors`,
RequestBodyWithAuthHeader('PUT', data, null,access_token || undefined) RequestBodyWithAuthHeader('PUT', data, null, access_token || undefined)
) )
const res = await getResponseMetadata(result) const res = await errorHandling(result)
return res
}
export async function getCourseRights(course_uuid: string, access_token: string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/rights`,
RequestBodyWithAuthHeader('GET', null, null, access_token || undefined)
)
const res = await errorHandling(result)
return res return res
} }

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
}