fix(workspace): recover from stale sessions instead of blank loader after impersonation (#5688)

* fix(workspace): recover from stale sessions instead of blank loader after impersonation

* fix(workspace): gate stale-session recovery on auth state and surface failed sign-out

* fix(workspace): proceed with redirect when session query errors but cached identity exists
This commit is contained in:
Waleed
2026-07-15 10:24:45 -07:00
committed by GitHub
parent 64280c955e
commit 4d0973fa9a
3 changed files with 107 additions and 22 deletions
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { Banner } from '@sim/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
import { clearUserData } from '@/stores'
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
@@ -35,8 +36,9 @@ export function ImpersonationBanner() {
onError: () => {
setIsRedirecting(false)
},
onSuccess: () => {
onSuccess: async () => {
setIsRedirecting(true)
await clearUserData()
window.location.assign('/workspace')
},
})
@@ -21,6 +21,7 @@ import {
} from '@/hooks/queries/admin-users'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useImportWorkflow } from '@/hooks/queries/workflows'
import { clearUserData } from '@/stores'
const PAGE_SIZE = 20 as const
@@ -109,7 +110,8 @@ export function Admin() {
onError: () => {
setImpersonatingUserId(null)
},
onSuccess: () => {
onSuccess: async () => {
await clearUserData()
window.location.assign('/workspace')
},
}
+101 -20
View File
@@ -1,35 +1,88 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Chip } from '@sim/emcn'
import { CircleAlert } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { useSession } from '@/lib/auth/auth-client'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
import { clearUserData } from '@/stores'
const logger = createLogger('WorkspacePage')
/**
* A 401 while the session claims we're authenticated means the auth cookies
* are stale or inconsistent (e.g. after an impersonation session expired or
* was switched). The only reliable recovery is a full sign-out, which clears
* every auth cookie server-side — matching what "clear browser cache" did
* manually — followed by a clean login.
*/
function isStaleSessionError(error: unknown): boolean {
return isApiClientError(error) && error.status === 401
}
/**
* Signs out (clearing every auth cookie server-side), wipes per-user client
* state, and navigates to login. Returns false without navigating when the
* sign-out request fails — the cookies are still set, so going to /login
* would only get bounced back to /workspace by the middleware.
*/
async function recoverFromStaleSession(): Promise<boolean> {
try {
await signOut()
} catch (error) {
logger.error('Failed to sign out while recovering from a stale session:', error)
return false
}
await clearUserData()
window.location.assign('/login')
return true
}
export default function WorkspacePage() {
const router = useRouter()
const { data: session, isPending: isSessionPending } = useSession()
const { data: session, isPending: isSessionPending, error: sessionError } = useSession()
const isAuthenticated = !isSessionPending && !!session?.user
const hasRedirectedRef = useRef(false)
const isRecoveringRef = useRef(false)
const [recoveryFailed, setRecoveryFailed] = useState(false)
const { data, isLoading: isWorkspacesLoading } = useWorkspacesWithMetadata(isAuthenticated)
const {
data,
isLoading: isWorkspacesLoading,
error: workspacesError,
} = useWorkspacesWithMetadata(isAuthenticated)
useEffect(() => {
if (!isAuthenticated || !isStaleSessionError(workspacesError) || isRecoveringRef.current) return
isRecoveringRef.current = true
logger.warn('Session cookies are stale (authenticated session but 401 API); signing out')
void recoverFromStaleSession().then((recovered) => {
if (recovered) return
isRecoveringRef.current = false
setRecoveryFailed(true)
})
}, [isAuthenticated, workspacesError])
useEffect(() => {
if (isSessionPending || hasRedirectedRef.current) return
if (!session?.user) {
// Indeterminate auth (errored session query, no cached identity): show
// the error card — /login would bounce back while a session cookie exists.
if (sessionError) return
logger.info('User not authenticated, redirecting to login')
router.replace('/login')
return
}
if (isWorkspacesLoading || !data) return
if (isWorkspacesLoading || workspacesError || !data) return
hasRedirectedRef.current = true
@@ -57,26 +110,54 @@ export default function WorkspacePage() {
logger.info(`Redirecting to workspace: ${targetWorkspace.id}`)
router.replace(`/workspace/${targetWorkspace.id}/home`)
}, [session, isSessionPending, isWorkspacesLoading, data, router])
}, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router])
if (isSessionPending || isWorkspacesLoading) {
const failedToLoad =
recoveryFailed ||
(Boolean(sessionError) && !session?.user) ||
(isAuthenticated && Boolean(workspacesError) && !isStaleSessionError(workspacesError))
if (failedToLoad) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div
className='size-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
<main className='flex h-screen w-full items-center justify-center bg-[var(--surface-1)] p-6'>
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
<div className='flex size-10 items-center justify-center rounded-full bg-[var(--surface-3)]'>
<CircleAlert className='size-[18px] text-[var(--text-icon)]' aria-hidden />
</div>
<div className='space-y-1'>
<h1 className='font-medium text-[var(--text-primary)] text-lg'>
Could not load your workspaces
</h1>
<p className='text-[var(--text-muted)] text-sm'>
Something went wrong while loading your account. Try again, or sign out and log back
in.
</p>
</div>
<div className='flex items-center gap-2'>
<Chip variant='primary' onClick={() => window.location.reload()}>
Try again
</Chip>
<Chip onClick={() => void recoverFromStaleSession()}>Sign out</Chip>
</div>
</div>
</main>
)
}
return null
return (
<div className='flex h-screen w-full items-center justify-center'>
<div
className='size-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
)
}
async function handleWorkflowRedirect(