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

@ -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>

View file

@ -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;

View file

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

View file

@ -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>
)}