From 2e5f6d0e76301d19d2017239f5ad4fe25d67dc57 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 25 Sep 2026 09:56:45 -0400 Subject: [PATCH] fix(frontend): keep the session when the persisted workspace is stale (#11344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): keep the session when the persisted workspace is stale A single-use login link signs a different account in while `workspace` in session/localStorage still names the previous account's workspace. `loadUser` read that workspace, got no membership back, threw `Not logged in` and logged the brand-new session out, landing on `/user/login?rd=...`. A missing membership says nothing about the session, so forget the workspace and continue down the no-workspace path, which logs out only when `globalWhoami` shows the session itself is gone. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(frontend): confirm the session before the workspace-picker redirect `loadWithoutWorkspace` fired the `/user/workspaces?rd=…` navigation before awaiting `globalWhoami`, so when the session turned out to be gone the logout read whichever URL the race had left in `page.url` and carried the picker as its `rd`. Ask first, then redirect. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(frontend): stop the loadUser comments overclaiming what they know Neither comment can promise what it stated: `getUserExt` collapses every failure into `undefined`, so the branch cannot tell a real non-membership from a transient one, and `loadWithoutWorkspace` throws on any `globalWhoami` rejection rather than only on a dead session. Say what each call actually answers about. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/routes/(root)/+layout.svelte | 67 ++++++++++++++--------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 369582e7a2..27a6ed20ac 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -107,12 +107,7 @@ } } - try { - clearWorkspaceFromStorage() - } catch (e) { - console.error('Could not clear workspace storage during deleted-workspace recovery', e) - } - workspaceStore.set(undefined) + forgetWorkspace() sendUserToast( `Workspace ${workspaceId} is no longer available, please pick a workspace.`, 'warning' @@ -121,6 +116,39 @@ return true } + // Storage is what a reload reads the workspace back from, so dropping only the store + // leaves the same dead id waiting for the next load (see getWorkspaceFromStorage). + function forgetWorkspace() { + try { + clearWorkspaceFromStorage() + } catch (e) { + console.error('Could not clear workspace storage', e) + } + workspaceStore.set(undefined) + } + + // Throws on any `globalWhoami` rejection, which is the caller's cue to log out. The picker + // redirect waits on that answer: navigating first leaves the logout's `rd` pointing at the + // picker rather than at where the user was headed. + async function loadWithoutWorkspace() { + let user = await UserService.globalWhoami() + noteSessionEmail(user.email) + console.log(`Welcome back ${user.email}`) + if ( + (!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) && + // The MCP consent page carries its own workspace picker, so it is left to + // run without one. Nothing sets `$userStore` on this branch, which is why + // that page must stay outside the (logged) layout — see its `@(root)` name. + !page.url.pathname.startsWith(`${base}/oauth/mcp_authorize`) && + // The hub import wizard asks for the destination itself, and may end in a + // workspace that does not exist yet — bouncing it to the picker would + // force the very choice it exists to make. + !page.url.pathname.startsWith(`${base}/projects/import`) + ) { + goto(`/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}`) + } + } + async function loadUser() { try { await refreshSuperadmin() @@ -141,29 +169,18 @@ return } if (!user) { - throw Error('Not logged in') + // The persisted workspace outlives the session that chose it: a login link + // signs a different account in while storage still names a workspace that + // account is not a member of. This lookup answers about the workspace, never + // about the session, so throwing here would log the new session out blind. + forgetWorkspace() + await loadWithoutWorkspace() + return } $userStore = user } } else { - if ( - (!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) && - // The MCP consent page carries its own workspace picker, so it is left to - // run without one. Nothing sets `$userStore` on this branch, which is why - // that page must stay outside the (logged) layout — see its `@(root)` name. - !page.url.pathname.startsWith(`${base}/oauth/mcp_authorize`) && - // The hub import wizard asks for the destination itself, and may end in a - // workspace that does not exist yet — bouncing it to the picker would - // force the very choice it exists to make. - !page.url.pathname.startsWith(`${base}/projects/import`) - ) { - goto( - `/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}` - ) - } - let user = await UserService.globalWhoami() - noteSessionEmail(user.email) - console.log(`Welcome back ${user.email}`) + await loadWithoutWorkspace() } } catch (e) { console.error(e)