Skip to content
Merged
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
105 changes: 81 additions & 24 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>` out of a `#/framework/<id>` 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[]
Expand Down Expand Up @@ -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<HomeFramework[]>(() => loadFrameworks())
const [activeFrameworkId, setActiveFrameworkId] = useState<string | null>(null)
const [activeFrameworkId, setActiveFrameworkId] = useState<string | null>(() => getFrameworkIdFromHash())

// Store layouts extracted from CASE extensions (keyed by framework ID)
const [frameworkLayouts, setFrameworkLayouts] = useState<Record<string, LayoutState>>({})
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -245,11 +271,33 @@ function AppInner() {
[serverCfDocuments],
)

const openFramework = useCallback((id: string) => {
// Push/replace the `#/framework/<id>` 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)
Expand All @@ -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) => {
Expand All @@ -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)
Expand All @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -676,9 +735,7 @@ function AppInner() {
initialCfAssociationGroupings={tenantCfAssociationGroupings}
>
<EditorCanvas
onBack={() => {
setScreen('home')
}}
onBack={() => navigateHome()}
onSaveToServer={tenantId ? handleSaveToServer : undefined}
isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false}
onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined}
Expand Down
19 changes: 14 additions & 5 deletions apps/editor/src/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<App />
</StrictMode>,
)
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(
<StrictMode>
<App />
</StrictMode>,
)
}

31 changes: 30 additions & 1 deletion apps/editor/src/app/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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<void> {
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<AuthContextValue | null>(null)

export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) {
Expand Down Expand Up @@ -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])

Expand Down
Loading