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 and basic Routing #8

Merged
merged 6 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 24 additions & 7 deletions clients/core/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React from 'react'
import React, { useState } from 'react'
import ErrorBoundary from './ErrorBoundary'
import { LandingPage } from './LandingPage/LandingPage'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const TemplateComponent = React.lazy(() => import('template_component/App'))
import { KeycloakProvider } from '@/keycloak/KeycloakProvider'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { ManagementRoot } from './management/ManagementConsole'

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -14,12 +17,26 @@ const queryClient = new QueryClient({

export const App = (): JSX.Element => {
return (
<QueryClientProvider client={queryClient}>
<div>
{/* add router here */}
<LandingPage />
</div>
</QueryClientProvider>
<KeycloakProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path='/' element={<LandingPage />} />
<Route path='/management' element={<ManagementRoot></ManagementRoot>} />
<Route
path='/template'
element={
<ErrorBoundary fallback={<div>TemplateComponent is unavailable.</div>}>
<React.Suspense fallback={<div>Loading...</div>}>
<TemplateComponent />
</React.Suspense>
</ErrorBoundary>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</KeycloakProvider>
)
}

Expand Down
2 changes: 1 addition & 1 deletion clients/core/src/LandingPage/components/CourseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { DeadlineInfo } from './DeadlineInfo'
import { Course } from 'src/interfaces/open_course_applications'
import { Course } from '@/interfaces/course'
import { Calendar } from 'lucide-react'
import { format } from 'date-fns'

Expand Down
30 changes: 19 additions & 11 deletions clients/core/src/LandingPage/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { Button } from '@/components/ui/button'
import { LogIn } from 'lucide-react'
import { useNavigate } from 'react-router-dom'

export const Header = (): JSX.Element => (
<div className='flex flex-col sm:flex-row justify-between items-center mb-12 gap-4'>
<div className='flex items-center space-x-4'>
<img src='/ase.jpeg' alt='TUM Logo' className='h-12 w-12' />
<h1 className='text-xl'>Applied Education Technologies</h1>
export const Header = (): JSX.Element => {
const navigate = useNavigate()
return (
<div className='flex flex-col sm:flex-row justify-between items-center mb-12 gap-4'>
<div className='flex items-center space-x-4'>
<img src='/ase.jpeg' alt='TUM Logo' className='h-12 w-12' />
<h1 className='text-xl'>Applied Education Technologies</h1>
</div>
<Button
variant='outline'
className='flex items-center space-x-2'
onClick={() => navigate('/management')}
>
<LogIn className='h-4 w-4' />
<span>Chair Member Login</span>
</Button>
</div>
<Button variant='outline' className='flex items-center space-x-2'>
<LogIn className='h-4 w-4' />
<span>Chair Member Login</span>
</Button>
</div>
)
)
}
31 changes: 31 additions & 0 deletions clients/core/src/management/ManagementConsole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useKeycloak } from '@/keycloak/useKeycloak'
import { useAuthStore } from '@/zustand/useAuthStore'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import UnauthorizedPage from './components/UnauthorizedPage'

export const ManagementRoot = (): JSX.Element => {
const { keycloak, logout } = useKeycloak()
const { user, permissions } = useAuthStore()
const isLoading = !(keycloak && user)

if (isLoading) {
return <Loader2 className='h-8 w-8 animate-spin text-primary bg-center' />
}

// TODO update with what was passed to this page
if (permissions.length === 0) {
return <UnauthorizedPage />
}

return (
<div>
<h1>Management Console</h1>
<p>Welcome, {user?.firstName}!</p>
{permissions.map((permission) => (
<p key={permission}>{permission}</p>
))}
<button onClick={logout}>Logout</button>
</div>
)
}
25 changes: 25 additions & 0 deletions clients/core/src/management/components/UnauthorizedPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { useKeycloak } from '@/keycloak/useKeycloak'
import { AlertTriangle, ArrowLeft } from 'lucide-react'

export default function UnauthorizedPage() {
const { logout } = useKeycloak()
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}>
<ArrowLeft className='mr-2 h-4 w-4' />
Go Back
</Button>
</div>
</div>
</div>
)
}
5 changes: 4 additions & 1 deletion clients/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"clsx": "^2.1.1",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"keycloak-js": "22.0.5",
"lucide-react": "^0.460.0",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
Expand All @@ -33,7 +35,8 @@
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2"
"ts-node": "^10.9.2",
"zustand": "^5.0.1"
},
"devDependencies": {
"@eslint/compat": "^1.2.2",
Expand Down
6 changes: 6 additions & 0 deletions clients/shared_library/interfaces/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface User {
firstName: string
lastName: string
email: string
username: string
}
22 changes: 22 additions & 0 deletions clients/shared_library/keycloak/KeycloakProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { createContext, ReactNode, useState } from 'react'
import type Keycloak from 'keycloak-js'

interface KeycloakContextType {
keycloakValue: Keycloak | undefined
setKeycloakValue: (keycloak: Keycloak) => void
}

export const KeycloakContext = createContext<KeycloakContextType>({
keycloakValue: undefined,
setKeycloakValue: () => {},
})

export const KeycloakProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [keycloakValue, setKeycloakValue] = useState<Keycloak>()

return (
<KeycloakContext.Provider value={{ keycloakValue, setKeycloakValue }}>
{children}
</KeycloakContext.Provider>
)
}
105 changes: 105 additions & 0 deletions clients/shared_library/keycloak/useKeycloak.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useContext, useEffect, useCallback } from 'react'
import Keycloak from 'keycloak-js'
import { KeycloakContext } from './KeycloakProvider'
import { useAuthStore } from '@/zustand/useAuthStore'
import { jwtDecode } from 'jwt-decode'

export const keycloakUrl = `${process.env.REACT_APP_KEYCLOAK_HOST ?? 'http://localhost:8081'}`
export const keycloakRealmName = `${process.env.REACT_APP_KEYCLOAK_REALM_NAME ?? 'prompt'}`

// Helper function to decode JWT safely
const parseJwt = (token: string) => {
try {
return jwtDecode<{
given_name: string
family_name: string
email: string
preferred_username: string
}>(token)
} catch {
return null
}
}

export const useKeycloak = (): { keycloak: Keycloak | undefined; logout: () => void } => {
const context = useContext(KeycloakContext)
const { setUser, setPermissions, clearUser, clearPermissions } = useAuthStore()

if (!context) {
throw new Error('useKeycloak must be used within a KeycloakProvider')
}

const { keycloakValue } = context

const initializeKeycloak = useCallback(() => {
const keycloak = new Keycloak({
realm: keycloakRealmName,
url: keycloakUrl,
clientId: 'prompt-client',
})

keycloak.onTokenExpired = () => {
keycloak
.updateToken(5)
.then(() => {
localStorage.setItem('jwt_token', keycloak.token ?? '')
localStorage.setItem('refreshToken', keycloak.refreshToken ?? '')
})
.catch(() => {
clearUser()
clearPermissions()
alert('Session expired. Please log in again.')
keycloak.logout({ redirectUri: window.location.origin })
})
}

void keycloak
.init({ onLoad: 'login-required' })
.then(() => {
localStorage.setItem('jwt_token', keycloak.token ?? '')
localStorage.setItem('refreshToken', keycloak.refreshToken ?? '')
context.keycloakValue = keycloak // Update context dynamically

if (keycloak.token) {
const decodedJwt = parseJwt(keycloak.token)
if (decodedJwt) {
setUser({
firstName: decodedJwt.given_name || '',
lastName: decodedJwt.family_name || '',
email: decodedJwt.email || '',
username: decodedJwt.preferred_username || '',
})
} else {
clearUser()
}
const resourceRoles = keycloak.resourceAccess?.['prompt-server']?.roles || []
setPermissions(resourceRoles)
}
})
.catch((err) => {
clearUser()
clearPermissions()
alert(`Authentication error: ${err.message}`)
})

return keycloak
}, [context, clearUser, clearPermissions, setUser, setPermissions])

useEffect(() => {
if (!keycloakValue) {
initializeKeycloak()
}
}, [keycloakValue, initializeKeycloak])

const logout = () => {
if (keycloakValue) {
keycloakValue.logout({ redirectUri: window.location.origin }) // Keycloak logout
}
clearUser()
clearPermissions()
localStorage.removeItem('jwt_token')
localStorage.removeItem('refreshToken')
}

return { keycloak: keycloakValue, logout }
}
2 changes: 0 additions & 2 deletions clients/shared_library/network/configService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import axios from 'axios'

export const serverBaseUrl = `${process.env.REACT_APP_SERVER_HOST ?? 'http://localhost:8080'}`
export const keycloakUrl = `${process.env.REACT_APP_KEYCLOAK_HOST ?? 'http://localhost:8081'}`
export const keycloakRealmName = `${process.env.REACT_APP_KEYCLOAK_REALM_NAME ?? 'prompt'}`

export interface Patch {
op: 'replace' | 'add' | 'remove' | 'copy'
Expand Down
20 changes: 20 additions & 0 deletions clients/shared_library/zustand/useAuthStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { create } from 'zustand'
import { User } from '@/interfaces/user'

interface AuthStoreState {
user?: User
permissions: string[]
setUser: (user: User) => void
clearUser: () => void
setPermissions: (permissions: string[]) => void
clearPermissions: () => void
}

export const useAuthStore = create<AuthStoreState>((set) => ({
user: undefined,
permissions: [],
setUser: (user) => set({ user }),
clearUser: () => set({ user: undefined }),
setPermissions: (permissions) => set({ permissions }),
clearPermissions: () => set({ permissions: [] })
}))
Loading