diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx new file mode 100644 index 00000000000..2702660d7ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Chip } from '@sim/emcn' +import { useSession } from '@/lib/auth/auth-client' +import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' + +/** + * Cleanly logs out when an impersonation session expires while the app is + * mounted. + * + * Detection keys off the transition: this only activates when the session + * query settles to `null` after a session that carried `impersonatedBy` (the + * expired session is deleted server-side, so the live session can't be used). + * + * Recovery goes through {@link recoverFromStaleSession}: the expired session's + * cookies are never cleared server-side (better-auth's customSession plugin + * swallows the deletion headers), and the login route bounces any request + * still carrying a session cookie back to /workspace. The helper signs out + * (which clears the cookies without requiring a live session), wipes per-user + * client state, and only navigates when the sign-out succeeded — navigating + * after a failed sign-out would recreate the redirect loop. + */ +export function ImpersonationExpired() { + const { data: session, isPending, error } = useSession() + const startedRef = useRef(false) + const [sawImpersonation, setSawImpersonation] = useState(false) + const [failed, setFailed] = useState(false) + + if (!sawImpersonation && session?.session?.impersonatedBy) { + setSawImpersonation(true) + } + + const expired = sawImpersonation && !isPending && !error && !session?.user + + const attemptRecovery = useCallback(() => { + setFailed(false) + void recoverFromStaleSession().then((recovered) => { + if (!recovered) setFailed(true) + }) + }, []) + + useEffect(() => { + if (!expired || startedRef.current) return + startedRef.current = true + attemptRecovery() + }, [expired, attemptRecovery]) + + if (!expired) { + return null + } + + return ( +
+

+ {failed + ? 'The impersonation session expired, but signing out failed.' + : 'The impersonation session expired. Signing you out…'} +

+ {failed && ( + + Try again + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts index dcfa353780c..1482b93081b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts @@ -1 +1,2 @@ export { ImpersonationBanner } from './impersonation-banner' +export { ImpersonationExpired } from './impersonation-expired' diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 33beae266a0..c2bd9879894 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -59,6 +59,7 @@ vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({ vi.mock('@/app/workspace/[workspaceId]/components/impersonation-banner', () => ({ ImpersonationBanner: () => null, + ImpersonationExpired: () => null, })) vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 645496c18c9..f10df8f2883 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -4,7 +4,10 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' +import { + ImpersonationBanner, + ImpersonationExpired, +} from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { @@ -66,6 +69,7 @@ export default async function WorkspaceLayout({
+ diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 5e277d699e3..6137b205d19 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -9,10 +9,10 @@ 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 { signOut, useSession } from '@/lib/auth/auth-client' +import { useSession } from '@/lib/auth/auth-client' +import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace' -import { clearUserData } from '@/stores' const logger = createLogger('WorkspacePage') @@ -27,24 +27,6 @@ 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 { - 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, error: sessionError } = useSession() @@ -77,8 +59,17 @@ export default function WorkspacePage() { // 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') + // A clean null session can still have stale auth cookies behind it (an + // expired impersonation session's cookies are never cleared server-side), + // and the middleware bounces /login back here while any session cookie + // exists — a bare replace('/login') loops forever on the spinner. Recover + // the same way as the 401 path: sign out (clears the cookies without + // needing a live session), then navigate. + hasRedirectedRef.current = true + logger.info('User not authenticated, signing out stale cookies and redirecting to login') + void recoverFromStaleSession().then((recovered) => { + if (!recovered) setRecoveryFailed(true) + }) return } diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index add45a08854..686a2447c7b 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -1,4 +1,4 @@ -import { type QueryClient, useQuery } from '@tanstack/react-query' +import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' import { type AppSession, @@ -12,8 +12,14 @@ export const sessionKeys = { detail: () => [...sessionKeys.all, 'detail'] as const, } -async function fetchSession(signal?: AbortSignal): Promise { - const res = await client.getSession({ fetchOptions: { signal } }) +async function fetchSession( + signal?: AbortSignal, + disableCookieCache?: boolean +): Promise { + const res = await client.getSession({ + ...(disableCookieCache ? { query: { disableCookieCache: true } } : {}), + fetchOptions: { signal }, + }) return extractSessionDataFromAuthClientResult(res) as AppSession } @@ -35,6 +41,8 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise fetchSession(signal), + queryFn: ({ signal }) => { + const cached = queryClient.getQueryData(sessionKeys.detail()) + return fetchSession(signal, Boolean(cached?.session?.impersonatedBy)) + }, staleTime: SESSION_STALE_TIME, retry: false, + refetchInterval: (query) => + query.state.data?.session?.impersonatedBy ? IMPERSONATION_REFETCH_INTERVAL : false, + refetchOnWindowFocus: (query) => Boolean(query.state.data?.session?.impersonatedBy), }) } diff --git a/apps/sim/lib/auth/stale-session-recovery.ts b/apps/sim/lib/auth/stale-session-recovery.ts new file mode 100644 index 00000000000..a3b9b38f6a7 --- /dev/null +++ b/apps/sim/lib/auth/stale-session-recovery.ts @@ -0,0 +1,29 @@ +'use client' + +import { createLogger } from '@sim/logger' +import { signOut } from '@/lib/auth/auth-client' +import { clearUserData } from '@/stores' + +const logger = createLogger('StaleSessionRecovery') + +/** + * 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. + * + * Shared by the /workspace loader (stale-cookie 401s and clean-null sessions) + * and the impersonation-expired screen, so every identity-recovery path clears + * cookies and persisted client state the same way. + */ +export async function recoverFromStaleSession(): Promise { + 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 +}