From ee0c609a2c110dd55919881f2f593530de900821 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 15:33:02 -0700 Subject: [PATCH] fix(navigation): land on workspaces from the app entry, not settings The signed-in front door sent organization members whose organization has not been rolled out to workspace settings, so opening the app dropped them on the General settings form instead of their workspace. Send them to the workspace picker, which is where the entry pointed before the organization surface existed. The default landing never opens settings; the /o guards still fall back to settings for viewers who explicitly asked for the organization surface. Also stop the entry from bouncing a stale session cookie to /login. The proxy treats /home as an app surface and redirects cookie-less requests to /login before the route renders, and auth-disabled deployments always resolve an anonymous session, so a null session here always means a present-but-invalid cookie. Redirecting that to /login was bounced straight back by the proxy's presence-only cookie check, looping until the browser gave up and leaving the viewer unable to reach the login page at all. Hand off to the workspace loader instead, the one identity-recovery surface, which clears the stale cookies before navigating. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CGkraEeFNiPCU4jb784FE4 --- apps/sim/app/home/page.test.tsx | 10 ++++++++-- apps/sim/app/home/page.tsx | 15 ++++++++++++++- .../navigation/organization-rollout.test.ts | 2 +- .../lib/navigation/resolve-app-entry.test.ts | 6 ++---- apps/sim/lib/navigation/resolve-app-entry.ts | 19 +++++++++---------- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx index 90760f97c44..329be1004ea 100644 --- a/apps/sim/app/home/page.test.tsx +++ b/apps/sim/app/home/page.test.tsx @@ -28,10 +28,16 @@ describe('AppEntryPage', () => { vi.clearAllMocks() }) - it('sends a signed-out visitor to login without resolving an entry', async () => { + /** + * The proxy sends cookie-less requests to /login before this route renders, so a + * null session here is always a stale cookie. Redirecting to /login would be + * bounced back by the proxy's presence-only cookie check, looping forever. + */ + it('sends a stale-cookie viewer to the recovery surface, never back to login', async () => { mockGetSession.mockResolvedValue(null) - await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login') + await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/workspace') + expect(mockRedirect).not.toHaveBeenCalledWith('/login') expect(mockResolveAppEntryPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx index 1965f6894c2..420b2b99877 100644 --- a/apps/sim/app/home/page.tsx +++ b/apps/sim/app/home/page.tsx @@ -1,5 +1,6 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' /** @@ -10,8 +11,20 @@ import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' */ export default async function AppEntryPage() { const session = await getSession() + + /** + * A missing session here is never a signed-out visitor: the proxy treats `/home` + * as an app surface and sends cookie-less requests to `/login` before this + * renders, and auth-disabled deployments always resolve an anonymous session. So + * this branch means the cookie is present but its session is gone — and + * redirecting to `/login` would be bounced straight back by the proxy, which + * reads cookie presence rather than validity, looping until the browser gives up. + * Hand off to the workspace loader instead: it is the app's one identity-recovery + * surface, and it clears the stale cookies through `recoverFromStaleSession` + * before navigating to `/login`. + */ if (!session?.user) { - redirect('/login') + redirect(WORKSPACES_PATH) } redirect(await resolveAppEntryPath(session)) diff --git a/apps/sim/lib/navigation/organization-rollout.test.ts b/apps/sim/lib/navigation/organization-rollout.test.ts index 961c9033e01..dee375993b4 100644 --- a/apps/sim/lib/navigation/organization-rollout.test.ts +++ b/apps/sim/lib/navigation/organization-rollout.test.ts @@ -66,7 +66,7 @@ describe('organization rollout during impersonation', () => { session: { impersonatedBy: 'platform-admin', activeOrganizationId: 'customer-org' }, } await expect(resolveAppEntryPath(impersonatedSession)).resolves.toBe( - knowledge && groups ? '/o/customer-org/home' : '/workspace?redirect=settings' + knowledge && groups ? '/o/customer-org/home' : '/workspace' ) expect(mocks.landing).toHaveBeenLastCalledWith('customer-member', 'customer-org') expect(mocks.platformAdmin).not.toHaveBeenCalled() diff --git a/apps/sim/lib/navigation/resolve-app-entry.test.ts b/apps/sim/lib/navigation/resolve-app-entry.test.ts index c34fd1f27f6..2985484df5a 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.test.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.test.ts @@ -37,12 +37,10 @@ describe('resolveAppEntryPath', () => { expect(mockSearchAvailable).toHaveBeenCalledWith({ organizationId: 'org-2' }) }) - it('opens full workspace settings when Search is disabled', async () => { + it('lands an organization member on the workspace picker when Search is disabled', async () => { mockResolveOrganizationLanding.mockResolvedValue('org-2') mockSearchAvailable.mockResolvedValue(false) - await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe( - '/workspace?redirect=settings' - ) + await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe('/workspace') }) it('lands a viewer with no organization on the workspace picker', async () => { diff --git a/apps/sim/lib/navigation/resolve-app-entry.ts b/apps/sim/lib/navigation/resolve-app-entry.ts index afa62a109ca..06c9ddd0dea 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.ts @@ -1,10 +1,6 @@ import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' -import { - organizationRoutes, - WORKSPACE_SETTINGS_PATH, - WORKSPACES_PATH, -} from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACES_PATH } from '@/lib/navigation/paths' import { resolveOrganizationLanding } from '@/lib/organizations/surface' interface EntrySession { @@ -12,8 +8,12 @@ interface EntrySession { } /** - * Routes organization members to Home when Search is enabled and workspace settings otherwise. - * Viewers without an organization land on the workspace picker. + * Routes organization members to Home when the organization surface is enabled for + * them. Everyone else — viewers without an organization, and members whose + * organization has not been rolled out — lands on the workspace picker, which is + * where the signed-in app's front door pointed before the organization surface + * existed. The default landing never opens settings: a viewer who did not ask for + * settings must not be dropped into them. */ export async function resolveAppEntryPath(session: EntrySession): Promise { const organizationId = await resolveOrganizationLanding( @@ -21,8 +21,7 @@ export async function resolveAppEntryPath(session: EntrySession): Promise