|
1 | 1 | 'use client' |
2 | 2 |
|
3 | 3 | import { useEffect, useRef } from 'react' |
| 4 | +import { Chip } from '@sim/emcn' |
| 5 | +import { CircleAlert } from '@sim/emcn/icons' |
4 | 6 | import { createLogger } from '@sim/logger' |
5 | 7 | import { useRouter } from 'next/navigation' |
| 8 | +import { isApiClientError } from '@/lib/api/client/errors' |
6 | 9 | import { requestJson } from '@/lib/api/client/request' |
7 | 10 | import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' |
8 | 11 | import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' |
9 | | -import { useSession } from '@/lib/auth/auth-client' |
| 12 | +import { signOut, useSession } from '@/lib/auth/auth-client' |
10 | 13 | import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' |
11 | 14 | import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace' |
| 15 | +import { clearUserData } from '@/stores' |
12 | 16 |
|
13 | 17 | const logger = createLogger('WorkspacePage') |
14 | 18 |
|
| 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 | + |
15 | 39 | export default function WorkspacePage() { |
16 | 40 | const router = useRouter() |
17 | | - const { data: session, isPending: isSessionPending } = useSession() |
| 41 | + const { data: session, isPending: isSessionPending, error: sessionError } = useSession() |
18 | 42 | const isAuthenticated = !isSessionPending && !!session?.user |
19 | 43 | const hasRedirectedRef = useRef(false) |
| 44 | + const isRecoveringRef = useRef(false) |
| 45 | + |
| 46 | + const { |
| 47 | + data, |
| 48 | + isLoading: isWorkspacesLoading, |
| 49 | + error: workspacesError, |
| 50 | + } = useWorkspacesWithMetadata(isAuthenticated) |
20 | 51 |
|
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]) |
22 | 58 |
|
23 | 59 | useEffect(() => { |
24 | 60 | if (isSessionPending || hasRedirectedRef.current) return |
25 | 61 |
|
| 62 | + if (sessionError) return |
| 63 | + |
26 | 64 | if (!session?.user) { |
27 | 65 | logger.info('User not authenticated, redirecting to login') |
28 | 66 | router.replace('/login') |
29 | 67 | return |
30 | 68 | } |
31 | 69 |
|
32 | | - if (isWorkspacesLoading || !data) return |
| 70 | + if (isWorkspacesLoading || workspacesError || !data) return |
33 | 71 |
|
34 | 72 | hasRedirectedRef.current = true |
35 | 73 |
|
@@ -57,26 +95,52 @@ export default function WorkspacePage() { |
57 | 95 |
|
58 | 96 | logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) |
59 | 97 | 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)) |
61 | 102 |
|
62 | | - if (isSessionPending || isWorkspacesLoading) { |
| 103 | + if (failedToLoad) { |
63 | 104 | 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> |
76 | 127 | ) |
77 | 128 | } |
78 | 129 |
|
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 | + ) |
80 | 144 | } |
81 | 145 |
|
82 | 146 | async function handleWorkflowRedirect( |
|
0 commit comments