mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #500 from learnhouse/feat/rbac-improvements
RBAC Improvements
This commit is contained in:
commit
6a13703560
50 changed files with 5980 additions and 2237 deletions
132
.dockerignore
Normal file
132
.dockerignore
Normal 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
|
||||||
87
Dockerfile
87
Dockerfile
|
|
@ -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.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Remove .env files from the final image
|
||||||
|
# This is a good practice to avoid leaking sensitive data
|
||||||
|
# Learn more about it in the Next.js documentation: https://nextjs.org/docs/basic-features/environment-variables
|
||||||
RUN rm -f .env*
|
RUN rm -f .env*
|
||||||
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile && pnpm run build; \
|
|
||||||
|
RUN \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Final image
|
# Production image, copy all the files and run next
|
||||||
FROM base as runner
|
FROM frontend-base AS frontend-runner
|
||||||
RUN addgroup --system --gid 1001 system \
|
WORKDIR /app
|
||||||
&& adduser --system --uid 1001 app \
|
|
||||||
&& mkdir .next \
|
# Install curl
|
||||||
&& chown app:system .next
|
RUN apk add --no-cache curl
|
||||||
COPY --from=deps /app/web/public ./app/web/public
|
|
||||||
COPY --from=deps --chown=app:system /app/web/.next/standalone ./app/web/
|
ENV NODE_ENV production
|
||||||
COPY --from=deps --chown=app:system /app/web/.next/static ./app/web/.next/static
|
# 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"]
|
||||||
|
|
@ -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=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Only enable logfire if explicitly configured
|
||||||
|
if learnhouse_config.general_config.logfire_enabled:
|
||||||
logfire.configure(console=False, service_name=learnhouse_config.site_name,)
|
logfire.configure(console=False, service_name=learnhouse_config.site_name,)
|
||||||
logfire.instrument_fastapi(app)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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 ✅")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
410
apps/api/src/security/courses_security.py
Normal file
410
apps/api/src/security/courses_security.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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,13 +106,29 @@ 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:
|
||||||
|
# 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
|
return True
|
||||||
|
|
||||||
# If we get here, no role granted the permission
|
# If we get here, no role granted the permission
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ##
|
|
||||||
|
|
|
||||||
|
|
@ -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 ##
|
|
||||||
|
|
|
||||||
|
|
@ -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()}"
|
||||||
|
|
||||||
|
|
@ -95,8 +97,6 @@ async def create_documentpdf_activity(
|
||||||
"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 ##
|
|
||||||
|
|
|
||||||
|
|
@ -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 ##
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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 ##
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +96,23 @@ 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:
|
||||||
|
# Check if user has access to this course
|
||||||
|
statement = select(Course).where(Course.id == course_id)
|
||||||
|
course = db_session.exec(statement).first()
|
||||||
|
|
||||||
|
if course:
|
||||||
|
# Verify user has read access to the course before adding it to collection
|
||||||
|
try:
|
||||||
|
await courses_rbac_check_for_collections(request, course.course_uuid, current_user, "read", db_session)
|
||||||
|
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_course = CollectionCourse(
|
||||||
collection_id=int(collection.id), # type: ignore
|
collection_id=int(collection.id), # type: ignore
|
||||||
course_id=course_id,
|
course_id=course_id,
|
||||||
|
|
@ -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 ##
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VERIFICATION 9: Handle ID sequence issue (existing logic)
|
||||||
|
# ============================================================================
|
||||||
|
try:
|
||||||
|
db_session.add(role)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(role)
|
||||||
|
except IntegrityError as e:
|
||||||
|
if "duplicate key value violates unique constraint" in str(e) and "role_pkey" in str(e):
|
||||||
|
# Handle the sequence issue by finding the next available ID
|
||||||
|
db_session.rollback()
|
||||||
|
|
||||||
|
# Get the maximum ID from the role table using raw SQL
|
||||||
|
result = db_session.execute(text("SELECT COALESCE(MAX(id), 0) as max_id FROM role"))
|
||||||
|
max_id_result = result.scalar()
|
||||||
|
max_id = max_id_result if max_id_result is not None else 0
|
||||||
|
|
||||||
|
# Set the next available ID
|
||||||
|
role.id = max_id + 1
|
||||||
|
|
||||||
|
# Try to insert again
|
||||||
db_session.add(role)
|
db_session.add(role)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(role)
|
db_session.refresh(role)
|
||||||
|
|
||||||
role = RoleRead(**role.model_dump())
|
# Update the sequence to the correct value for future inserts
|
||||||
|
try:
|
||||||
|
# Use raw SQL to update the sequence
|
||||||
|
db_session.execute(text(f"SELECT setval('role_id_seq', {max_id + 1}, true)"))
|
||||||
|
db_session.commit()
|
||||||
|
except Exception:
|
||||||
|
# If sequence doesn't exist or can't be updated, that's okay
|
||||||
|
# The manual ID assignment above will handle it
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Re-raise the original exception if it's not the sequence issue
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Create RoleRead object with all required fields
|
||||||
|
role_data = role.model_dump()
|
||||||
|
# Ensure org_id is properly handled
|
||||||
|
if role_data.get('org_id') is None:
|
||||||
|
role_data['org_id'] = 0
|
||||||
|
role = RoleRead(**role_data)
|
||||||
|
|
||||||
return role
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h1 className="text-3xl font-bold mb-4 sm:mb-0">Courses</h1>
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
<div className="flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear opacity-30 cursor-not-allowed">
|
||||||
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content'
|
|
||||||
? 'border-b-4'
|
|
||||||
: 'opacity-50'
|
|
||||||
} cursor-pointer`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2.5 mx-2">
|
<div className="flex items-center space-x-2.5 mx-2">
|
||||||
<GalleryVerticalEnd size={16} />
|
<IconComponent size={16} />
|
||||||
<div>Content</div>
|
<div>{tab.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</ToolTip>
|
||||||
<Link
|
)
|
||||||
href={
|
|
||||||
getUriWithOrg(params.orgslug, '') +
|
|
||||||
`/dash/courses/course/${params.courseuuid}/access`
|
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.key}
|
||||||
|
href={getUriWithOrg(params.orgslug, '') + tab.href}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${
|
||||||
|
isActive ? 'border-b-4' : 'opacity-50 hover:opacity-75'
|
||||||
|
} cursor-pointer`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2.5 mx-2">
|
||||||
|
<IconComponent size={16} />
|
||||||
|
<div>{tab.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function DocumentationLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
217
apps/web/app/orgs/[orgslug]/dash/documentation/rights/page.tsx
Normal file
217
apps/web/app/orgs/[orgslug]/dash/documentation/rights/page.tsx
Normal 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
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
295
apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx
Normal file
295
apps/web/components/Dashboard/Pages/Users/OrgRoles/OrgRoles.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
'use client'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import AddRole from '@components/Objects/Modals/Dash/OrgRoles/AddRole'
|
||||||
|
import EditRole from '@components/Objects/Modals/Dash/OrgRoles/EditRole'
|
||||||
|
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||||
|
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import { deleteRole } from '@services/roles/roles'
|
||||||
|
import { swrFetcher } from '@services/utils/ts/requests'
|
||||||
|
import { Pencil, Shield, Users, X, Globe } from 'lucide-react'
|
||||||
|
import React from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import useSWR, { mutate } from 'swr'
|
||||||
|
|
||||||
|
function OrgRoles() {
|
||||||
|
const org = useOrg() as any
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token;
|
||||||
|
const [createRoleModal, setCreateRoleModal] = React.useState(false)
|
||||||
|
const [editRoleModal, setEditRoleModal] = React.useState(false)
|
||||||
|
const [selectedRole, setSelectedRole] = React.useState(null) as any
|
||||||
|
|
||||||
|
const { data: roles } = useSWR(
|
||||||
|
org ? `${getAPIUrl()}roles/org/${org.id}` : null,
|
||||||
|
(url) => swrFetcher(url, access_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteRoleUI = async (role_id: any) => {
|
||||||
|
const toastId = toast.loading("Deleting...");
|
||||||
|
const res = await deleteRole(role_id, org.id, access_token)
|
||||||
|
if (res.status === 200) {
|
||||||
|
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||||
|
toast.success("Deleted role", {id:toastId})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error('Error deleting role', {id:toastId})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditRoleModal = (role: any) => {
|
||||||
|
setSelectedRole(role)
|
||||||
|
setEditRoleModal(!editRoleModal)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRightsSummary = (rights: any) => {
|
||||||
|
if (!rights) return 'No permissions'
|
||||||
|
|
||||||
|
const totalPermissions = Object.keys(rights).reduce((acc, key) => {
|
||||||
|
if (typeof rights[key] === 'object') {
|
||||||
|
return acc + Object.keys(rights[key]).filter(k => rights[key][k] === true).length
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return `${totalPermissions} permissions`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a role is system-wide (TYPE_GLOBAL or role_uuid starts with role_global_)
|
||||||
|
const isSystemRole = (role: any) => {
|
||||||
|
// Check for role_type field first
|
||||||
|
if (role.role_type === 'TYPE_GLOBAL') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for role_uuid starting with role_global_
|
||||||
|
if (role.role_uuid && role.role_uuid.startsWith('role_global_')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common system role IDs (1-4 are typically system roles)
|
||||||
|
if (role.id && [1, 2, 3, 4].includes(role.id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the role name indicates it's a system role
|
||||||
|
if (role.name && ['Admin', 'Maintainer', 'Instructor', 'User'].includes(role.name)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-6"></div>
|
||||||
|
<div className="mx-4 sm:mx-6 lg:mx-10 bg-white rounded-xl nice-shadow px-3 sm:px-4 py-4">
|
||||||
|
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||||
|
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Manage Roles & Permissions</h1>
|
||||||
|
<h2 className="text-gray-500 text-xs sm:text-sm">
|
||||||
|
{' '}
|
||||||
|
Roles define what users can do within your organization. Create custom roles with specific permissions for different user types.{' '}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile view - Cards */}
|
||||||
|
<div className="block sm:hidden space-y-3">
|
||||||
|
{roles?.map((role: any) => {
|
||||||
|
const isSystem = isSystemRole(role)
|
||||||
|
return (
|
||||||
|
<div key={role.id} className="bg-white border border-gray-200 rounded-lg p-4 space-y-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Shield className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="font-medium text-sm">{role.name}</span>
|
||||||
|
{isSystem && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||||
|
<Globe className="w-3 h-3 mr-1" />
|
||||||
|
System-wide
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{getRightsSummary(role.rights)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm">{role.description || 'No description'}</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{!isSystem ? (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isDialogOpen={
|
||||||
|
editRoleModal &&
|
||||||
|
selectedRole?.id === role.id
|
||||||
|
}
|
||||||
|
onOpenChange={() =>
|
||||||
|
handleEditRoleModal(role)
|
||||||
|
}
|
||||||
|
minHeight="lg"
|
||||||
|
minWidth='xl'
|
||||||
|
customWidth="max-w-7xl"
|
||||||
|
dialogContent={
|
||||||
|
<EditRole
|
||||||
|
role={role}
|
||||||
|
setEditRoleModal={setEditRoleModal}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
dialogTitle="Edit Role"
|
||||||
|
dialogDescription={
|
||||||
|
'Edit the role permissions and details'
|
||||||
|
}
|
||||||
|
dialogTrigger={
|
||||||
|
<button className="flex-1 flex justify-center space-x-2 hover:cursor-pointer p-2 bg-black rounded-md font-bold items-center text-sm text-white hover:bg-gray-800 transition-colors shadow-sm">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfirmationModal
|
||||||
|
confirmationButtonText="Delete Role"
|
||||||
|
confirmationMessage="This action cannot be undone. All users with this role will lose their permissions. Are you sure you want to delete this role?"
|
||||||
|
dialogTitle={'Delete Role ?'}
|
||||||
|
dialogTrigger={
|
||||||
|
<button className="flex-1 flex justify-center space-x-2 hover:cursor-pointer p-2 bg-red-600 rounded-md font-bold items-center text-sm text-white hover:bg-red-700 transition-colors shadow-sm">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
functionToExecute={() => {
|
||||||
|
deleteRoleUI(role.id)
|
||||||
|
}}
|
||||||
|
status="warning"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop view - Table */}
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
|
||||||
|
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
|
||||||
|
<tr className="font-bolder text-sm">
|
||||||
|
<th className="py-3 px-4">Role Name</th>
|
||||||
|
<th className="py-3 px-4">Description</th>
|
||||||
|
<th className="py-3 px-4">Permissions</th>
|
||||||
|
<th className="py-3 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<>
|
||||||
|
<tbody className="mt-5 bg-white rounded-md">
|
||||||
|
{roles?.map((role: any) => {
|
||||||
|
const isSystem = isSystemRole(role)
|
||||||
|
return (
|
||||||
|
<tr key={role.id} className="border-b border-gray-100 text-sm hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Shield className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="font-medium">{role.name}</span>
|
||||||
|
{isSystem && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||||
|
<Globe className="w-3 h-3 mr-1" />
|
||||||
|
System-wide
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-600">{role.description || 'No description'}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{getRightsSummary(role.rights)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{!isSystem ? (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isDialogOpen={
|
||||||
|
editRoleModal &&
|
||||||
|
selectedRole?.id === role.id
|
||||||
|
}
|
||||||
|
onOpenChange={() =>
|
||||||
|
handleEditRoleModal(role)
|
||||||
|
}
|
||||||
|
minHeight="lg"
|
||||||
|
minWidth='xl'
|
||||||
|
customWidth="max-w-7xl"
|
||||||
|
dialogContent={
|
||||||
|
<EditRole
|
||||||
|
role={role}
|
||||||
|
setEditRoleModal={setEditRoleModal}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
dialogTitle="Edit Role"
|
||||||
|
dialogDescription={
|
||||||
|
'Edit the role permissions and details'
|
||||||
|
}
|
||||||
|
dialogTrigger={
|
||||||
|
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-black rounded-md font-bold items-center text-sm text-white hover:bg-gray-800 transition-colors shadow-sm">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfirmationModal
|
||||||
|
confirmationButtonText="Delete Role"
|
||||||
|
confirmationMessage="This action cannot be undone. All users with this role will lose their permissions. Are you sure you want to delete this role?"
|
||||||
|
dialogTitle={'Delete Role ?'}
|
||||||
|
dialogTrigger={
|
||||||
|
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-red-600 rounded-md font-bold items-center text-sm text-white hover:bg-red-700 transition-colors shadow-sm">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
functionToExecute={() => {
|
||||||
|
deleteRoleUI(role.id)
|
||||||
|
}}
|
||||||
|
status="warning"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex justify-end mt-3 mr-2'>
|
||||||
|
<Modal
|
||||||
|
isDialogOpen={createRoleModal}
|
||||||
|
onOpenChange={() => setCreateRoleModal(!createRoleModal)}
|
||||||
|
minHeight="no-min"
|
||||||
|
minWidth='xl'
|
||||||
|
customWidth="max-w-7xl"
|
||||||
|
dialogContent={
|
||||||
|
<AddRole
|
||||||
|
setCreateRoleModal={setCreateRoleModal}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
dialogTitle="Create a Role"
|
||||||
|
dialogDescription={
|
||||||
|
'Create a new role with specific permissions'
|
||||||
|
}
|
||||||
|
dialogTrigger={
|
||||||
|
<button className="flex space-x-2 hover:cursor-pointer p-2 sm:p-1 sm:px-3 bg-black rounded-md font-bold items-center text-sm text-white w-full sm:w-auto justify-center hover:bg-gray-800 transition-colors shadow-sm">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>Create a Role</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrgRoles
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
64
apps/web/components/Hooks/useCourseRights.tsx
Normal file
64
apps/web/components/Hooks/useCourseRights.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
599
apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx
Normal file
599
apps/web/components/Objects/Modals/Dash/OrgRoles/AddRole.tsx
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
'use client'
|
||||||
|
import FormLayout, {
|
||||||
|
FormField,
|
||||||
|
FormLabelAndMessage,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
} from '@components/Objects/StyledElements/Form/Form'
|
||||||
|
import * as Form from '@radix-ui/react-form'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import React from 'react'
|
||||||
|
import { createRole } from '@services/roles/roles'
|
||||||
|
import { mutate } from 'swr'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Shield, BookOpen, Users, UserCheck, FolderOpen, Building, FileText, Activity, Settings, Monitor, CheckSquare, Square } from 'lucide-react'
|
||||||
|
|
||||||
|
type AddRoleProps = {
|
||||||
|
setCreateRoleModal: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rights {
|
||||||
|
courses: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_read_own: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_update_own: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
action_delete_own: boolean;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
usergroups: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
collections: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
organizations: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
coursechapters: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
activities: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
roles: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
dashboard: {
|
||||||
|
action_access: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (values: any) => {
|
||||||
|
const errors: any = {}
|
||||||
|
|
||||||
|
if (!values.name) {
|
||||||
|
errors.name = 'Required'
|
||||||
|
} else if (values.name.length < 2) {
|
||||||
|
errors.name = 'Name must be at least 2 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.description) {
|
||||||
|
errors.description = 'Required'
|
||||||
|
} else if (values.description.length < 10) {
|
||||||
|
errors.description = 'Description must be at least 10 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRights: Rights = {
|
||||||
|
courses: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_read_own: false,
|
||||||
|
action_update: false,
|
||||||
|
action_update_own: false,
|
||||||
|
action_delete: false,
|
||||||
|
action_delete_own: false
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
usergroups: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
organizations: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
coursechapters: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
action_create: false,
|
||||||
|
action_read: false,
|
||||||
|
action_update: false,
|
||||||
|
action_delete: false
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
action_access: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const predefinedRoles = {
|
||||||
|
'Admin': {
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Full platform control with all permissions',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: true, action_delete_own: true },
|
||||||
|
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
organizations: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
roles: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Course Manager': {
|
||||||
|
name: 'Course Manager',
|
||||||
|
description: 'Can manage courses, chapters, and activities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Instructor': {
|
||||||
|
name: 'Instructor',
|
||||||
|
description: 'Can create and manage their own courses',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: false, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Viewer': {
|
||||||
|
name: 'Viewer',
|
||||||
|
description: 'Read-only access to courses and content',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Content Creator': {
|
||||||
|
name: 'Content Creator',
|
||||||
|
description: 'Can create and edit content but not manage users',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'User Manager': {
|
||||||
|
name: 'User Manager',
|
||||||
|
description: 'Can manage users and user groups',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderator': {
|
||||||
|
name: 'Moderator',
|
||||||
|
description: 'Can moderate content and manage activities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Analyst': {
|
||||||
|
name: 'Analyst',
|
||||||
|
description: 'Read-only access with analytics capabilities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Guest': {
|
||||||
|
name: 'Guest',
|
||||||
|
description: 'Limited access for external users',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: false, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddRole(props: AddRoleProps) {
|
||||||
|
const org = useOrg() as any;
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token;
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||||
|
const [rights, setRights] = React.useState<Rights>(defaultRights)
|
||||||
|
|
||||||
|
const formik = useFormik({
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
org_id: org.id,
|
||||||
|
rights: defaultRights
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const toastID = toast.loading("Creating...")
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
// Ensure rights object is properly structured
|
||||||
|
const formattedRights = {
|
||||||
|
courses: {
|
||||||
|
action_create: rights.courses?.action_create || false,
|
||||||
|
action_read: rights.courses?.action_read || false,
|
||||||
|
action_read_own: rights.courses?.action_read_own || false,
|
||||||
|
action_update: rights.courses?.action_update || false,
|
||||||
|
action_update_own: rights.courses?.action_update_own || false,
|
||||||
|
action_delete: rights.courses?.action_delete || false,
|
||||||
|
action_delete_own: rights.courses?.action_delete_own || false
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
action_create: rights.users?.action_create || false,
|
||||||
|
action_read: rights.users?.action_read || false,
|
||||||
|
action_update: rights.users?.action_update || false,
|
||||||
|
action_delete: rights.users?.action_delete || false
|
||||||
|
},
|
||||||
|
usergroups: {
|
||||||
|
action_create: rights.usergroups?.action_create || false,
|
||||||
|
action_read: rights.usergroups?.action_read || false,
|
||||||
|
action_update: rights.usergroups?.action_update || false,
|
||||||
|
action_delete: rights.usergroups?.action_delete || false
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
action_create: rights.collections?.action_create || false,
|
||||||
|
action_read: rights.collections?.action_read || false,
|
||||||
|
action_update: rights.collections?.action_update || false,
|
||||||
|
action_delete: rights.collections?.action_delete || false
|
||||||
|
},
|
||||||
|
organizations: {
|
||||||
|
action_create: rights.organizations?.action_create || false,
|
||||||
|
action_read: rights.organizations?.action_read || false,
|
||||||
|
action_update: rights.organizations?.action_update || false,
|
||||||
|
action_delete: rights.organizations?.action_delete || false
|
||||||
|
},
|
||||||
|
coursechapters: {
|
||||||
|
action_create: rights.coursechapters?.action_create || false,
|
||||||
|
action_read: rights.coursechapters?.action_read || false,
|
||||||
|
action_update: rights.coursechapters?.action_update || false,
|
||||||
|
action_delete: rights.coursechapters?.action_delete || false
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
action_create: rights.activities?.action_create || false,
|
||||||
|
action_read: rights.activities?.action_read || false,
|
||||||
|
action_update: rights.activities?.action_update || false,
|
||||||
|
action_delete: rights.activities?.action_delete || false
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
action_create: rights.roles?.action_create || false,
|
||||||
|
action_read: rights.roles?.action_read || false,
|
||||||
|
action_update: rights.roles?.action_update || false,
|
||||||
|
action_delete: rights.roles?.action_delete || false
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
action_access: rights.dashboard?.action_access || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createRole({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
org_id: values.org_id,
|
||||||
|
rights: formattedRights
|
||||||
|
}, access_token)
|
||||||
|
if (res.status === 200 || res.status === 201) {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||||
|
props.setCreateRoleModal(false)
|
||||||
|
toast.success("Created new role", {id:toastID})
|
||||||
|
} else {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
toast.error("Couldn't create new role", {id:toastID})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleRightChange = (section: keyof Rights, action: string, value: boolean) => {
|
||||||
|
setRights(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: {
|
||||||
|
...prev[section],
|
||||||
|
[action]: value
|
||||||
|
} as any
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = (section: keyof Rights, value: boolean) => {
|
||||||
|
setRights(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: Object.keys(prev[section]).reduce((acc, key) => ({
|
||||||
|
...acc,
|
||||||
|
[key]: value
|
||||||
|
}), {} as any)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePredefinedRole = (roleKey: string) => {
|
||||||
|
const role = predefinedRoles[roleKey as keyof typeof predefinedRoles]
|
||||||
|
if (role) {
|
||||||
|
formik.setFieldValue('name', role.name)
|
||||||
|
formik.setFieldValue('description', role.description)
|
||||||
|
setRights(role.rights as Rights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionSection = ({ title, icon: Icon, section, permissions }: { title: string, icon: any, section: keyof Rights, permissions: string[] }) => {
|
||||||
|
const sectionRights = rights[section] as any
|
||||||
|
const allSelected = permissions.every(perm => sectionRights[perm])
|
||||||
|
const someSelected = permissions.some(perm => sectionRights[perm]) && !allSelected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 mb-4 bg-white shadow-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon className="w-4 h-4 text-gray-500" />
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm sm:text-base">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectAll(section, !allSelected)}
|
||||||
|
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-700 font-medium self-start sm:self-auto transition-colors"
|
||||||
|
>
|
||||||
|
{allSelected ? <CheckSquare className="w-4 h-4" /> : someSelected ? <Square className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||||
|
<span className="hidden sm:inline">{allSelected ? 'Deselect All' : 'Select All'}</span>
|
||||||
|
<span className="sm:hidden">{allSelected ? 'Deselect' : 'Select'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<label key={permission} className="flex items-center space-x-2 cursor-pointer p-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rights[section]?.[permission as keyof typeof rights[typeof section]] || false}
|
||||||
|
onChange={(e) => handleRightChange(section, permission, e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 capitalize">
|
||||||
|
{permission.replace('action_', '').replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-3 max-w-6xl mx-auto px-2 sm:px-0">
|
||||||
|
<FormLayout onSubmit={formik.handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<FormField name="name">
|
||||||
|
<FormLabelAndMessage label="Role Name" message={formik.errors.name} />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.name}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g., Course Manager"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField name="description">
|
||||||
|
<FormLabelAndMessage label="Description" message={formik.errors.description} />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Textarea
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.description}
|
||||||
|
required
|
||||||
|
placeholder="Describe what this role can do..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Predefined Rights</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Object.keys(predefinedRoles).map((roleKey) => (
|
||||||
|
<button
|
||||||
|
key={roleKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePredefinedRole(roleKey)}
|
||||||
|
className="p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 text-left bg-white shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900 text-sm sm:text-base">{predefinedRoles[roleKey as keyof typeof predefinedRoles].name}</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 mt-1">{predefinedRoles[roleKey as keyof typeof predefinedRoles].description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Permissions</h3>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Courses"
|
||||||
|
icon={BookOpen}
|
||||||
|
section="courses"
|
||||||
|
permissions={['action_create', 'action_read', 'action_read_own', 'action_update', 'action_update_own', 'action_delete', 'action_delete_own']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Users"
|
||||||
|
icon={Users}
|
||||||
|
section="users"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="User Groups"
|
||||||
|
icon={UserCheck}
|
||||||
|
section="usergroups"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Collections"
|
||||||
|
icon={FolderOpen}
|
||||||
|
section="collections"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Organizations"
|
||||||
|
icon={Building}
|
||||||
|
section="organizations"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Course Chapters"
|
||||||
|
icon={FileText}
|
||||||
|
section="coursechapters"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Activities"
|
||||||
|
icon={Activity}
|
||||||
|
section="activities"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Roles"
|
||||||
|
icon={Shield}
|
||||||
|
section="roles"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Dashboard"
|
||||||
|
icon={Monitor}
|
||||||
|
section="dashboard"
|
||||||
|
permissions={['action_access']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.setCreateRoleModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors w-full sm:w-auto font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<Form.Submit asChild>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 w-full sm:w-auto font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create Role'}
|
||||||
|
</button>
|
||||||
|
</Form.Submit>
|
||||||
|
</div>
|
||||||
|
</FormLayout>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddRole
|
||||||
548
apps/web/components/Objects/Modals/Dash/OrgRoles/EditRole.tsx
Normal file
548
apps/web/components/Objects/Modals/Dash/OrgRoles/EditRole.tsx
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
'use client'
|
||||||
|
import FormLayout, {
|
||||||
|
FormField,
|
||||||
|
FormLabelAndMessage,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
} from '@components/Objects/StyledElements/Form/Form'
|
||||||
|
import * as Form from '@radix-ui/react-form'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import React from 'react'
|
||||||
|
import { updateRole } from '@services/roles/roles'
|
||||||
|
import { mutate } from 'swr'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Shield, BookOpen, Users, UserCheck, FolderOpen, Building, FileText, Activity, Settings, Monitor, CheckSquare, Square } from 'lucide-react'
|
||||||
|
|
||||||
|
type EditRoleProps = {
|
||||||
|
role: {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
rights: any
|
||||||
|
}
|
||||||
|
setEditRoleModal: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rights {
|
||||||
|
courses: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_read_own: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_update_own: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
action_delete_own: boolean;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
usergroups: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
collections: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
organizations: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
coursechapters: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
activities: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
roles: {
|
||||||
|
action_create: boolean;
|
||||||
|
action_read: boolean;
|
||||||
|
action_update: boolean;
|
||||||
|
action_delete: boolean;
|
||||||
|
};
|
||||||
|
dashboard: {
|
||||||
|
action_access: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (values: any) => {
|
||||||
|
const errors: any = {}
|
||||||
|
|
||||||
|
if (!values.name) {
|
||||||
|
errors.name = 'Required'
|
||||||
|
} else if (values.name.length < 2) {
|
||||||
|
errors.name = 'Name must be at least 2 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.description) {
|
||||||
|
errors.description = 'Required'
|
||||||
|
} else if (values.description.length < 10) {
|
||||||
|
errors.description = 'Description must be at least 10 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const predefinedRoles = {
|
||||||
|
'Admin': {
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Full platform control with all permissions',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: true, action_delete_own: true },
|
||||||
|
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
organizations: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
roles: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Course Manager': {
|
||||||
|
name: 'Course Manager',
|
||||||
|
description: 'Can manage courses, chapters, and activities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Instructor': {
|
||||||
|
name: 'Instructor',
|
||||||
|
description: 'Can create and manage their own courses',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: false, action_update_own: true, action_delete: false, action_delete_own: true },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Viewer': {
|
||||||
|
name: 'Viewer',
|
||||||
|
description: 'Read-only access to courses and content',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Content Creator': {
|
||||||
|
name: 'Content Creator',
|
||||||
|
description: 'Can create and edit content but not manage users',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: true, action_read: true, action_read_own: true, action_update: true, action_update_own: true, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: true, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'User Manager': {
|
||||||
|
name: 'User Manager',
|
||||||
|
description: 'Can manage users and user groups',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
usergroups: { action_create: true, action_read: true, action_update: true, action_delete: true },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Moderator': {
|
||||||
|
name: 'Moderator',
|
||||||
|
description: 'Can moderate content and manage activities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: true, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Analyst': {
|
||||||
|
name: 'Analyst',
|
||||||
|
description: 'Read-only access with analytics capabilities',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: true, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Guest': {
|
||||||
|
name: 'Guest',
|
||||||
|
description: 'Limited access for external users',
|
||||||
|
rights: {
|
||||||
|
courses: { action_create: false, action_read: true, action_read_own: false, action_update: false, action_update_own: false, action_delete: false, action_delete_own: false },
|
||||||
|
users: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
usergroups: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
collections: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
organizations: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
coursechapters: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
activities: { action_create: false, action_read: true, action_update: false, action_delete: false },
|
||||||
|
roles: { action_create: false, action_read: false, action_update: false, action_delete: false },
|
||||||
|
dashboard: { action_access: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRole(props: EditRoleProps) {
|
||||||
|
const org = useOrg() as any;
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token;
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||||
|
const [rights, setRights] = React.useState<Rights>(props.role.rights || {})
|
||||||
|
|
||||||
|
const formik = useFormik({
|
||||||
|
initialValues: {
|
||||||
|
name: props.role.name,
|
||||||
|
description: props.role.description,
|
||||||
|
org_id: org.id,
|
||||||
|
rights: props.role.rights || {}
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const toastID = toast.loading("Updating...")
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
// Ensure rights object is properly structured
|
||||||
|
const formattedRights = {
|
||||||
|
courses: {
|
||||||
|
action_create: rights.courses?.action_create || false,
|
||||||
|
action_read: rights.courses?.action_read || false,
|
||||||
|
action_read_own: rights.courses?.action_read_own || false,
|
||||||
|
action_update: rights.courses?.action_update || false,
|
||||||
|
action_update_own: rights.courses?.action_update_own || false,
|
||||||
|
action_delete: rights.courses?.action_delete || false,
|
||||||
|
action_delete_own: rights.courses?.action_delete_own || false
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
action_create: rights.users?.action_create || false,
|
||||||
|
action_read: rights.users?.action_read || false,
|
||||||
|
action_update: rights.users?.action_update || false,
|
||||||
|
action_delete: rights.users?.action_delete || false
|
||||||
|
},
|
||||||
|
usergroups: {
|
||||||
|
action_create: rights.usergroups?.action_create || false,
|
||||||
|
action_read: rights.usergroups?.action_read || false,
|
||||||
|
action_update: rights.usergroups?.action_update || false,
|
||||||
|
action_delete: rights.usergroups?.action_delete || false
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
action_create: rights.collections?.action_create || false,
|
||||||
|
action_read: rights.collections?.action_read || false,
|
||||||
|
action_update: rights.collections?.action_update || false,
|
||||||
|
action_delete: rights.collections?.action_delete || false
|
||||||
|
},
|
||||||
|
organizations: {
|
||||||
|
action_create: rights.organizations?.action_create || false,
|
||||||
|
action_read: rights.organizations?.action_read || false,
|
||||||
|
action_update: rights.organizations?.action_update || false,
|
||||||
|
action_delete: rights.organizations?.action_delete || false
|
||||||
|
},
|
||||||
|
coursechapters: {
|
||||||
|
action_create: rights.coursechapters?.action_create || false,
|
||||||
|
action_read: rights.coursechapters?.action_read || false,
|
||||||
|
action_update: rights.coursechapters?.action_update || false,
|
||||||
|
action_delete: rights.coursechapters?.action_delete || false
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
action_create: rights.activities?.action_create || false,
|
||||||
|
action_read: rights.activities?.action_read || false,
|
||||||
|
action_update: rights.activities?.action_update || false,
|
||||||
|
action_delete: rights.activities?.action_delete || false
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
action_create: rights.roles?.action_create || false,
|
||||||
|
action_read: rights.roles?.action_read || false,
|
||||||
|
action_update: rights.roles?.action_update || false,
|
||||||
|
action_delete: rights.roles?.action_delete || false
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
action_access: rights.dashboard?.action_access || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await updateRole(props.role.id, {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
org_id: values.org_id,
|
||||||
|
rights: formattedRights
|
||||||
|
}, access_token)
|
||||||
|
if (res.status === 200) {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
mutate(`${getAPIUrl()}roles/org/${org.id}`)
|
||||||
|
props.setEditRoleModal(false)
|
||||||
|
toast.success("Updated role", {id:toastID})
|
||||||
|
} else {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
toast.error("Couldn't update role", {id:toastID})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleRightChange = (section: keyof Rights, action: string, value: boolean) => {
|
||||||
|
setRights(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: {
|
||||||
|
...prev[section],
|
||||||
|
[action]: value
|
||||||
|
} as any
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = (section: keyof Rights, value: boolean) => {
|
||||||
|
setRights(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: Object.keys(prev[section]).reduce((acc, key) => ({
|
||||||
|
...acc,
|
||||||
|
[key]: value
|
||||||
|
}), {} as any)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePredefinedRole = (roleKey: string) => {
|
||||||
|
const role = predefinedRoles[roleKey as keyof typeof predefinedRoles]
|
||||||
|
if (role) {
|
||||||
|
formik.setFieldValue('name', role.name)
|
||||||
|
formik.setFieldValue('description', role.description)
|
||||||
|
setRights(role.rights as Rights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionSection = ({ title, icon: Icon, section, permissions }: { title: string, icon: any, section: keyof Rights, permissions: string[] }) => {
|
||||||
|
const sectionRights = rights[section] as any
|
||||||
|
const allSelected = permissions.every(perm => sectionRights[perm])
|
||||||
|
const someSelected = permissions.some(perm => sectionRights[perm]) && !allSelected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 mb-4 bg-white shadow-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon className="w-4 h-4 text-gray-500" />
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm sm:text-base">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectAll(section, !allSelected)}
|
||||||
|
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-700 font-medium self-start sm:self-auto transition-colors"
|
||||||
|
>
|
||||||
|
{allSelected ? <CheckSquare className="w-4 h-4" /> : someSelected ? <Square className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||||
|
<span className="hidden sm:inline">{allSelected ? 'Deselect All' : 'Select All'}</span>
|
||||||
|
<span className="sm:hidden">{allSelected ? 'Deselect' : 'Select'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<label key={permission} className="flex items-center space-x-2 cursor-pointer p-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rights[section]?.[permission as keyof typeof rights[typeof section]] || false}
|
||||||
|
onChange={(e) => handleRightChange(section, permission, e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 capitalize">
|
||||||
|
{permission.replace('action_', '').replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-3 max-w-6xl mx-auto px-2 sm:px-0">
|
||||||
|
<FormLayout onSubmit={formik.handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<FormField name="name">
|
||||||
|
<FormLabelAndMessage label="Role Name" message={formik.errors.name} />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.name}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g., Course Manager"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField name="description">
|
||||||
|
<FormLabelAndMessage label="Description" message={formik.errors.description} />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Textarea
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.description}
|
||||||
|
required
|
||||||
|
placeholder="Describe what this role can do..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Predefined Rights</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{Object.keys(predefinedRoles).map((roleKey) => (
|
||||||
|
<button
|
||||||
|
key={roleKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePredefinedRole(roleKey)}
|
||||||
|
className="p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 text-left bg-white shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900 text-sm sm:text-base">{predefinedRoles[roleKey as keyof typeof predefinedRoles].name}</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 mt-1">{predefinedRoles[roleKey as keyof typeof predefinedRoles].description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Permissions</h3>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Courses"
|
||||||
|
icon={BookOpen}
|
||||||
|
section="courses"
|
||||||
|
permissions={['action_create', 'action_read', 'action_read_own', 'action_update', 'action_update_own', 'action_delete', 'action_delete_own']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Users"
|
||||||
|
icon={Users}
|
||||||
|
section="users"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="User Groups"
|
||||||
|
icon={UserCheck}
|
||||||
|
section="usergroups"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Collections"
|
||||||
|
icon={FolderOpen}
|
||||||
|
section="collections"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Organizations"
|
||||||
|
icon={Building}
|
||||||
|
section="organizations"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Course Chapters"
|
||||||
|
icon={FileText}
|
||||||
|
section="coursechapters"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Activities"
|
||||||
|
icon={Activity}
|
||||||
|
section="activities"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Roles"
|
||||||
|
icon={Shield}
|
||||||
|
section="roles"
|
||||||
|
permissions={['action_create', 'action_read', 'action_update', 'action_delete']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PermissionSection
|
||||||
|
title="Dashboard"
|
||||||
|
icon={Monitor}
|
||||||
|
section="dashboard"
|
||||||
|
permissions={['action_access']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.setEditRoleModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors w-full sm:w-auto font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<Form.Submit asChild>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 w-full sm:w-auto font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Updating...' : 'Update Role'}
|
||||||
|
</button>
|
||||||
|
</Form.Submit>
|
||||||
|
</div>
|
||||||
|
</FormLayout>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditRole
|
||||||
|
|
@ -11,10 +11,12 @@ import * as Form from '@radix-ui/react-form'
|
||||||
import { FormMessage } from '@radix-ui/react-form'
|
import { 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
|
||||||
|
|
@ -32,6 +34,12 @@ function RolesUpdate(props: Props) {
|
||||||
)
|
)
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="pr-2">
|
||||||
{params.dialogContent}
|
{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">
|
||||||
|
|
|
||||||
|
|
@ -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-3">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="cursor-pointer flex items-center space-x-3 hover:bg-gray-50 rounded-lg p-2 transition-colors">
|
||||||
|
<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">
|
||||||
<div className='flex items-center space-x-2' >
|
<p className='text-sm font-semibold text-gray-900 capitalize'>{session.data.user.username}</p>
|
||||||
<p className='text-sm capitalize'>{session.data.user.username}</p>
|
{userRoleInfo && userRoleInfo.name !== 'USER' && (
|
||||||
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={"Your Owned Courses"}
|
content={userRoleInfo.description}
|
||||||
sideOffset={15}
|
sideOffset={15}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<Link className="text-gray-600" href={'/dash/user-account/owned'}>
|
<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`}>
|
||||||
<Package2 size={14} />
|
{userRoleInfo.icon}
|
||||||
</Link>
|
{userRoleInfo.name}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Custom roles */}
|
||||||
|
{customRoles.map((customRole, index) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={"Your Settings"}
|
key={index}
|
||||||
|
content={customRole.description || `Custom role: ${customRole.name}`}
|
||||||
sideOffset={15}
|
sideOffset={15}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<Link className="text-gray-600" href={'/dash'}>
|
<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">
|
||||||
<Settings size={14} />
|
<Shield size={12} />
|
||||||
</Link>
|
{customRole.name}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-4">
|
<p className='text-xs text-gray-500'>{session.data.user.email}</p>
|
||||||
<UserAvatar border="border-4" rounded="rounded-lg" width={30} />
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
2895
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -166,6 +166,15 @@ export async function bulkRemoveContributors(course_uuid: string, data: any, acc
|
||||||
`${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
|
||||||
}
|
}
|
||||||
68
apps/web/services/roles/roles.ts
Normal file
68
apps/web/services/roles/roles.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue