@@ -139,6 +206,11 @@ export default function KeycloakProvider({ children }: { children: ReactNode })
return (
{children}
+ { setShowTimeoutModal(false); logout('inactivity') }}
+ />
)
}
diff --git a/src/lib/keycloak/keycloak.ts b/src/lib/keycloak/keycloak.ts
index 1a3455a..ec842ed 100644
--- a/src/lib/keycloak/keycloak.ts
+++ b/src/lib/keycloak/keycloak.ts
@@ -1,5 +1,8 @@
import Keycloak from 'keycloak-js'
+// Single shared Keycloak instance for the whole app — Login.tsx (ROPC),
+// KeycloakProvider.tsx, and the axios interceptors all read/write this same
+// object. Nothing else should construct a second `new Keycloak(...)`.
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
@@ -14,4 +17,24 @@ export function decodeJwtPayload(token: string) {
return JSON.parse(atob(padded))
}
+export const TOKEN_KEY = 'kc_token'
+export const REFRESH_TOKEN_KEY = 'kc_refresh_token'
+export const ID_TOKEN_KEY = 'kc_id_token'
+
+// Single source of truth for the token-storage contract — every read/write
+// of the kc_* localStorage keys should go through these two functions
+// instead of raw localStorage calls, so the set of persisted keys can
+// change in one place. Never log the values passed through here.
+export function persistTokens(kc: Pick
) {
+ if (kc.token) localStorage.setItem(TOKEN_KEY, kc.token)
+ if (kc.refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, kc.refreshToken)
+ if (kc.idToken) localStorage.setItem(ID_TOKEN_KEY, kc.idToken)
+}
+
+export function clearTokens() {
+ localStorage.removeItem(TOKEN_KEY)
+ localStorage.removeItem(REFRESH_TOKEN_KEY)
+ localStorage.removeItem(ID_TOKEN_KEY)
+}
+
export default keycloak
diff --git a/src/lib/keycloak/refresh.test.ts b/src/lib/keycloak/refresh.test.ts
new file mode 100644
index 0000000..1c7b43f
--- /dev/null
+++ b/src/lib/keycloak/refresh.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+const mockKeycloak = {
+ updateToken: vi.fn(),
+ token: undefined as string | undefined,
+ refreshToken: undefined as string | undefined,
+ idToken: undefined as string | undefined,
+}
+
+vi.mock('./keycloak', async () => {
+ const actual = await vi.importActual('./keycloak')
+ return {
+ ...actual,
+ default: mockKeycloak,
+ }
+})
+
+// Imported after the mock so it picks up the mocked default export.
+const { refreshToken, isTokenExpiringSoon } = await import('./refresh')
+const { TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY } = await import('./keycloak')
+
+function base64UrlEncode(json: object) {
+ const base64 = btoa(JSON.stringify(json))
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+function fakeToken(exp: number) {
+ return `header.${base64UrlEncode({ sub: 'user-1', exp })}.signature`
+}
+
+describe('refreshToken', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ mockKeycloak.updateToken.mockReset()
+ mockKeycloak.token = undefined
+ mockKeycloak.refreshToken = undefined
+ mockKeycloak.idToken = undefined
+ })
+
+ it('persists the new tokens on a successful refresh', async () => {
+ mockKeycloak.updateToken.mockImplementation(async () => {
+ mockKeycloak.token = 'new-access-token'
+ mockKeycloak.refreshToken = 'new-refresh-token'
+ mockKeycloak.idToken = 'new-id-token'
+ return true
+ })
+
+ const result = await refreshToken()
+
+ expect(result).toBe(true)
+ expect(localStorage.getItem(TOKEN_KEY)).toBe('new-access-token')
+ expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe('new-refresh-token')
+ expect(localStorage.getItem(ID_TOKEN_KEY)).toBe('new-id-token')
+ })
+
+ it('clears all stored tokens and resolves false when refresh fails', async () => {
+ localStorage.setItem(TOKEN_KEY, 'stale-access-token')
+ localStorage.setItem(REFRESH_TOKEN_KEY, 'stale-refresh-token')
+ mockKeycloak.updateToken.mockRejectedValue(new Error('invalid_grant'))
+
+ const result = await refreshToken()
+
+ expect(result).toBe(false)
+ expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
+ expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull()
+ })
+
+ it('retries once after a transient failure before giving up', async () => {
+ vi.useFakeTimers()
+ mockKeycloak.updateToken
+ .mockRejectedValueOnce(new Error('network error'))
+ .mockRejectedValueOnce(new Error('network error'))
+
+ const resultPromise = refreshToken()
+ await vi.advanceTimersByTimeAsync(2000)
+ const result = await resultPromise
+
+ expect(mockKeycloak.updateToken).toHaveBeenCalledTimes(2)
+ expect(result).toBe(false)
+ vi.useRealTimers()
+ })
+
+ it('succeeds on the retry after one transient failure', async () => {
+ vi.useFakeTimers()
+ mockKeycloak.updateToken.mockRejectedValueOnce(new Error('network error')).mockImplementationOnce(async () => {
+ mockKeycloak.token = 'recovered-token'
+ return true
+ })
+
+ const resultPromise = refreshToken()
+ await vi.advanceTimersByTimeAsync(2000)
+ const result = await resultPromise
+
+ expect(mockKeycloak.updateToken).toHaveBeenCalledTimes(2)
+ expect(result).toBe(true)
+ expect(localStorage.getItem(TOKEN_KEY)).toBe('recovered-token')
+ vi.useRealTimers()
+ })
+})
+
+describe('isTokenExpiringSoon', () => {
+ afterEach(() => {
+ localStorage.clear()
+ })
+
+ it('returns true when there is no stored token', () => {
+ expect(isTokenExpiringSoon(0)).toBe(true)
+ })
+
+ it('returns false for a token with plenty of remaining validity', () => {
+ const farFutureExp = Math.floor(Date.now() / 1000) + 3600
+ localStorage.setItem('kc_token', fakeToken(farFutureExp))
+
+ expect(isTokenExpiringSoon(5 * 60_000)).toBe(false)
+ })
+
+ it('returns true for a token expiring within the given window', () => {
+ const soonExp = Math.floor(Date.now() / 1000) + 60
+ localStorage.setItem('kc_token', fakeToken(soonExp))
+
+ expect(isTokenExpiringSoon(5 * 60_000)).toBe(true)
+ })
+})
diff --git a/src/lib/keycloak/refresh.ts b/src/lib/keycloak/refresh.ts
new file mode 100644
index 0000000..2845f19
--- /dev/null
+++ b/src/lib/keycloak/refresh.ts
@@ -0,0 +1,53 @@
+// Framework-agnostic — no React import — so this can be used from both
+// KeycloakProvider.tsx and the axios interceptors without a circular import
+// between a file that exports React hooks/components and one axios needs.
+import keycloak, { decodeJwtPayload, persistTokens, clearTokens, TOKEN_KEY } from './keycloak'
+
+const MIN_VALIDITY_SECONDS = 300 // refresh if less than 5 min of validity remains
+const RETRY_DELAY_MS = 2000
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function attemptUpdateToken(): Promise {
+ const refreshed = await keycloak.updateToken(MIN_VALIDITY_SECONDS)
+ if (refreshed) persistTokens(keycloak)
+ return true
+}
+
+// Silently refreshes the access token via keycloak-js's own updateToken(),
+// which uses the refresh token keycloak-js was hydrated with (see
+// Login.tsx / KeycloakProvider.tsx). Never logs token values — only the
+// boolean outcome is observable from here.
+export async function refreshToken(): Promise {
+ try {
+ return await attemptUpdateToken()
+ } catch {
+ // Could be a transient network error rather than a truly invalid
+ // refresh token — give it one more try before giving up.
+ await delay(RETRY_DELAY_MS)
+ try {
+ return await attemptUpdateToken()
+ } catch {
+ clearTokens()
+ return false
+ }
+ }
+}
+
+// Pure check against the persisted access token — usable standalone
+// (e.g. in ProtectedRoute or tests) without depending on keycloak-js
+// instance state, which may not be hydrated yet on a fresh page load.
+export function isTokenExpiringSoon(minValidityMs: number): boolean {
+ const token = localStorage.getItem(TOKEN_KEY)
+ if (!token) return true
+
+ try {
+ const payload = decodeJwtPayload(token)
+ const msUntilExpiry = payload.exp * 1000 - Date.now()
+ return msUntilExpiry < minValidityMs
+ } catch {
+ return true
+ }
+}
diff --git a/src/lib/keycloak/useIdleTimer.test.ts b/src/lib/keycloak/useIdleTimer.test.ts
new file mode 100644
index 0000000..b116ffa
--- /dev/null
+++ b/src/lib/keycloak/useIdleTimer.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useIdleTimer } from './useIdleTimer'
+
+describe('useIdleTimer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('fires onWarn at idleMs - warnBeforeMs without yet firing onIdle', () => {
+ const onWarn = vi.fn()
+ const onIdle = vi.fn()
+
+ renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle }))
+
+ act(() => {
+ vi.advanceTimersByTime(700)
+ })
+
+ expect(onWarn).toHaveBeenCalledTimes(1)
+ expect(onIdle).not.toHaveBeenCalled()
+ })
+
+ it('fires onIdle once the full idle duration elapses', () => {
+ const onWarn = vi.fn()
+ const onIdle = vi.fn()
+
+ renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle }))
+
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(onIdle).toHaveBeenCalledTimes(1)
+ })
+
+ it('restarts the clock when reset() is called', () => {
+ const onWarn = vi.fn()
+ const onIdle = vi.fn()
+
+ const { result } = renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle }))
+
+ act(() => {
+ vi.advanceTimersByTime(600)
+ result.current.reset()
+ vi.advanceTimersByTime(600)
+ })
+
+ // 1200ms of real elapsed time, but reset() at 600ms means only 600ms
+ // has passed since the last reset — under the 700ms warn threshold.
+ expect(onWarn).not.toHaveBeenCalled()
+ })
+
+ it('resets on a tracked DOM activity event', () => {
+ const onWarn = vi.fn()
+ const onIdle = vi.fn()
+
+ renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle }))
+
+ act(() => {
+ vi.advanceTimersByTime(600)
+ window.dispatchEvent(new Event('keydown'))
+ vi.advanceTimersByTime(600)
+ })
+
+ expect(onWarn).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/lib/keycloak/useIdleTimer.ts b/src/lib/keycloak/useIdleTimer.ts
new file mode 100644
index 0000000..b4ec155
--- /dev/null
+++ b/src/lib/keycloak/useIdleTimer.ts
@@ -0,0 +1,59 @@
+import { useEffect, useRef } from 'react'
+import { on } from '@/lib/events'
+
+interface UseIdleTimerOptions {
+ idleMs: number
+ warnBeforeMs: number
+ onWarn: () => void
+ onIdle: () => void
+}
+
+const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll'] as const
+
+// Tracks inactivity independent of JWT expiry — driven by real user
+// interaction (DOM events) and API activity (the 'activity' event emitted
+// by the axios interceptors), not by the access token's exp claim.
+export function useIdleTimer({ idleMs, warnBeforeMs, onWarn, onIdle }: UseIdleTimerOptions) {
+ const warnTimeoutRef = useRef | null>(null)
+ const idleTimeoutRef = useRef | null>(null)
+ // Keep the latest callbacks in refs so `reset` doesn't need to change
+ // identity every time a consumer passes a fresh inline function. Synced
+ // in an effect, not during render, to avoid mutating a ref while rendering.
+ const onWarnRef = useRef(onWarn)
+ const onIdleRef = useRef(onIdle)
+ useEffect(() => {
+ onWarnRef.current = onWarn
+ onIdleRef.current = onIdle
+ }, [onWarn, onIdle])
+
+ const reset = () => {
+ if (warnTimeoutRef.current) clearTimeout(warnTimeoutRef.current)
+ if (idleTimeoutRef.current) clearTimeout(idleTimeoutRef.current)
+
+ const warnDelay = Math.max(idleMs - warnBeforeMs, 0)
+ warnTimeoutRef.current = setTimeout(() => onWarnRef.current(), warnDelay)
+ idleTimeoutRef.current = setTimeout(() => onIdleRef.current(), idleMs)
+ }
+
+ useEffect(() => {
+ reset()
+
+ const handleActivity = () => reset()
+ for (const type of ACTIVITY_EVENTS) {
+ window.addEventListener(type, handleActivity, { passive: true })
+ }
+ const unsubscribeActivity = on('activity', handleActivity)
+
+ return () => {
+ if (warnTimeoutRef.current) clearTimeout(warnTimeoutRef.current)
+ if (idleTimeoutRef.current) clearTimeout(idleTimeoutRef.current)
+ for (const type of ACTIVITY_EVENTS) {
+ window.removeEventListener(type, handleActivity)
+ }
+ unsubscribeActivity()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [idleMs, warnBeforeMs])
+
+ return { reset }
+}
diff --git a/src/main.tsx b/src/main.tsx
index 45810cd..2b96857 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -4,6 +4,8 @@ import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import KeycloakProvider from '@/lib/keycloak/KeycloakProvider'
+import ProtectedRoute from '@/components/shared/ProtectedRoute'
+import ForbiddenListener from '@/components/shared/ForbiddenListener'
import ToastProvider from '@/components/shared/ToastProvider'
import AppLayout from '@/components/shared/AppLayout'
import SplashScreen from '@/pages/SplashScreen'
@@ -26,6 +28,7 @@ import AccountMapperSelfService from '@/pages/AccountMapperSelfService'
function AuthRoot() {
return (
+
)
@@ -39,27 +42,32 @@ const router = createBrowserRouter([
{ path: '/login', element: },
{ path: '/account-mapper/self-service', element: },
- // Protected routes — wrapped by KeycloakProvider
+ // Protected routes — wrapped by KeycloakProvider, guarded by ProtectedRoute
{
element: ,
children: [
{
- path: '/',
- element: ,
+ element: ,
children: [
- { index: true, element: },
- { path: 'payment-hub', element: },
- { path: 'payment-hub/batch/:batchId', element: },
- { path: 'vouchers', element: },
- { path: 'account-mapper', element: },
- { path: 'g2p-config', element: },
- { path: 'settings', element: },
- { path: 'rbac', element: },
- { path: 'reporting', element: },
- { path: 'visualizations', element: },
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'payment-hub', element: },
+ { path: 'payment-hub/batch/:batchId', element: },
+ { path: 'vouchers', element: },
+ { path: 'account-mapper', element: },
+ { path: 'g2p-config', element: },
+ { path: 'settings', element: },
+ { path: 'rbac', element: },
+ { path: 'reporting', element: },
+ { path: 'visualizations', element: },
+ ],
+ },
+ { path: '*', element: },
],
},
- { path: '*', element: },
],
},
])
diff --git a/src/modules/auth/Login.tsx b/src/modules/auth/Login.tsx
index 2608995..42b816f 100644
--- a/src/modules/auth/Login.tsx
+++ b/src/modules/auth/Login.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react'
+import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { User, Lock, Eye, EyeOff, Loader2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
@@ -12,11 +12,19 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
+import { useToast } from '@/components/shared/ToastProvider'
+import keycloak, { persistTokens } from '@/lib/keycloak/keycloak'
const TENANTS = ['greenbank', 'bluebank', 'redbank']
+const LOGOUT_REASON_MESSAGES: Record = {
+ inactivity: 'Session ended due to inactivity.',
+ expired: 'Your session expired. Please log in again.',
+}
+
export default function Login() {
const navigate = useNavigate()
+ const { toast } = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [tenant, setTenant] = useState('')
@@ -25,6 +33,18 @@ export default function Login() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
+ // Surface why the user landed back here after a hard logout redirect —
+ // no in-memory state survives that navigation, so this is passed via
+ // sessionStorage by KeycloakProvider/authInterceptors.
+ useEffect(() => {
+ const reason = sessionStorage.getItem('logout_reason')
+ if (reason) {
+ toast(LOGOUT_REASON_MESSAGES[reason] ?? 'Session ended.', 'warning')
+ sessionStorage.removeItem('logout_reason')
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
@@ -46,7 +66,16 @@ export default function Login() {
)
const data = await response.json()
if (data.access_token) {
- localStorage.setItem('kc_token', data.access_token)
+ // Hand the ROPC tokens to the shared keycloak-js instance so its own
+ // updateToken()/logout() have a real refresh/id token to work with —
+ // without this, keycloak.token stays undefined forever and silent
+ // refresh has nothing to refresh.
+ await keycloak.init({
+ token: data.access_token,
+ refreshToken: data.refresh_token,
+ idToken: data.id_token,
+ })
+ persistTokens(keycloak)
localStorage.setItem('tenant', tenant)
navigate('/')
} else {