mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+3
-1
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user