Skip to content
Closed
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
1 change: 0 additions & 1 deletion apps/legacy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"msw": "^2.12.14",
"tailwindcss": "^4.0.0",
"vite": "^8.0.2",
"vite-tsconfig-paths": "^6.1.0"
Expand Down
1 change: 0 additions & 1 deletion apps/legacy/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"include": ["src/**/*"],
"compilerOptions": {
"types": ["node", "vite/client"],
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
Expand Down
4 changes: 4 additions & 0 deletions apps/smartem/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_KEYCLOAK_URL=https://identity.diamond.ac.uk
VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_CLIENT_ID=smartem-frontend
VITE_AUTH_ENABLED=false
3 changes: 2 additions & 1 deletion apps/smartem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"@smartem/ui": "*",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.0.2",
"keycloak-js": "^26.1.0",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/router-devtools": "^1.166.11",
Expand All @@ -30,7 +32,6 @@
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"msw": "^2.12.14",
"tailwindcss": "^4.0.0",
"vite": "^8.0.2",
"vite-tsconfig-paths": "^6.1.0"
Expand Down
12 changes: 12 additions & 0 deletions apps/smartem/src/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { setAuthToken } from '@smartem/api'
import type { PropsWithChildren } from 'react'
import { AuthProvider } from './AuthProvider'
import { isAuthEnabled } from './config'

export const AuthGate = ({ children }: PropsWithChildren) => {
if (!isAuthEnabled()) {
return <>{children}</>
}

return <AuthProvider onTokenChange={setAuthToken}>{children}</AuthProvider>
}
123 changes: 123 additions & 0 deletions apps/smartem/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Keycloak, { type KeycloakError } from 'keycloak-js'
import {
createContext,
type PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { keycloakConfig } from './config'
import type { Auth, AuthUser } from './types'

const MIN_SECONDS_BEFORE_EXPIRY = 10
const MIN_REFRESH_INTERVAL_SECONDS = 5

const defaultAuth: Auth = {
initialised: false,
authenticated: false,
login: () => {},
logout: () => {},
getToken: () => '',
}

const AuthContext = createContext<Auth>(defaultAuth)

export const useAuth = () => useContext(AuthContext)

interface AuthProviderProps extends PropsWithChildren {
onTokenChange?: (token: string) => void
}

function buildAuth(keycloak: Keycloak): Auth {
const auth: Auth = {
initialised: keycloak.didInitialize,
authenticated: keycloak.authenticated ?? false,
login: () => void keycloak.login({}),
logout: () => void keycloak.logout({}),
getToken: () => keycloak.token ?? '',
}

if (keycloak.authenticated && keycloak.idTokenParsed) {
auth.user = {
name: keycloak.idTokenParsed.name ?? '',
givenName: keycloak.idTokenParsed.given_name ?? '',
familyName: keycloak.idTokenParsed.family_name ?? '',
fedId: keycloak.idTokenParsed.fedId ?? '',
email: keycloak.idTokenParsed.email ?? '',
} satisfies AuthUser
}

return auth
}

export const AuthProvider = ({ children, onTokenChange }: AuthProviderProps) => {
const [auth, setAuth] = useState<Auth>(defaultAuth)
const refreshTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const onTokenChangeRef = useRef(onTokenChange)
onTokenChangeRef.current = onTokenChange

useEffect(() => {
const keycloak = new Keycloak(keycloakConfig)

const emitToken = () => {
const token = keycloak.authenticated && keycloak.token ? keycloak.token : ''
onTokenChangeRef.current?.(token)
}

const scheduleRefresh = () => {
clearTimeout(refreshTimer.current)

if (!keycloak.idTokenParsed?.exp || !keycloak.idTokenParsed?.iat) return

const tokenLifetime = keycloak.idTokenParsed.exp - keycloak.idTokenParsed.iat
let refreshIn = tokenLifetime - MIN_SECONDS_BEFORE_EXPIRY
if (refreshIn < MIN_REFRESH_INTERVAL_SECONDS) {
refreshIn = MIN_REFRESH_INTERVAL_SECONDS
}

refreshTimer.current = setTimeout(() => {
keycloak.updateToken(-1).catch((err: KeycloakError) => {
console.error('Token refresh failed:', err)
setAuth((prev) => ({ ...prev, error: 'Token refresh failed' }))
})
}, refreshIn * 1000)
}

keycloak.onAuthSuccess = () => {
setAuth(buildAuth(keycloak))
emitToken()
scheduleRefresh()
}

keycloak.onAuthRefreshSuccess = () => {
emitToken()
scheduleRefresh()
}

keycloak.onAuthLogout = () => {
clearTimeout(refreshTimer.current)
setAuth(buildAuth(keycloak))
emitToken()
}

keycloak.onAuthError = (error: KeycloakError) => {
console.error('Keycloak auth error:', error)
setAuth((prev) => ({ ...prev, error: `Auth error: ${error.error}` }))
}

keycloak
.init({ onLoad: 'check-sso' })
.then(() => setAuth(buildAuth(keycloak)))
.catch((err) => {
console.error('Keycloak init failed:', err)
setAuth({ ...defaultAuth, initialised: true, error: 'Failed to connect to Keycloak' })
})

return () => {
clearTimeout(refreshTimer.current)
}
}, [])

return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
}
14 changes: 14 additions & 0 deletions apps/smartem/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { KeycloakServerConfig } from 'keycloak-js'

export const isAuthEnabled = (): boolean => {
if (import.meta.env.VITE_ENABLE_MOCKS === 'true') {
return import.meta.env.VITE_AUTH_ENABLED === 'true'
}
return import.meta.env.VITE_AUTH_ENABLED !== 'false'
}

export const keycloakConfig: KeycloakServerConfig = {
url: import.meta.env.VITE_KEYCLOAK_URL || 'https://identity.diamond.ac.uk',
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'master',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'smartem-frontend',
}
3 changes: 3 additions & 0 deletions apps/smartem/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AuthGate } from './AuthGate'
export { AuthProvider, useAuth } from './AuthProvider'
export type { Auth, AuthUser } from './types'
17 changes: 17 additions & 0 deletions apps/smartem/src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type AuthUser = {
name: string
givenName: string
familyName: string
fedId: string
email: string
}

export interface Auth {
initialised: boolean
authenticated: boolean
user?: AuthUser
login: () => void
logout: () => void
getToken: () => string
error?: string
}
5 changes: 4 additions & 1 deletion apps/smartem/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthGate } from './auth'
import { routeTree } from './routeTree.gen'
import './app.css'

Expand Down Expand Up @@ -30,7 +31,9 @@ if (rootElement && !rootElement.innerHTML) {
const root = createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
<AuthGate>
<RouterProvider router={router} />
</AuthGate>
</StrictMode>
)
})
Expand Down
8 changes: 1 addition & 7 deletions apps/smartem/src/mocks/browser.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import { mswHandlers } from '@smartem/api'
import type { RequestHandler } from 'msw'
import { setupWorker } from 'msw/browser'

const generatedHandlers = Object.values(mswHandlers)
.filter((value) => typeof value === 'function')
.filter((fn) => fn.name.includes('MockHandler'))
.map((handlerFn) => (handlerFn as () => RequestHandler)())

export const worker = setupWorker(...generatedHandlers)
export const worker = setupWorker(...mswHandlers.getDefaultMock())
43 changes: 42 additions & 1 deletion apps/smartem/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Box, Typography } from '@mui/material'
import { AccountCircle, Login } from '@mui/icons-material'
import { Box, IconButton, Menu, MenuItem, Typography } from '@mui/material'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { useState } from 'react'
import { useAuth } from '../auth'

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -16,6 +19,40 @@ export const Route = createRootRoute({
component: RootComponent,
})

function AuthControls() {
const auth = useAuth()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)

if (!auth.initialised) return null

if (!auth.authenticated) {
return (
<IconButton color="inherit" onClick={() => auth.login()}>
<Login />
</IconButton>
)
}

return (
<>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
<AccountCircle />
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
<MenuItem disabled>{auth.user?.name || auth.user?.email}</MenuItem>
<MenuItem
onClick={() => {
setAnchorEl(null)
auth.logout()
}}
>
Logout
</MenuItem>
</Menu>
</>
)
}

function RootComponent() {
return (
<QueryClientProvider client={queryClient}>
Expand All @@ -27,11 +64,15 @@ function RootComponent() {
py: 2,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="h6" component="span" fontWeight={700}>
SmartEM
</Typography>
<AuthControls />
</Box>

<Box component="main" sx={{ flex: 1, p: 3 }}>
Expand Down
1 change: 0 additions & 1 deletion apps/smartem/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"include": ["src/**/*"],
"compilerOptions": {
"types": ["node", "vite/client"],
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
Expand Down
Loading