From 0aec3e2e6ad0c79b06238dd05c3920366994e5af Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:49:18 +0100 Subject: [PATCH] fix: treat the acting user as unknown during a workspace switch (#12049) * fix: treat the acting user as unknown during a workspace switch * docs: align acting-user comments with the workspace switch check --- frontend/src/lib/actingUser.svelte.ts | 21 ++++++++++++------- .../components/raw_apps/RawAppEditor.svelte | 12 +++-------- .../components/resources/ResourcesList.svelte | 4 ++-- .../components/schedules/SchedulesList.svelte | 4 ++-- .../components/variables/VariablesList.svelte | 4 ++-- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/actingUser.svelte.ts b/frontend/src/lib/actingUser.svelte.ts index 30da65ff83..71e8cbdcea 100644 --- a/frontend/src/lib/actingUser.svelte.ts +++ b/frontend/src/lib/actingUser.svelte.ts @@ -8,10 +8,10 @@ import { getWorkspaceRole, type RoleLookup } from '$lib/user' * The user acting in a workspace that is not necessarily the one the top nav points at — an AI * session or a workspace-specific variant acts on a workspace the nav deliberately is not on. * - * `$userStore` answers for the navigation workspace at no cost, exactly as every permission check - * in the app did before this hook existed — including when it holds nobody, which reads as unknown - * and refuses. Every other workspace is looked up, and an unresolved user there is `undefined`: it - * must never fall back to the navigation user, whose rights belong to another workspace. + * `$userStore` answers for the navigation workspace at no cost once it was fetched for it — + * including when it holds nobody, which reads as unknown and refuses. Every other workspace is + * looked up, and an unresolved user there is `undefined`: it must never fall back to the + * navigation user, whose rights belong to another workspace. * `canWrite`/`isOwner` refuse for an unknown user, which is the only safe answer. A caller that * must not render that refusal as a denial asks `resolved` first. */ @@ -43,9 +43,13 @@ export function useActingUser(workspace: () => string | undefined) { }) }) + // Right after a switch, `$userStore` still holds the previous workspace's membership until + // the new `whoami` lands: that is unknown, not an answer. + const navStale = () => !!navUser.current && navUser.current.workspace_id !== navWorkspace.current + function userIn(ws: string | undefined): UserExt | undefined { if (!ws) return undefined - if (ws === navWorkspace.current) return navUser.current + if (ws === navWorkspace.current) return navStale() ? undefined : navUser.current const lookup = looked.get(ws) return lookup?.kind === 'resolved' ? lookup.user : undefined } @@ -55,9 +59,12 @@ export function useActingUser(workspace: () => string | undefined) { * hook has been pointed at are looked up; the rest read as unknown. */ in: userIn, /** Whether `ws` has an answer at all — a user, or a lookup that came back without one. - * The navigation workspace always has one: `$userStore`, "nobody" included. */ + * The navigation workspace has one once `$userStore` was fetched for it, "nobody" + * included. */ resolved: (ws: string | undefined): boolean => - !!ws && (ws === navWorkspace.current || looked.has(ws)), + !!ws && (ws === navWorkspace.current ? !navStale() : looked.has(ws)), + /** Unknown right after a workspace switch: load lists without waiting for it, as it only + * gates their controls, or they keep the previous workspace's rows. */ get current(): UserExt | undefined { return userIn(workspace()) }, diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 55b211ecdd..29c5b3147c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1902,15 +1902,9 @@ addEventListener('message', function (e) { return $state.snapshot({ ctx: operatingUser.current, workspace: opWorkspace, path }) } - // A build fed before the viewer is known keeps that viewer until the next one. Outside - // the navigation workspace the viewer is looked up; in it, `$userStore` still holds the - // previous workspace's membership right after a switch. A failed lookup also settles - // this: the app then runs with no viewer, as we don't know who it is. - const previewUserPending = $derived( - !!opWorkspace && - (!operatingUser.resolved(opWorkspace) || - (!!operatingUser.current && operatingUser.current.workspace_id !== opWorkspace)) - ) + // A build fed before the viewer is known keeps that viewer until the next one. A failed + // lookup also settles this: the app then runs with no viewer, as we don't know who it is. + const previewUserPending = $derived(!!opWorkspace && !operatingUser.resolved(opWorkspace)) $effect(() => { if (previewUserPending) return untrack(() => { diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte index 90323c4ca2..6500715087 100644 --- a/frontend/src/lib/components/resources/ResourcesList.svelte +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -203,8 +203,8 @@ ) let filters = useUrlSyncedFilterInstance(untrack(() => resourcesFilterSchema)) // A link can carry the user folders filter to someone it is not offered to, where it would sit - // set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for - // the navigation workspace even while its user is still loading. + // set with no chip to clear it. Dropped only once a user is actually known: `resolved` also + // holds for a lookup that came back with nobody. $effect(() => { if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return if (userFoldersFilterType !== undefined) return diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte index 32101d09d5..b0aeaab38e 100644 --- a/frontend/src/lib/components/schedules/SchedulesList.svelte +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -280,8 +280,8 @@ ) let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema)) // A link can carry the user folders filter to someone it is not offered to, where it would sit - // set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for - // the navigation workspace even while its user is still loading. + // set with no chip to clear it. Dropped only once a user is actually known: `resolved` also + // holds for a lookup that came back with nobody. $effect(() => { if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return if (userFoldersFilterType !== undefined) return diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte index 565be7a04a..a311767bdc 100644 --- a/frontend/src/lib/components/variables/VariablesList.svelte +++ b/frontend/src/lib/components/variables/VariablesList.svelte @@ -106,8 +106,8 @@ ) let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema)) // A link can carry the user folders filter to someone it is not offered to, where it would sit - // set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for - // the navigation workspace even while its user is still loading. + // set with no chip to clear it. Dropped only once a user is actually known: `resolved` also + // holds for a lookup that came back with nobody. $effect(() => { if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return if (userFoldersFilterType !== undefined) return