feat: implement comprehensive RBAC checks for courses, chapters, collections, and activities, enhancing user rights management and security documentation

This commit is contained in:
swve 2025-08-09 12:13:12 +02:00
parent 887046203e
commit 3ce019abec
22 changed files with 1788 additions and 598 deletions

View file

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

View file

@ -1,16 +1,20 @@
'use client'
import { getUriWithOrg } from '@services/config/config'
import React, { use } from 'react';
import React, { use, useEffect } from 'react';
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
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 EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
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 = {
orgslug: string
courseuuid: string
@ -19,110 +23,146 @@ export type CourseOverviewParams = {
function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
const params = use(props.params);
const router = useRouter();
function getEntireCourseUUID(courseuuid: string) {
// add course_ to uuid
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 (
<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">
<CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm">
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/general`
{tabs.map((tab) => {
const IconComponent = tab.icon
const isActive = params.subpage.toString() === tab.key
const hasAccess = hasPermission(tab.requiredPermission)
if (!hasAccess) {
// Show disabled tab with subtle visual cues and tooltip
return (
<ToolTip
key={tab.key}
content={
<div className="text-center">
<div className="font-medium text-gray-900">Access Restricted</div>
<div className="text-sm text-gray-600">
You don't have permission to access {tab.label}
</div>
</div>
}
>
<div className="flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear opacity-30 cursor-not-allowed">
<div className="flex items-center space-x-2.5 mx-2">
<IconComponent size={16} />
<div>{tab.label}</div>
</div>
</div>
</ToolTip>
)
}
>
<div
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
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/content`
}
>
<div
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">
<GalleryVerticalEnd size={16} />
<div>Content</div>
</div>
</div>
</Link>
<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>
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>
<motion.div
initial={{ opacity: 0 }}
@ -132,12 +172,21 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
className="h-full overflow-y-auto relative"
>
<div className="absolute inset-0">
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
{params.subpage == 'certification' ? (<EditCourseCertification orgslug={params.orgslug} />) : ('')}
{params.subpage == 'content' && hasPermission('update_content') ? (
<EditCourseStructure orgslug={params.orgslug} />
) : null}
{params.subpage == 'general' && hasPermission('update') ? (
<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>
</motion.div>
</CourseProvider>

View file

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

View file

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