diff --git a/clients/core/src/App.tsx b/clients/core/src/App.tsx
index 16d54de1..f55fe348 100644
--- a/clients/core/src/App.tsx
+++ b/clients/core/src/App.tsx
@@ -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: {
@@ -14,12 +17,26 @@ const queryClient = new QueryClient({
export const App = (): JSX.Element => {
return (
-
-
- {/* add router here */}
-
-
-
+
+
+
+
+ } />
+ } />
+ TemplateComponent is unavailable.}>
+ Loading...}>
+
+
+
+ }
+ />
+
+
+
+
)
}
diff --git a/clients/core/src/LandingPage/components/CourseCard.tsx b/clients/core/src/LandingPage/components/CourseCard.tsx
index b009a98f..f2841e85 100644
--- a/clients/core/src/LandingPage/components/CourseCard.tsx
+++ b/clients/core/src/LandingPage/components/CourseCard.tsx
@@ -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'
diff --git a/clients/core/src/LandingPage/components/Header.tsx b/clients/core/src/LandingPage/components/Header.tsx
index 1cadabe8..8ae73692 100644
--- a/clients/core/src/LandingPage/components/Header.tsx
+++ b/clients/core/src/LandingPage/components/Header.tsx
@@ -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 => (
-
-
-

-
Applied Education Technologies
+export const Header = (): JSX.Element => {
+ const navigate = useNavigate()
+ return (
+
+
+

+
Applied Education Technologies
+
+
-
-
-)
+ )
+}
diff --git a/clients/core/src/management/ManagementConsole.tsx b/clients/core/src/management/ManagementConsole.tsx
new file mode 100644
index 00000000..6a16ab6f
--- /dev/null
+++ b/clients/core/src/management/ManagementConsole.tsx
@@ -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
+ }
+
+ // TODO update with what was passed to this page
+ if (permissions.length === 0) {
+ return
+ }
+
+ return (
+
+
Management Console
+
Welcome, {user?.firstName}!
+ {permissions.map((permission) => (
+
{permission}
+ ))}
+
+
+ )
+}
diff --git a/clients/core/src/management/components/UnauthorizedPage.tsx b/clients/core/src/management/components/UnauthorizedPage.tsx
new file mode 100644
index 00000000..6dec3582
--- /dev/null
+++ b/clients/core/src/management/components/UnauthorizedPage.tsx
@@ -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 (
+
+
+
+
+ Access Denied
+ You do not have permission to access this page.
+
+
+
+
+ )
+}
diff --git a/clients/package.json b/clients/package.json
index 7bdb084b..62bb12f4 100644
--- a/clients/package.json
+++ b/clients/package.json
@@ -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",
@@ -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",
diff --git a/clients/shared_library/interfaces/user.ts b/clients/shared_library/interfaces/user.ts
new file mode 100644
index 00000000..585a0ecc
--- /dev/null
+++ b/clients/shared_library/interfaces/user.ts
@@ -0,0 +1,6 @@
+export interface User {
+ firstName: string
+ lastName: string
+ email: string
+ username: string
+}
diff --git a/clients/shared_library/keycloak/KeycloakProvider.tsx b/clients/shared_library/keycloak/KeycloakProvider.tsx
new file mode 100644
index 00000000..08aa0600
--- /dev/null
+++ b/clients/shared_library/keycloak/KeycloakProvider.tsx
@@ -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
({
+ keycloakValue: undefined,
+ setKeycloakValue: () => {},
+})
+
+export const KeycloakProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+ const [keycloakValue, setKeycloakValue] = useState()
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/clients/shared_library/keycloak/useKeycloak.tsx b/clients/shared_library/keycloak/useKeycloak.tsx
new file mode 100644
index 00000000..57a4d6be
--- /dev/null
+++ b/clients/shared_library/keycloak/useKeycloak.tsx
@@ -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 }
+}
diff --git a/clients/shared_library/network/configService.ts b/clients/shared_library/network/configService.ts
index 69ebbab7..ecfd65a4 100644
--- a/clients/shared_library/network/configService.ts
+++ b/clients/shared_library/network/configService.ts
@@ -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'
diff --git a/clients/shared_library/zustand/useAuthStore.tsx b/clients/shared_library/zustand/useAuthStore.tsx
new file mode 100644
index 00000000..566c04ee
--- /dev/null
+++ b/clients/shared_library/zustand/useAuthStore.tsx
@@ -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((set) => ({
+ user: undefined,
+ permissions: [],
+ setUser: (user) => set({ user }),
+ clearUser: () => set({ user: undefined }),
+ setPermissions: (permissions) => set({ permissions }),
+ clearPermissions: () => set({ permissions: [] })
+}))
diff --git a/clients/yarn.lock b/clients/yarn.lock
index 08349a0d..45868360 100644
--- a/clients/yarn.lock
+++ b/clients/yarn.lock
@@ -3326,7 +3326,7 @@ __metadata:
languageName: node
linkType: hard
-"base64-js@npm:^1.3.1":
+"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf
@@ -3791,8 +3791,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "client@workspace:core"
dependencies:
- "@tanstack/react-query": "npm:^5.61.0"
- axios: "npm:^1.7.7"
clean-webpack-plugin: "npm:^4.0.0"
compression-webpack-plugin: "npm:^11.1.0"
css-minimizer-webpack-plugin: "npm:^7.0.0"
@@ -7512,6 +7510,13 @@ __metadata:
languageName: node
linkType: hard
+"js-sha256@npm:^0.9.0":
+ version: 0.9.0
+ resolution: "js-sha256@npm:0.9.0"
+ checksum: 10c0/f20b9245f6ebe666f42ca05536f777301132fb1aa7fbc22f10578fa302717a6cca507344894efdeaf40a011256eb2f7d517b94ac7105bd5cf087fa61551ad634
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -7655,6 +7660,23 @@ __metadata:
languageName: node
linkType: hard
+"jwt-decode@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "jwt-decode@npm:4.0.0"
+ checksum: 10c0/de75bbf89220746c388cf6a7b71e56080437b77d2edb29bae1c2155048b02c6b8c59a3e5e8d6ccdfd54f0b8bda25226e491a4f1b55ac5f8da04cfbadec4e546c
+ languageName: node
+ linkType: hard
+
+"keycloak-js@npm:22.0.5":
+ version: 22.0.5
+ resolution: "keycloak-js@npm:22.0.5"
+ dependencies:
+ base64-js: "npm:^1.5.1"
+ js-sha256: "npm:^0.9.0"
+ checksum: 10c0/d377d8f3bb062d052e10c119f4eacdb9f34b9aedc76a1050c1d9201bcbf78dc8e089e6609af4c4dc6e9118969236ca7e4f458224f89d1273937b5b27b128c9c5
+ languageName: node
+ linkType: hard
+
"keyv@npm:^4.5.3":
version: 4.5.4
resolution: "keyv@npm:4.5.4"
@@ -10466,6 +10488,8 @@ __metadata:
eslint-plugin-react: "npm:^7.34.2"
eslint-plugin-react-hooks: "npm:^4.6.2"
html-webpack-plugin: "npm:^5.6.0"
+ jwt-decode: "npm:^4.0.0"
+ keycloak-js: "npm:22.0.5"
lerna: "npm:^4.0.0"
lucide-react: "npm:^0.460.0"
postcss: "npm:^8.4.49"
@@ -10486,6 +10510,7 @@ __metadata:
webpack: "npm:^5.91.0"
webpack-cli: "npm:^5.1.4"
webpack-dev-server: "npm:^5.0.4"
+ zustand: "npm:^5.0.1"
languageName: unknown
linkType: soft
@@ -11443,6 +11468,7 @@ __metadata:
"@radix-ui/react-slot": "npm:^1.1.0"
"@shadcn/ui": "npm:^0.0.4"
tailwindcss: "npm:^3.4.15"
+ zustand: "npm:^5.0.1"
languageName: unknown
linkType: soft
@@ -13495,3 +13521,24 @@ __metadata:
checksum: 10c0/8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69
languageName: node
linkType: hard
+
+"zustand@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "zustand@npm:5.0.1"
+ peerDependencies:
+ "@types/react": ">=18.0.0"
+ immer: ">=9.0.6"
+ react: ">=18.0.0"
+ use-sync-external-store: ">=1.2.0"
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+ checksum: 10c0/b4239c8bf3988bfdaaed1c48f3958d0b047b721c4908a76bd78e73387d107963cda774541cf303c2ea89261481c995aa6666e7e77c30717ad440cdb499d9e5ca
+ languageName: node
+ linkType: hard