mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement comprehensive RBAC checks for courses, chapters, collections, and activities, enhancing user rights management and security documentation
This commit is contained in:
parent
887046203e
commit
3ce019abec
22 changed files with 1788 additions and 598 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -9,6 +9,7 @@ import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import EmptyThumbnailImage from '../../../public/empty_thumbnail.png'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export function CourseOverviewTop({
|
||||
params,
|
||||
|
|
@ -57,7 +58,14 @@ export function CourseOverviewTop({
|
|||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,40 +3,193 @@ import { useLHSession } from '@components/Contexts/LHSessionContext';
|
|||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
interface Role {
|
||||
org: { id: number };
|
||||
role: { id: number; role_uuid: string };
|
||||
org: { id: number; org_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 org = useOrg() as any;
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [rights, setRights] = useState<Rights | null>(null);
|
||||
|
||||
const userRoles = useMemo(() => session?.data?.roles || [], [session?.data?.roles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === 'authenticated' && org?.id) {
|
||||
const isAdminVar = userRoles.some((role: Role) => {
|
||||
return (
|
||||
role.org.id === org.id &&
|
||||
(
|
||||
role.role.id === 1 ||
|
||||
role.role.id === 2 ||
|
||||
role.role.role_uuid === 'role_global_admin' ||
|
||||
role.role.role_uuid === 'role_global_maintainer'
|
||||
)
|
||||
);
|
||||
});
|
||||
// Extract rights from the backend session data
|
||||
const extractRightsFromRoles = (): Rights | null => {
|
||||
if (!userRoles || userRoles.length === 0) return null;
|
||||
|
||||
// Find roles for the current organization
|
||||
const orgRoles = userRoles.filter((role: Role) => role.org.id === org.id);
|
||||
if (orgRoles.length === 0) return null;
|
||||
|
||||
// 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);
|
||||
setLoading(false); // Set loading to false once the status is determined
|
||||
|
||||
setLoading(false);
|
||||
} else {
|
||||
setIsAdmin(false);
|
||||
setLoading(false); // Set loading to false if not authenticated or org not found
|
||||
setRights(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session.status, userRoles, org.id]);
|
||||
|
||||
return { isAdmin, loading };
|
||||
return { isAdmin, loading, userRoles, rights };
|
||||
}
|
||||
|
||||
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) => 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,108 @@
|
|||
'use client'
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
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 useAdminStatus from '@components/Hooks/useAdminStatus'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import { getUriWithoutOrg } from '@services/config/config'
|
||||
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;
|
||||
}
|
||||
|
||||
export const HeaderProfileBox = () => {
|
||||
const session = useLHSession() as any
|
||||
const isUserAdmin = useAdminStatus()
|
||||
const { isAdmin, loading, userRoles, rights } = useAdminStatus()
|
||||
const org = useOrg() as any
|
||||
|
||||
useEffect(() => { }
|
||||
, [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]);
|
||||
|
||||
return (
|
||||
<ProfileArea>
|
||||
{session.status == 'unauthenticated' && (
|
||||
|
|
@ -35,35 +120,73 @@ export const HeaderProfileBox = () => {
|
|||
)}
|
||||
{session.status == 'authenticated' && (
|
||||
<AccountArea className="space-x-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className='flex items-center space-x-2' >
|
||||
<p className='text-sm capitalize'>{session.data.user.username}</p>
|
||||
{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
|
||||
content={"Your Owned Courses"}
|
||||
sideOffset={15}
|
||||
side="bottom"
|
||||
>
|
||||
<Link className="text-gray-600" href={'/dash/user-account/owned'}>
|
||||
<Package2 size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={"Your Settings"}
|
||||
sideOffset={15}
|
||||
side="bottom"
|
||||
>
|
||||
<Link className="text-gray-600" href={'/dash'}>
|
||||
<Settings size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<UserAvatar border="border-4" rounded="rounded-lg" width={30} />
|
||||
</div>
|
||||
<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">
|
||||
<p className='text-sm font-semibold text-gray-900 capitalize'>{session.data.user.username}</p>
|
||||
{userRoleInfo && userRoleInfo.name !== 'USER' && (
|
||||
<Tooltip
|
||||
content={userRoleInfo.description}
|
||||
sideOffset={15}
|
||||
side="bottom"
|
||||
>
|
||||
<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`}>
|
||||
{userRoleInfo.icon}
|
||||
{userRoleInfo.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-xs text-gray-500'>{session.data.user.email}</p>
|
||||
</div>
|
||||
<ChevronDown size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar border="border-2" rounded="rounded-full" width={24} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{session.data.user.username}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{session.data.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{rights?.dashboard?.action_access && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dash" className="flex items-center space-x-2">
|
||||
<Shield size={16} />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dash/user-account/settings/general" className="flex items-center space-x-2">
|
||||
<UserIcon size={16} />
|
||||
<span>User Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dash/user-account/owned" className="flex items-center space-x-2">
|
||||
<Package2 size={16} />
|
||||
<span>My Courses</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="flex items-center space-x-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</AccountArea>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -161,11 +161,20 @@ export async function bulkAddContributors(course_uuid: string, data: any, access
|
|||
return res
|
||||
}
|
||||
|
||||
export async function bulkRemoveContributors(course_uuid: string, data: any, access_token:string | null | undefined) {
|
||||
export async function bulkRemoveContributors(course_uuid: string, data: any, access_token: string | null | undefined) {
|
||||
const result: any = await fetch(
|
||||
`${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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue