fix(frontend): keep the session when the persisted workspace is stale (#11344)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-09-25 15:56:45 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e8c3f9514e
commit 2e5f6d0e76
+42 -25
View File
@@ -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)