From c2f7123e8e7c8631f07ef18eb010648c9162a499 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Mon, 14 Sep 2026 13:05:27 -0400 Subject: [PATCH] Updates to properly handle silent-renewal of tokens --- apps/editor/src/app/App.tsx | 105 ++++++++++++++---- apps/editor/src/app/main.tsx | 19 +++- .../editor/src/app/providers/AuthProvider.tsx | 31 +++++- 3 files changed, 125 insertions(+), 30 deletions(-) diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index b36b023..70ee11c 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -18,6 +18,13 @@ import LoginScreen from '@/ui/auth/LoginScreen' import { detectTopology } from '@/ui/editor/layout/detectTopology' import { applyInitialLayout } from '@/ui/editor/layout/applyInitialLayout' +/** Parse the `` out of a `#/framework/` hash, or null if the hash doesn't match. */ +function getFrameworkIdFromHash(): string | null { + const hash = globalThis.location?.hash ?? '' + const match = /^#\/framework\/([^/?]+)/.exec(hash) + return match ? decodeURIComponent(match[1]) : null +} + /** Extract CFDefinitions from a raw CFPackage response and merge into tenant state */ function extractCfDefinitions(pkg: unknown): { CFItemTypes?: CFItemType[] @@ -54,9 +61,11 @@ function AppInner() { const cfg = getAppConfig() const api = useMemo(() => new CaseApiClient(createFetchHttpClient(cfg.opencaseBaseUrl, { getAccessToken })), [cfg.opencaseBaseUrl, getAccessToken]) - const [screen, setScreen] = useState<'home' | 'editor'>('home') + // Seed from the URL hash so a hard refresh (or restoring from browser history) reopens + // the same framework instead of always landing on the home screen. + const [screen, setScreen] = useState<'home' | 'editor'>(() => (getFrameworkIdFromHash() ? 'editor' : 'home')) const [frameworks, setFrameworks] = useState(() => loadFrameworks()) - const [activeFrameworkId, setActiveFrameworkId] = useState(null) + const [activeFrameworkId, setActiveFrameworkId] = useState(() => getFrameworkIdFromHash()) // Store layouts extracted from CASE extensions (keyed by framework ID) const [frameworkLayouts, setFrameworkLayouts] = useState>({}) @@ -131,8 +140,21 @@ function AppInner() { }, []) const [route, setRoute] = useState<'authCallback' | 'login' | 'app'>(() => getRoute()) + // Keep screen/activeFrameworkId in sync with the URL on browser back/forward navigation. + // (pushState/replaceState calls we make ourselves don't fire `hashchange`, so this only + // reacts to real navigation — our own navigateToFramework/navigateHome set state directly.) useEffect(() => { - const onHashChange = () => setRoute(getRoute()) + const onHashChange = () => { + setRoute(getRoute()) + const id = getFrameworkIdFromHash() + if (id) { + setActiveFrameworkId(id) + setScreen('editor') + } else if (getRoute() === 'app') { + setScreen('home') + setActiveFrameworkId(null) + } + } globalThis.addEventListener('hashchange', onHashChange) return () => globalThis.removeEventListener('hashchange', onHashChange) }, [getRoute]) @@ -170,8 +192,12 @@ function AppInner() { }, [completeSignIn, getRoute]) // Force unauthenticated users onto the login route. + // Skip while the initial session check is still in flight (authStatus starts as + // 'loading' on every mount) — otherwise a refresh or back-navigation gets bounced to + // login before the persisted session has had a chance to load. useEffect(() => { if (route === 'authCallback') return + if (authStatus === 'loading') return if (authStatus === 'authenticated') return if (globalThis.location?.hash?.startsWith('#/login')) return globalThis.history?.replaceState(null, '', '/#/login') @@ -245,11 +271,33 @@ function AppInner() { [serverCfDocuments], ) - const openFramework = useCallback((id: string) => { + // Push/replace the `#/framework/` route alongside the screen state change, so browser + // back/forward and refresh reflect which framework (if any) is open. + const navigateToFramework = useCallback((id: string, opts?: { replace?: boolean }) => { + const url = `/#/framework/${encodeURIComponent(id)}` + if (opts?.replace) { + globalThis.history?.replaceState(null, '', url) + } else { + globalThis.history?.pushState(null, '', url) + } setActiveFrameworkId(id) setScreen('editor') }, []) + const navigateHome = useCallback((opts?: { replace?: boolean }) => { + if (opts?.replace) { + globalThis.history?.replaceState(null, '', '/#/') + } else { + globalThis.history?.pushState(null, '', '/#/') + } + setActiveFrameworkId(null) + setScreen('home') + }, []) + + const openFramework = useCallback((id: string) => { + navigateToFramework(id) + }, [navigateToFramework]) + const deleteDraft = useCallback((id: string) => { setFrameworks((prev) => { const next = prev.filter((f) => f.id !== id) @@ -258,10 +306,9 @@ function AppInner() { }) // If we're deleting the active framework, go back to home if (activeFrameworkId === id) { - setActiveFrameworkId(null) - setScreen('home') + navigateHome({ replace: true }) } - }, [activeFrameworkId]) + }, [activeFrameworkId, navigateHome]) /** Remove a framework from localStorage (used after archive or hard delete) */ const removeFrameworkFromStorage = useCallback((docId: string) => { @@ -276,10 +323,9 @@ function AppInner() { return next }) if (activeFrameworkId === docId) { - setActiveFrameworkId(null) - setScreen('home') + navigateHome({ replace: true }) } - }, [activeFrameworkId]) + }, [activeFrameworkId, navigateHome]) const createNew = useCallback((draft: CreateFrameworkDraft) => { const fw = createNewFrameworkDraft(draft) @@ -288,9 +334,8 @@ function AppInner() { saveFrameworks(next) return next }) - setActiveFrameworkId(fw.id) - setScreen('editor') - }, []) + navigateToFramework(fw.id) + }, [navigateToFramework]) /** Create a HomeFramework from a pre-populated domain Framework (e.g. from spreadsheet upload). */ const createFromFramework = useCallback((framework: Framework) => { @@ -300,12 +345,11 @@ function AppInner() { saveFrameworks(next) return next }) - setActiveFrameworkId(fw.id) - setScreen('editor') - }, []) + navigateToFramework(fw.id) + }, [navigateToFramework]) const openRemoteFramework = useCallback( - async (docId: string) => { + async (docId: string, opts?: { replace?: boolean }) => { setRemoteOpenState('loading') try { // Fetch the CASE package from the API @@ -367,15 +411,30 @@ function AppInner() { // Mark as published since it was loaded from OpenCASE setPublishedFrameworkIds((prev) => new Set(prev).add(fw.id)) - setActiveFrameworkId(fw.id) - setScreen('editor') + navigateToFramework(fw.id, opts) } finally { setRemoteOpenState('idle') } }, - [api, mergeCfDefinitions], + [api, mergeCfDefinitions, navigateToFramework], ) + // If the URL points at a framework that isn't in the local cache yet (e.g. a hard refresh, + // or a deep link to a framework never opened on this device), fetch it from the server. + // Falls back to home if it can't be loaded (deleted, no access, etc.). + useEffect(() => { + if (!activeFrameworkId) return + if (authStatus !== 'authenticated') return + if (frameworks.some((f) => f.id === activeFrameworkId)) return + let cancelled = false + openRemoteFramework(activeFrameworkId, { replace: true }).catch((err: unknown) => { + if (cancelled) return + console.warn('[App] Failed to restore framework from URL:', err) + navigateHome({ replace: true }) + }) + return () => { cancelled = true } + }, [activeFrameworkId, authStatus, frameworks, openRemoteFramework, navigateHome]) + // Load a framework from the server into the local session without navigating to it. // Used by TreePanelView when the user selects a crosswalk target that isn't loaded locally yet. const handleLoadTargetFramework = useCallback( @@ -490,7 +549,7 @@ function AppInner() { // pre-fork local record. if (activeFrameworkId && result.docId && result.docId !== activeFrameworkId) { const oldId = activeFrameworkId - await openRemoteFramework(result.docId) + await openRemoteFramework(result.docId, { replace: true }) setFrameworks((prev) => { const next = prev.filter((f) => f.id !== oldId) saveFrameworks(next) @@ -676,9 +735,7 @@ function AppInner() { initialCfAssociationGroupings={tenantCfAssociationGroupings} > { - setScreen('home') - }} + onBack={() => navigateHome()} onSaveToServer={tenantId ? handleSaveToServer : undefined} isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false} onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined} diff --git a/apps/editor/src/app/main.tsx b/apps/editor/src/app/main.tsx index d99ae73..1880a4f 100644 --- a/apps/editor/src/app/main.tsx +++ b/apps/editor/src/app/main.tsx @@ -2,10 +2,19 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import '@/styles/index.css' import App from './App' +import { runSilentRenewCallback } from './providers/AuthProvider' -createRoot(document.getElementById('root')!).render( - - - , -) +const hash = globalThis.location?.hash ?? '' + +if (hash.startsWith('#/auth/silent-callback')) { + // Loaded inside the hidden iframe oidc-client-ts uses for automatic silent + // renewal — just forward the auth response to the parent window, don't mount the app. + void runSilentRenewCallback() +} else { + createRoot(document.getElementById('root')!).render( + + + , + ) +} diff --git a/apps/editor/src/app/providers/AuthProvider.tsx b/apps/editor/src/app/providers/AuthProvider.tsx index 0ec4abe..32b8fe9 100644 --- a/apps/editor/src/app/providers/AuthProvider.tsx +++ b/apps/editor/src/app/providers/AuthProvider.tsx @@ -52,7 +52,7 @@ function pickUserName(user: User | null): string | null { return candidates[0] ?? null } -function createUserManager(params: { authority: string; clientId: string; redirectUri: string; tenantId: string }) { +export function createUserManager(params: { authority: string; clientId: string; redirectUri: string; tenantId: string }) { // Use per-tenant prefixes to avoid mixing tokens/state across tenant client_ids. const prefix = `case-editor:oidc:${params.tenantId}:` const postLogoutRedirectUri = `${globalThis.location?.origin ?? ''}/#/login` @@ -66,9 +66,30 @@ function createUserManager(params: { authority: string; clientId: string; redire // The Keycloak client-per-tenant model means we need clean tenant switching. userStore: new WebStorageStateStore({ store: globalThis.localStorage, prefix }), stateStore: new WebStorageStateStore({ store: globalThis.sessionStorage, prefix }), + // Renew the access token in a hidden iframe shortly before it expires, using the + // IdP's own SSO session cookie — no refresh_token / offline_access scope needed. + automaticSilentRenew: true, + silent_redirect_uri: `${globalThis.location?.origin ?? ''}/#/auth/silent-callback`, }) } +/** + * Entry point for the hidden iframe oidc-client-ts navigates to for silent renewal + * (see `silent_redirect_uri` above). Forwards the auth response back to the parent + * window's UserManager; must not mount the app or touch auth state itself. + */ +export async function runSilentRenewCallback(): Promise { + const cfg = getAppConfig() + const tenantId = readTenantId() + const mgr = createUserManager({ + authority: cfg.oidcAuthority, + clientId: `${cfg.oidcClientIdPrefix}${tenantId}`, + redirectUri: `${globalThis.location?.origin ?? ''}/#/auth/callback`, + tenantId, + }) + await mgr.signinSilentCallback() +} + const AuthContext = createContext(null) export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { @@ -118,15 +139,23 @@ export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { setUser(null) setStatus('anonymous') } + const onSilentRenewError = (e: Error) => { + // Automatic renewal failed (e.g. IdP session expired, third-party cookies blocked). + // Leave the user signed in with their current token — `addAccessTokenExpired` is + // still the fallback that logs them out once that token actually expires. + console.warn('[Auth] Silent token renewal failed:', e) + } userManager.events.addUserLoaded(onLoaded) userManager.events.addUserUnloaded(onUnloaded) userManager.events.addAccessTokenExpired(onUnloaded) + userManager.events.addSilentRenewError(onSilentRenewError) return () => { userManager.events.removeUserLoaded(onLoaded) userManager.events.removeUserUnloaded(onUnloaded) userManager.events.removeAccessTokenExpired(onUnloaded) + userManager.events.removeSilentRenewError(onSilentRenewError) } }, [userManager])