Skip to content
Open
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
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-test.diamond.ac.uk
VITE_KEYCLOAK_REALM=dls
VITE_KEYCLOAK_CLIENT_ID=SmartEM
VITE_AUTH_ENABLED=false
2 changes: 2 additions & 0 deletions 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.100.6",
"@tanstack/react-router": "^1.168.3",
"@tanstack/router-devtools": "^1.166.11",
Expand Down
4 changes: 4 additions & 0 deletions apps/smartem/public/silent-check-sso.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!doctype html>
<html lang="en"><body><script>
parent.postMessage(location.href, location.origin)
</script></body></html>
62 changes: 62 additions & 0 deletions apps/smartem/src/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Login } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/material'
import { setAuthToken } from '@smartem/api'
import type { PropsWithChildren } from 'react'
import { AuthProvider, useAuth } from './AuthProvider'
import { isAuthEnabled } from './config'

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

return (
<AuthProvider onTokenChange={setAuthToken}>
<AuthBoundary>{children}</AuthBoundary>
</AuthProvider>
)
}

function AuthBoundary({ children }: PropsWithChildren) {
const auth = useAuth()

// Keycloak init is in flight - render nothing rather than flash either the
// sign-in screen or the app contents.
if (!auth.initialised) return null

if (!auth.authenticated) return <SignInScreen onSignIn={auth.login} error={auth.error} />

return <>{children}</>
}

function SignInScreen({ onSignIn, error }: { onSignIn: () => void; error?: string }) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
px: 3,
textAlign: 'center',
}}
>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
SmartEM
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 320 }}>
Sign in with your Diamond account to continue.
</Typography>
<Button variant="contained" startIcon={<Login fontSize="small" />} onClick={onSignIn}>
Sign in
</Button>
{error && (
<Typography variant="caption" sx={{ color: 'error.main' }}>
{error}
</Typography>
)}
</Box>
)
}
141 changes: 141 additions & 0 deletions apps/smartem/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
pkceMethod: 'S256',
// Don't fall back to a top-level redirect when the silent iframe can't
// read Keycloak's session cookie (third-party cookies are blocked by
// default in modern browsers). The fallback turns "not logged in" into
// a full page navigation, which in dev's StrictMode double-mount or
// alongside the polling iframe below produces a redirect loop.
silentCheckSsoFallback: false,
// The default post-init polling iframe (5s interval) hits the same
// third-party-cookie wall and keeps firing top-level logins. Disable
// it; cross-tab logout is handled via the token-refresh path.
checkLoginIframe: false,
})
.then(() => setAuth(buildAuth(keycloak)))
.catch((err) => {
console.error('Keycloak init failed:', err)
setAuth({
...buildAuth(keycloak),
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 || 'dls',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'SmartEM',
}
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
}
95 changes: 43 additions & 52 deletions apps/smartem/src/components/shell/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AccountCircle, Login } from '@mui/icons-material'
import {
AppBar,
Box,
Button,
Divider,
IconButton,
InputBase,
Expand All @@ -14,7 +14,8 @@ import {
Typography,
} from '@mui/material'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { useAuth } from '~/auth'
import { type CommandGroup, CommandPalette } from '~/components/widgets/CommandPalette'
import { sessions } from '~/data/mock-dashboard'
import { gray } from '~/theme'
Expand Down Expand Up @@ -53,22 +54,6 @@ function NavLink({ label, to }: { label: string; to: string }) {
)
}

type UserRole = 'visitor' | 'staff' | 'admin'

const ROLE_KEY = 'smartem-user-role'

const ROLES: { key: UserRole; label: string; short: string }[] = [
{ key: 'visitor', label: 'Visitor user', short: 'Visitor' },
{ key: 'staff', label: 'Facility staff', short: 'Staff' },
{ key: 'admin', label: 'System admin', short: 'Admin' },
]

function readRole(): UserRole {
const v = localStorage.getItem(ROLE_KEY)
if (v === 'visitor' || v === 'staff' || v === 'admin') return v
return 'staff'
}

export function Header() {
const navigate = useNavigate()
const [paletteOpen, setPaletteOpen] = useState(false)
Expand Down Expand Up @@ -234,7 +219,7 @@ export function Header() {

<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 2, flexShrink: 0 }}>
<SettingsMenu />
<RoleSwitcher />
<AuthControls />
</Box>
</Toolbar>
</AppBar>
Expand Down Expand Up @@ -349,46 +334,52 @@ function SettingsMenu() {
)
}

function RoleSwitcher() {
const [role, setRole] = useState<UserRole>(readRole)
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
function AuthControls() {
const auth = useAuth()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)

const pick = useCallback((r: UserRole) => {
setRole(r)
localStorage.setItem(ROLE_KEY, r)
setOpen(false)
}, [])
if (!auth.initialised) return null

const current = ROLES.find((r) => r.key === role) ?? ROLES[1]
if (!auth.authenticated) {
return (
<Tooltip title="Sign in">
<IconButton size="small" onClick={() => auth.login()} sx={{ color: 'text.secondary' }}>
<Login fontSize="small" />
</IconButton>
</Tooltip>
)
}

return (
<>
<Button
ref={anchorRef}
size="small"
variant="outlined"
onClick={() => setOpen((v) => !v)}
sx={{ textTransform: 'none', fontWeight: 500, fontSize: '0.8125rem' }}
>
{current.short}
</Button>
<Tooltip title={auth.user?.name || auth.user?.email || 'Account'}>
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{ color: 'text.secondary' }}
>
<AccountCircle fontSize="small" />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorRef.current}
open={open}
onClose={() => setOpen(false)}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 160 } } }}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 180 } } }}
>
{ROLES.map((r) => (
<MenuItem
key={r.key}
selected={r.key === role}
onClick={() => pick(r.key)}
sx={{ fontSize: '0.8125rem' }}
>
{r.label}
</MenuItem>
))}
<MenuItem disabled sx={{ opacity: '0.7 !important', fontSize: '0.8125rem' }}>
{auth.user?.name || auth.user?.email}
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
setAnchorEl(null)
auth.logout()
}}
sx={{ fontSize: '0.8125rem' }}
>
Sign out
</MenuItem>
</Menu>
</>
)
Expand Down
Loading