diff --git a/apps/smartem/.env.example b/apps/smartem/.env.example
new file mode 100644
index 00000000..50baf546
--- /dev/null
+++ b/apps/smartem/.env.example
@@ -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
diff --git a/apps/smartem/package.json b/apps/smartem/package.json
index b83f1759..c36275a6 100644
--- a/apps/smartem/package.json
+++ b/apps/smartem/package.json
@@ -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",
diff --git a/apps/smartem/public/silent-check-sso.html b/apps/smartem/public/silent-check-sso.html
new file mode 100644
index 00000000..66da34c6
--- /dev/null
+++ b/apps/smartem/public/silent-check-sso.html
@@ -0,0 +1,4 @@
+
+
diff --git a/apps/smartem/src/auth/AuthGate.tsx b/apps/smartem/src/auth/AuthGate.tsx
new file mode 100644
index 00000000..0ceab228
--- /dev/null
+++ b/apps/smartem/src/auth/AuthGate.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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
+
+ return <>{children}>
+}
+
+function SignInScreen({ onSignIn, error }: { onSignIn: () => void; error?: string }) {
+ return (
+
+
+ SmartEM
+
+
+ Sign in with your Diamond account to continue.
+
+ } onClick={onSignIn}>
+ Sign in
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/apps/smartem/src/auth/AuthProvider.tsx b/apps/smartem/src/auth/AuthProvider.tsx
new file mode 100644
index 00000000..513a21ab
--- /dev/null
+++ b/apps/smartem/src/auth/AuthProvider.tsx
@@ -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(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(defaultAuth)
+ const refreshTimer = useRef>(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 {children}
+}
diff --git a/apps/smartem/src/auth/config.ts b/apps/smartem/src/auth/config.ts
new file mode 100644
index 00000000..4602747f
--- /dev/null
+++ b/apps/smartem/src/auth/config.ts
@@ -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',
+}
diff --git a/apps/smartem/src/auth/index.ts b/apps/smartem/src/auth/index.ts
new file mode 100644
index 00000000..c5bdb5dc
--- /dev/null
+++ b/apps/smartem/src/auth/index.ts
@@ -0,0 +1,3 @@
+export { AuthGate } from './AuthGate'
+export { AuthProvider, useAuth } from './AuthProvider'
+export type { Auth, AuthUser } from './types'
diff --git a/apps/smartem/src/auth/types.ts b/apps/smartem/src/auth/types.ts
new file mode 100644
index 00000000..4f290028
--- /dev/null
+++ b/apps/smartem/src/auth/types.ts
@@ -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
+}
diff --git a/apps/smartem/src/components/shell/Header.tsx b/apps/smartem/src/components/shell/Header.tsx
index e2de80d1..519b0e45 100644
--- a/apps/smartem/src/components/shell/Header.tsx
+++ b/apps/smartem/src/components/shell/Header.tsx
@@ -1,7 +1,7 @@
+import { AccountCircle, Login } from '@mui/icons-material'
import {
AppBar,
Box,
- Button,
Divider,
IconButton,
InputBase,
@@ -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'
@@ -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)
@@ -234,7 +219,7 @@ export function Header() {
-
+
@@ -349,46 +334,52 @@ function SettingsMenu() {
)
}
-function RoleSwitcher() {
- const [role, setRole] = useState(readRole)
- const [open, setOpen] = useState(false)
- const anchorRef = useRef(null)
+function AuthControls() {
+ const auth = useAuth()
+ const [anchorEl, setAnchorEl] = useState(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 (
+
+ auth.login()} sx={{ color: 'text.secondary' }}>
+
+
+
+ )
+ }
return (
<>
-
+
+ setAnchorEl(e.currentTarget)}
+ sx={{ color: 'text.secondary' }}
+ >
+
+
+
>
)
diff --git a/apps/smartem/src/main.tsx b/apps/smartem/src/main.tsx
index b53f9c6a..d8d10fa0 100644
--- a/apps/smartem/src/main.tsx
+++ b/apps/smartem/src/main.tsx
@@ -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'
@@ -30,7 +31,9 @@ if (rootElement && !rootElement.innerHTML) {
const root = createRoot(rootElement)
root.render(
-
+
+
+
)
})
diff --git a/enhancements.md b/enhancements.md
deleted file mode 100644
index 2a2ed090..00000000
--- a/enhancements.md
+++ /dev/null
@@ -1,2202 +0,0 @@
-# SmartEM Frontend - Comprehensive Improvement Analysis
-
-**Analysis Date:** 2025-10-07
-**Framework:** React 19 + React Router 7 + TypeScript
-**Current State:** Production-ready application with modern tooling
-
----
-
-## Executive Summary
-
-The SmartEM frontend is a well-architected React application using modern technologies including React 19, React Router 7, TanStack Query, and Material-UI. The codebase demonstrates good practices in several areas, particularly in API client generation and type safety. However, there are significant opportunities for improvement in code organization, component architecture, testing, and developer tooling.
-
-**Overall Assessment:** 7/10 - Solid foundation with room for modernization and refinement.
-
----
-
-## 1. Project Structure and Organization
-
-### Current State
-
-```
-app/
-├── api/
-│ ├── generated/ # Auto-generated (300+ files)
-│ ├── mutator.ts
-│ ├── version-check.ts
-│ └── openapi.json
-├── components/ # 6 components, mixed purposes
-├── hooks/ # Empty directory
-├── mocks/ # MSW setup (2 files)
-├── routes/ # 10 route components (94-432 lines each)
-├── utils/ # Empty directory
-├── routes.ts
-├── root.tsx
-└── entry.client.tsx
-```
-
-### Issues Identified
-
-1. **Flat component structure** - All components in single directory without categorization
-2. **Large route components** - Some routes have 400+ lines (atlas.tsx: 432 lines, squareLR.tsx: 396 lines)
-3. **Empty directories** - `hooks/` and `utils/` directories exist but are empty
-4. **Mixed responsibilities** - Components contain business logic, API calls, and presentation
-5. **No feature-based organization** - Everything organized by technical type rather than domain
-
-### Recommended Improvements
-
-#### 1.1 Adopt Feature-Based Structure
-
-**Effort:** Medium | **Priority:** High | **Impact:** High
-
-Reorganize code by domain features:
-
-```
-app/
-├── features/
-│ ├── acquisitions/
-│ │ ├── components/
-│ │ ├── hooks/
-│ │ ├── routes/
-│ │ └── types.ts
-│ ├── atlas/
-│ │ ├── components/
-│ │ ├── hooks/
-│ │ └── routes/
-│ ├── grids/
-│ └── predictions/
-├── shared/
-│ ├── components/ # Navbar, ApiVersionCheck, etc.
-│ ├── hooks/
-│ ├── utils/
-│ └── theme/
-├── api/
-└── core/ # Root-level app setup
-```
-
-**Benefits:**
-
-- Better code discoverability
-- Easier to understand feature scope
-- Reduced coupling between features
-- Easier onboarding for new developers
-- Clearer ownership boundaries
-
-#### 1.2 Split Large Route Components
-
-**Effort:** Medium | **Priority:** High | **Impact:** High
-
-Break down large route files (atlas.tsx, squareLR.tsx, grid.tsx) into smaller, focused components:
-
-**Current:** `app/routes/atlas.tsx` (432 lines)
-**Proposed:**
-
-```
-app/features/atlas/
-├── routes/
-│ └── AtlasRoute.tsx (50-100 lines)
-├── components/
-│ ├── AtlasViewer.tsx
-│ ├── LatentSpacePanel.tsx
-│ ├── PredictionControls.tsx
-│ └── GridSquareOverlay.tsx
-├── hooks/
-│ ├── useAtlasData.ts
-│ ├── usePredictions.ts
-│ └── useLatentRepresentation.ts
-└── types.ts
-```
-
-**Benefits:**
-
-- Improved testability
-- Better code reuse
-- Easier to understand and maintain
-- Clearer separation of concerns
-
-#### 1.3 Create Proper Barrel Exports
-
-**Effort:** Low | **Priority:** Medium | **Impact:** Medium
-
-Add index.ts files for cleaner imports:
-
-```typescript
-// app/features/atlas/index.ts
-export { AtlasRoute } from './routes/AtlasRoute'
-export * from './types'
-
-// Usage
-import { AtlasRoute } from '~/features/atlas'
-```
-
-**Benefits:**
-
-- Cleaner import statements
-- Better encapsulation
-- Easier refactoring
-
----
-
-## 2. TypeScript Usage and Type Safety
-
-### Current State
-
-**Strengths:**
-
-- TypeScript strict mode enabled (`tsconfig.json`: `"strict": true`)
-- Auto-generated types from OpenAPI spec
-- Comprehensive API type coverage
-- Proper type exports from generated code
-
-**Issues:**
-
-1. **4 instances of `@ts-ignore` or `any` types** in application code
-2. **Inconsistent type imports** - Mix of `import type` and regular imports
-3. **Missing custom types** - Domain types not formalized
-4. **Inline type definitions** - Types defined in components instead of separate files
-
-### Recommended Improvements
-
-#### 2.1 Remove All Type Suppressions
-
-**Effort:** Low | **Priority:** High | **Impact:** Medium
-
-Current issues in `/home/vredchenko/dev/DLS/smartem-frontend/app/api/mutator.ts`:
-
-```typescript
-// @ts-ignore
-promise.cancel = () => {
- source.cancel('Query was cancelled')
-}
-```
-
-**Solution:**
-
-```typescript
-type CancellablePromise = Promise & {
- cancel: () => void
-}
-
-export const customInstance = (
- config: AxiosRequestConfig,
- options?: AxiosRequestConfig
-): CancellablePromise => {
- const source = Axios.CancelToken.source()
- const promise = AXIOS_INSTANCE({
- ...config,
- ...options,
- cancelToken: source.token,
- }).then(({ data }) => data) as CancellablePromise
-
- promise.cancel = () => {
- source.cancel('Query was cancelled')
- }
-
- return promise
-}
-```
-
-**Benefits:**
-
-- Full type safety
-- Better IDE autocomplete
-- Catch errors at compile time
-
-#### 2.2 Consolidate Types into Domain Files
-
-**Effort:** Medium | **Priority:** Medium | **Impact:** Medium
-
-**Current:** Types scattered across route files
-
-```typescript
-// In atlas.tsx
-type GridSquare = GridSquareResponse
-type PredictionModel = QualityPredictionModelResponse
-type Coords = { x: number; y: number; index: number }
-```
-
-**Proposed:**
-
-```typescript
-// app/features/atlas/types.ts
-export type GridSquare = GridSquareResponse
-export type PredictionModel = QualityPredictionModelResponse
-export type Coords = { x: number; y: number; index: number }
-export type AtlasViewState = {
- selectedSquare: string
- showPredictions: boolean
- selectionFrozen: boolean
-}
-```
-
-**Benefits:**
-
-- Single source of truth for types
-- Easier to share types across files
-- Better type documentation
-
-#### 2.3 Use Consistent Type Import Syntax
-
-**Effort:** Low | **Priority:** Low | **Impact:** Low
-
-Standardize on `import type` for all type-only imports:
-
-```typescript
-// Before (inconsistent)
-import { GridSquareResponse } from '../api/generated/models'
-import type { SelectChangeEvent } from '@mui/material'
-
-// After (consistent)
-import type { GridSquareResponse } from '../api/generated/models'
-import type { SelectChangeEvent } from '@mui/material'
-```
-
-**Benefits:**
-
-- Clearer intent
-- Better tree-shaking
-- Faster compilation
-
-#### 2.4 Enable Additional TypeScript Strict Checks
-
-**Effort:** Low | **Priority:** Medium | **Impact:** Medium
-
-Update `tsconfig.json`:
-
-```json
-{
- "compilerOptions": {
- "strict": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
- "exactOptionalPropertyTypes": true
- }
-}
-```
-
-**Benefits:**
-
-- Catch more potential runtime errors
-- Better null/undefined handling
-- Stronger type guarantees
-
----
-
-## 3. React Patterns and Best Practices
-
-### Current State
-
-**Good Practices:**
-
-- Using React 19 features
-- Functional components throughout
-- React.StrictMode enabled
-- Using hooks appropriately
-
-**Issues Identified:**
-
-1. **72 instances of `React.useState`, `React.useEffect`** - Unnecessary namespace usage
-2. **No custom hooks** - Business logic embedded in components
-3. **Prop drilling** - No context usage for shared state
-4. **Mixed concerns** - Components handle data fetching, state, and UI
-5. **Inline styles** - Heavy use of `style={}` props (78 instances)
-6. **No component composition patterns** - Monolithic components
-7. **Missing error boundaries** - Only root-level error boundary
-8. **No loading states abstraction** - Repeated loading/error patterns
-
-### Recommended Improvements
-
-#### 3.1 Remove React Namespace from Hooks
-
-**Effort:** Low | **Priority:** Medium | **Impact:** Low
-
-**Current:**
-
-```typescript
-import React from 'react'
-const [state, setState] = React.useState(false)
-```
-
-**Proposed:**
-
-```typescript
-import { useState } from 'react'
-const [state, setState] = useState(false)
-```
-
-**Benefits:**
-
-- Cleaner code
-- Modern React conventions
-- Smaller bundle (tree-shaking)
-
-#### 3.2 Extract Custom Hooks
-
-**Effort:** Medium | **Priority:** High | **Impact:** High
-
-**Current:** Business logic in components
-
-```typescript
-// In atlas.tsx (432 lines)
-export default function Atlas({ loaderData, params }: Route.ComponentProps) {
- const [predictions, setPredictions] = React.useState