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 => ( -
-
- TUM Logo -

Applied Education Technologies

+export const Header = (): JSX.Element => { + const navigate = useNavigate() + return ( +
+
+ TUM Logo +

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