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
This commit is contained in:
AlexRV12
2026-10-27 23:49:18 +01:00
committed by GitHub
parent 8121dc2b1c
commit 0aec3e2e6a
5 changed files with 23 additions and 22 deletions
+14 -7
View File
@@ -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())
},
@@ -1902,15 +1902,9 @@ addEventListener('message', function (e) {
function previewCtx() {
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
@@ -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
@@ -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
@@ -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