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