Skip to content

Commit 2ca9814

Browse files
committed
fix(workspace): recover from stale sessions instead of blank loader after impersonation
1 parent 64280c9 commit 2ca9814

3 files changed

Lines changed: 89 additions & 21 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react'
44
import { Banner } from '@sim/emcn'
55
import { useSession } from '@/lib/auth/auth-client'
66
import { useStopImpersonating } from '@/hooks/queries/admin-users'
7+
import { clearUserData } from '@/stores'
78

89
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
910
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
@@ -35,8 +36,9 @@ export function ImpersonationBanner() {
3536
onError: () => {
3637
setIsRedirecting(false)
3738
},
38-
onSuccess: () => {
39+
onSuccess: async () => {
3940
setIsRedirecting(true)
41+
await clearUserData()
4042
window.location.assign('/workspace')
4143
},
4244
})

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '@/hooks/queries/admin-users'
2222
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
2323
import { useImportWorkflow } from '@/hooks/queries/workflows'
24+
import { clearUserData } from '@/stores'
2425

2526
const PAGE_SIZE = 20 as const
2627

@@ -109,7 +110,8 @@ export function Admin() {
109110
onError: () => {
110111
setImpersonatingUserId(null)
111112
},
112-
onSuccess: () => {
113+
onSuccess: async () => {
114+
await clearUserData()
113115
window.location.assign('/workspace')
114116
},
115117
}

apps/sim/app/workspace/page.tsx

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,73 @@
11
'use client'
22

33
import { useEffect, useRef } from 'react'
4+
import { Chip } from '@sim/emcn'
5+
import { CircleAlert } from '@sim/emcn/icons'
46
import { createLogger } from '@sim/logger'
57
import { useRouter } from 'next/navigation'
8+
import { isApiClientError } from '@/lib/api/client/errors'
69
import { requestJson } from '@/lib/api/client/request'
710
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
811
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
9-
import { useSession } from '@/lib/auth/auth-client'
12+
import { signOut, useSession } from '@/lib/auth/auth-client'
1013
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
1114
import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
15+
import { clearUserData } from '@/stores'
1216

1317
const logger = createLogger('WorkspacePage')
1418

19+
/**
20+
* A 401 while the session claims we're authenticated means the auth cookies
21+
* are stale or inconsistent (e.g. after an impersonation session expired or
22+
* was switched). The only reliable recovery is a full sign-out, which clears
23+
* every auth cookie server-side — matching what "clear browser cache" did
24+
* manually — followed by a clean login.
25+
*/
26+
function isStaleSessionError(error: unknown): boolean {
27+
return isApiClientError(error) && error.status === 401
28+
}
29+
30+
async function recoverFromStaleSession(): Promise<void> {
31+
try {
32+
await Promise.all([signOut(), clearUserData()])
33+
} catch (error) {
34+
logger.error('Failed to sign out while recovering from a stale session:', error)
35+
}
36+
window.location.assign('/login')
37+
}
38+
1539
export default function WorkspacePage() {
1640
const router = useRouter()
17-
const { data: session, isPending: isSessionPending } = useSession()
41+
const { data: session, isPending: isSessionPending, error: sessionError } = useSession()
1842
const isAuthenticated = !isSessionPending && !!session?.user
1943
const hasRedirectedRef = useRef(false)
44+
const isRecoveringRef = useRef(false)
45+
46+
const {
47+
data,
48+
isLoading: isWorkspacesLoading,
49+
error: workspacesError,
50+
} = useWorkspacesWithMetadata(isAuthenticated)
2051

21-
const { data, isLoading: isWorkspacesLoading } = useWorkspacesWithMetadata(isAuthenticated)
52+
useEffect(() => {
53+
if (!isStaleSessionError(workspacesError) || isRecoveringRef.current) return
54+
isRecoveringRef.current = true
55+
logger.warn('Session cookies are stale (authenticated session but 401 API); signing out')
56+
void recoverFromStaleSession()
57+
}, [workspacesError])
2258

2359
useEffect(() => {
2460
if (isSessionPending || hasRedirectedRef.current) return
2561

62+
if (sessionError) return
63+
2664
if (!session?.user) {
2765
logger.info('User not authenticated, redirecting to login')
2866
router.replace('/login')
2967
return
3068
}
3169

32-
if (isWorkspacesLoading || !data) return
70+
if (isWorkspacesLoading || workspacesError || !data) return
3371

3472
hasRedirectedRef.current = true
3573

@@ -57,26 +95,52 @@ export default function WorkspacePage() {
5795

5896
logger.info(`Redirecting to workspace: ${targetWorkspace.id}`)
5997
router.replace(`/workspace/${targetWorkspace.id}/home`)
60-
}, [session, isSessionPending, isWorkspacesLoading, data, router])
98+
}, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router])
99+
100+
const failedToLoad =
101+
Boolean(sessionError) || (Boolean(workspacesError) && !isStaleSessionError(workspacesError))
61102

62-
if (isSessionPending || isWorkspacesLoading) {
103+
if (failedToLoad) {
63104
return (
64-
<div className='flex h-screen w-full items-center justify-center'>
65-
<div
66-
className='size-[18px] animate-spin rounded-full'
67-
style={{
68-
background:
69-
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
70-
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
71-
WebkitMask:
72-
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
73-
}}
74-
/>
75-
</div>
105+
<main className='flex h-screen w-full items-center justify-center bg-[var(--surface-1)] p-6'>
106+
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
107+
<div className='flex size-10 items-center justify-center rounded-full bg-[var(--surface-3)]'>
108+
<CircleAlert className='size-[18px] text-[var(--text-icon)]' aria-hidden />
109+
</div>
110+
<div className='space-y-1'>
111+
<h1 className='font-medium text-[var(--text-primary)] text-lg'>
112+
Could not load your workspaces
113+
</h1>
114+
<p className='text-[var(--text-muted)] text-sm'>
115+
Something went wrong while loading your account. Try again, or sign out and log back
116+
in.
117+
</p>
118+
</div>
119+
<div className='flex items-center gap-2'>
120+
<Chip variant='primary' onClick={() => window.location.reload()}>
121+
Try again
122+
</Chip>
123+
<Chip onClick={() => void recoverFromStaleSession()}>Sign out</Chip>
124+
</div>
125+
</div>
126+
</main>
76127
)
77128
}
78129

79-
return null
130+
return (
131+
<div className='flex h-screen w-full items-center justify-center'>
132+
<div
133+
className='size-[18px] animate-spin rounded-full'
134+
style={{
135+
background:
136+
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
137+
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
138+
WebkitMask:
139+
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
140+
}}
141+
/>
142+
</div>
143+
)
80144
}
81145

82146
async function handleWorkflowRedirect(

0 commit comments

Comments
 (0)