Skip to content

Commit

Permalink
Merge pull request #22 from ls1intum/keycloak-roles
Browse files Browse the repository at this point in the history
Adding Keycloak To Server
  • Loading branch information
niclasheun authored Dec 18, 2024
2 parents 0ffa248 + ff27d4f commit de39b1f
Show file tree
Hide file tree
Showing 43 changed files with 1,204 additions and 91 deletions.
1 change: 1 addition & 0 deletions .github/workflows/go-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
- name: Test with Go
run: cd ${{ matrix.directory }} && go test ./... -json > TestResults-${{ matrix.directory }}.json
- name: Upload Go test results
if: always()
uses: actions/upload-artifact@v4
with:
name: Go-results-${{ matrix.directory }}
Expand Down
12 changes: 11 additions & 1 deletion clients/core/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { PhaseRouterMapping } from './PhaseMapping/PhaseRouterMapping'
import PrivacyPage from './LegalPages/Privacy'
import ImprintPage from './LegalPages/Imprint'
import AboutPage from './LegalPages/AboutPage'
import { PermissionRestriction } from './management/PermissionRestriction'
import { Role } from '@/interfaces/permission_roles'

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -55,7 +57,15 @@ export const App = (): JSX.Element => {
path='/management/course/:courseId/application/*'
element={
<ManagementRoot>
<Application />
<PermissionRestriction
requiredPermissions={[
Role.PROMPT_ADMIN,
Role.COURSE_LECTURER,
Role.COURSE_EDITOR,
]}
>
<Application />
</PermissionRestriction>
</ManagementRoot>
}
/>
Expand Down
26 changes: 20 additions & 6 deletions clients/core/src/Course/AddingCourse/AddCourseDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PostCourse } from '@/interfaces/post_course'
import { postNewCourse } from '../../network/mutations/postNewCourse'
import { useNavigate } from 'react-router-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { useKeycloak } from '@/keycloak/useKeycloak'

interface AddCourseDialogProps {
children: React.ReactNode
Expand All @@ -28,18 +29,31 @@ export const AddCourseDialog: React.FC<AddCourseDialogProps> = ({ children }) =>

const queryClient = useQueryClient()
const navigate = useNavigate()
const { forceTokenRefresh } = useKeycloak()

const { mutate, isPending, error, isError, reset } = useMutation({
mutationFn: (course: PostCourse) => {
return postNewCourse(course)
},
onSuccess: (data: string | undefined) => {
console.log('Received ID' + data)
queryClient.invalidateQueries({ queryKey: ['courses'] })

setIsOpen(false)
setIsOpen(false)
navigate(`/management/course/${data}`)
forceTokenRefresh() // refresh token to get permission for new course
.then(() => {
// Invalidate course queries
return queryClient.invalidateQueries({ queryKey: ['courses'] })
})
.then(() => {
// Wait for courses to be refetched
return queryClient.refetchQueries({ queryKey: ['courses'] })
})
.then(() => {
// Close the window and navigate
setIsOpen(false)
navigate(`/management/course/${data}`)
})
.catch((err) => {
console.error('Error during token refresh or query invalidation:', err)
return err
})
},
})

Expand Down
30 changes: 30 additions & 0 deletions clients/core/src/PhaseMapping/ExternalRouters/ExternalRoutes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ExtendedRouteObject } from '@/interfaces/extended_route_object'
import { Route, Routes } from 'react-router-dom'
import ErrorBoundary from '../../ErrorBoundary'
import { PermissionRestriction } from '../../management/PermissionRestriction'

interface ExternalRoutesProps {
routes: ExtendedRouteObject[]
}

export const ExternalRoutes: React.FC<ExternalRoutesProps> = ({ routes }: ExternalRoutesProps) => {
return (
<>
<Routes>
{routes.map((route, index) => (
<Route
key={index}
path={route.path}
element={
<PermissionRestriction requiredPermissions={route.requiredPermissions || []}>
<ErrorBoundary fallback={<div>Route loading failed</div>}>
{route.element}
</ErrorBoundary>
</PermissionRestriction>
}
/>
))}
</Routes>
</>
)
}
22 changes: 4 additions & 18 deletions clients/core/src/PhaseMapping/ExternalRouters/TemplateRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import React from 'react'
import { RouteObject, Routes, Route } from 'react-router-dom'
import ErrorBoundary from '../../ErrorBoundary'
import { ExtendedRouteObject } from '@/interfaces/extended_route_object'
import { ExternalRoutes } from './ExternalRoutes'

export const TemplateRoutes = React.lazy(() =>
import('template_component/routers')
.then((module): { default: React.FC } => ({
default: () => {
const routes: RouteObject[] = module.default || []
return (
<Routes>
{routes.map((route, index) => (
<Route
key={index}
path={route.path}
element={
<ErrorBoundary fallback={<div>Route loading failed</div>}>
{route.element}
</ErrorBoundary>
}
/>
))}
</Routes>
)
const routes: ExtendedRouteObject[] = module.default || []
return <ExternalRoutes routes={routes} />
},
}))
.catch((): { default: React.FC } => ({
Expand Down
68 changes: 68 additions & 0 deletions clients/core/src/PhaseMapping/ExternalSidebars/ExternalSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { SidebarMenuItemProps } from '@/interfaces/sidebar'
import { useAuthStore } from '@/zustand/useAuthStore'
import { InsideSidebarMenuItem } from '../../Sidebar/InsideSidebar/components/InsideSidebarMenuItem'
import { getPermissionString } from '@/interfaces/permission_roles'
import { useCourseStore } from '@/zustand/useCourseStore'
import { useParams } from 'react-router-dom'

interface ExternalSidebarProps {
rootPath: string
title?: string
sidebarElement: SidebarMenuItemProps
}

export const ExternalSidebarComponent: React.FC<ExternalSidebarProps> = ({
title,
rootPath,
sidebarElement,
}: ExternalSidebarProps) => {
// Example of using a custom hook
const { permissions } = useAuthStore() // Example of calling your custom hook
const { courses } = useCourseStore()
const courseId = useParams<{ courseId: string }>().courseId

const course = courses.find((c) => c.id === courseId)

let hasComponentPermission = false
if (sidebarElement.requiredPermissions && sidebarElement.requiredPermissions.length > 0) {
hasComponentPermission = sidebarElement.requiredPermissions.some((role) => {
return permissions.includes(getPermissionString(role, course?.name, course?.semester_tag))
})
} else {
// no permissions required
hasComponentPermission = true
}

return (
<>
{hasComponentPermission && (
<InsideSidebarMenuItem
title={title || sidebarElement.title}
icon={sidebarElement.icon}
goToPath={rootPath + sidebarElement.goToPath}
subitems={
sidebarElement.subitems
?.filter((subitem) => {
const hasPermission = subitem.requiredPermissions?.some((role) => {
return permissions.includes(
getPermissionString(role, course?.name, course?.semester_tag),
)
})
if (subitem.requiredPermissions && !hasPermission) {
return false
} else {
return true
}
})
.map((subitem) => {
return {
title: subitem.title,
goToPath: rootPath + subitem.goToPath,
}
}) || []
}
/>
)}
</>
)
}
17 changes: 5 additions & 12 deletions clients/core/src/PhaseMapping/ExternalSidebars/TemplateSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react'
import { InsideSidebarMenuItem } from '../../Sidebar/InsideSidebar/components/InsideSidebarMenuItem'
import { DisabledSidebarMenuItem } from '../../Sidebar/InsideSidebar/components/DisabledSidebarMenuItem'
import { SidebarMenuItemProps } from '@/interfaces/sidebar'

import { ExternalSidebarComponent } from './ExternalSidebar'

interface TemplateSidebarProps {
rootPath: string
Expand All @@ -15,16 +14,10 @@ export const TemplateSidebar = React.lazy(() =>
default: ({ title, rootPath }) => {
const sidebarElement: SidebarMenuItemProps = module.default || {}
return (
<InsideSidebarMenuItem
title={title || sidebarElement.title}
icon={sidebarElement.icon}
goToPath={rootPath + sidebarElement.goToPath}
subitems={
sidebarElement.subitems?.map((subitem) => ({
title: subitem.title,
goToPath: rootPath + subitem.goToPath,
})) || []
}
<ExternalSidebarComponent
title={title}
rootPath={rootPath}
sidebarElement={sidebarElement}
/>
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ import { useCourseStore } from '@/zustand/useCourseStore'
import SidebarHeaderComponent from './components/SidebarHeader'
import { CourseSidebarItem } from './components/CourseSidebarItem'
import { AddCourseButton } from './components/AddCourseSidebarItem'
import { useAuthStore } from '@/zustand/useAuthStore'
import { Role } from '@/interfaces/permission_roles'

interface CourseSwitchSidebarProps {
onLogout: () => void
}

export const CourseSwitchSidebar = ({ onLogout }: CourseSwitchSidebarProps): JSX.Element => {
const { courses } = useCourseStore()
const { permissions } = useAuthStore()

const canAddCourse = permissions.some(
(permission) => permission === Role.PROMPT_ADMIN || permission === Role.PROMPT_LECTURER,
)

return (
<Sidebar
Expand All @@ -32,7 +39,7 @@ export const CourseSwitchSidebar = ({ onLogout }: CourseSwitchSidebarProps): JSX
{courses.map((course) => {
return <CourseSidebarItem key={course.id} course={course} />
})}
<AddCourseButton />
{canAddCourse && <AddCourseButton />}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
Expand Down
6 changes: 2 additions & 4 deletions clients/core/src/management/ManagementConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const ManagementRoot = ({ children }: { children?: React.ReactNode }): JS
return <ErrorPage onRetry={() => refetch()} onLogout={logout} />
}

// TODO update with what was passed to this page
// Check if the user has at least some Prompt rights
if (permissions.length === 0) {
return <UnauthorizedPage />
}
Expand All @@ -63,10 +63,8 @@ export const ManagementRoot = ({ children }: { children?: React.ReactNode }): JS

// TODO do course id management here
// store latest selected course in local storage
// check authorization
// if course non existent or unauthorized, show error page

const courseExists = fetchedCourses.some((course) => course.id === courseId.courseId)
console.log(fetchedCourses)

return (
<DarkModeProvider>
Expand Down
40 changes: 40 additions & 0 deletions clients/core/src/management/PermissionRestriction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useAuthStore } from '@/zustand/useAuthStore'
import { Role, getPermissionString } from '@/interfaces/permission_roles'
import { useParams } from 'react-router-dom'
import { useCourseStore } from '@/zustand/useCourseStore'
import UnauthorizedPage from './components/UnauthorizedPage'

interface PermissionRestrictionProps {
requiredPermissions: Role[]
children: React.ReactNode
}

// The server will only return data which the user is allowed to see
// This is only needed if the user has to restrict permission further to not show some pages at all (i.e. settings pages)
export const PermissionRestriction = ({
requiredPermissions,
children,
}: PermissionRestrictionProps): JSX.Element => {
const { permissions } = useAuthStore()
const { courses } = useCourseStore()
const courseId = useParams<{ courseId: string }>().courseId

// This means something /general
if (!courseId) {
// TODO: refine at later stage
// has at least some prompt permission
return <>{permissions.length > 0 ? children : <UnauthorizedPage />}</>
}

// in ManagementRoot is verified that this exists
const course = courses.find((c) => c.id === courseId)

let hasPermission = true
if (requiredPermissions.length > 0) {
hasPermission = requiredPermissions.some((role) => {
return permissions.includes(getPermissionString(role, course?.name, course?.semester_tag))
})
}

return <>{hasPermission ? children : <UnauthorizedPage />}</>
}
42 changes: 30 additions & 12 deletions clients/core/src/management/components/UnauthorizedPage.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useKeycloak } from '@/keycloak/useKeycloak'
import { AlertTriangle, ArrowLeft } from 'lucide-react'
import { AlertTriangle, ArrowLeft, LogOut } from 'lucide-react'
import { useNavigate } from 'react-router-dom'

export default function UnauthorizedPage() {
const { logout } = useKeycloak()
const navigate = useNavigate()

return (
<div className='min-h-screen flex items-center justify-center bg-background p-4'>
<div className='max-w-md w-full space-y-8'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>You do not have permission to access this page.</AlertDescription>
</Alert>
<div className='text-center'>
<Button variant='outline' onClick={logout}>
<div className='fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4'>
<Card className='max-w-md w-full shadow-lg'>
<CardHeader>
<CardTitle className='text-2xl font-bold text-center text-primary'>
Access Denied
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<Alert
variant='destructive'
className='border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
>
<AlertTriangle className='h-4 w-4' />
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>You do not have permission to access this page.</AlertDescription>
</Alert>
</CardContent>
<CardFooter className='flex justify-center space-x-4'>
<Button variant='outline' onClick={() => navigate(-1)} className='w-full sm:w-auto'>
<ArrowLeft className='mr-2 h-4 w-4' />
Go Back
</Button>
</div>
</div>
<Button variant='destructive' onClick={logout} className='w-full sm:w-auto'>
<LogOut className='mr-2 h-4 w-4' />
Logout
</Button>
</CardFooter>
</Card>
</div>
)
}
Loading

0 comments on commit de39b1f

Please sign in to comment.