Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Keycloak To Server #22

Merged
merged 29 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
40109f2
adding server side support to create the necessary roles on creating …
niclasheun Dec 6, 2024
13cfd8c
changing logrus to log
niclasheun Dec 6, 2024
9205920
Merge branch 'main' into keycloak-roles
niclasheun Dec 6, 2024
0cc563f
adding keycloak integration in the server
niclasheun Dec 7, 2024
0cd78d4
changing to add client roles instead of realm roles
niclasheun Dec 7, 2024
e680cfd
key verfication middleware self-implemented
niclasheun Dec 7, 2024
dae4bde
refined keycloak middleware
niclasheun Dec 7, 2024
1ab8c1d
adding middleware demo to route
niclasheun Dec 7, 2024
40f0a78
introducing dependency injection to allowing for mocking keycloak dur…
niclasheun Dec 8, 2024
0d00df9
changing the auth to use the lecturer, editor, student role
niclasheun Dec 8, 2024
c41bf1e
renaming keycloak
niclasheun Dec 8, 2024
59d84e8
introducing permissionValidationService
niclasheun Dec 8, 2024
66e72da
removing duplicated middleware
niclasheun Dec 9, 2024
2ab7193
adding permission wrapper page
niclasheun Dec 9, 2024
1f24a5d
adding timeout to db requests
niclasheun Dec 9, 2024
662f3c2
adding sidebar permission control
niclasheun Dec 9, 2024
7001795
fixing some stuff and adding permission restriction to external compo…
niclasheun Dec 9, 2024
34bedca
assigning creator of the course the lecture role
niclasheun Dec 9, 2024
7d1226e
force refreshing user rights after course creating
niclasheun Dec 9, 2024
48b2e43
dependency injection for testing
niclasheun Dec 9, 2024
507c9f3
switching to enum
niclasheun Dec 9, 2024
29ad7d0
little fix
niclasheun Dec 9, 2024
4e2c9cc
adjusting right to add course
niclasheun Dec 9, 2024
2fe00e2
fixing most comments
niclasheun Dec 18, 2024
c9fbad9
moving permissionValidation in the middleware
niclasheun Dec 18, 2024
603249c
adjusted the log statements
niclasheun Dec 18, 2024
3d4292d
Merge branch 'main' into keycloak-roles
niclasheun Dec 18, 2024
9565f04
increasing timeout
niclasheun Dec 18, 2024
ff27d4f
fixing the automated tests
niclasheun Dec 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading