Files
windmill/frontend/src/lib/actingUser.svelte.ts
T
Diego ImbertandClaude Opus 5 0d767d00fb refactor: make the acting workspace and user explicit in the entity editors (#11031)
* refactor: make the acting workspace and user explicit in the entity editors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: resolve the acting user in new-item mode and for the navigation workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: discard acting-user lookups that no longer describe the acting workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* refactor: own the acting-user resolution in one composable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: key the acting-user cache by a Map and re-ask after a failed lookup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: re-ask a failed acting-user lookup when an editor opens a new session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: forget a failed acting-user lookup when its workspace stops being the acting one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: drop a stale acting-user refusal on arrival rather than on departure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* fix: let the navigation user answer for the navigation workspace unconditionally

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

* docs: mark prototype-key workspace ids as unsupported by the entity editors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:51:00 +00:00

75 lines
3.4 KiB
TypeScript

import { untrack } from 'svelte'
import { fromStore } from 'svelte/store'
import { SvelteMap } from 'svelte/reactivity'
import { userStore, workspaceStore, type UserExt } from '$lib/stores'
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.
* `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.
*/
export function useActingUser(workspace: () => string | undefined) {
const navWorkspace = fromStore(workspaceStore)
const navUser = fromStore(userStore)
const looked = new SvelteMap<string, RoleLookup>()
// The workspace this effect last acted on, so arriving at one is distinguishable from the
// effect re-running while already there.
let asking: string | undefined
$effect(() => {
const ws = workspace()
if (asking !== ws) {
asking = ws
// Dropped on the way *in*, not on the way out: a lookup that fails after the acting
// workspace has already moved on has no entry to clear at the moment it is left, so
// clearing it there would keep a refusal that no attempt is behind any more.
if (ws && untrack(() => looked.get(ws)?.kind) === 'lookup_failed') looked.delete(ws)
}
if (!ws || ws === navWorkspace.current) return
// Any settled answer stops the asking, a failure included — otherwise recording one
// would re-enter this effect and loop.
if (looked.has(ws)) return
untrack(() => {
// Memoized process-wide, so two components pointed at the same workspace share one
// request rather than each issuing their own.
getWorkspaceRole(ws).then((lookup) => looked.set(ws, lookup))
})
})
function userIn(ws: string | undefined): UserExt | undefined {
if (!ws) return undefined
if (ws === navWorkspace.current) return navUser.current
const lookup = looked.get(ws)
return lookup?.kind === 'resolved' ? lookup.user : undefined
}
return {
/** The acting user in `ws`, or `undefined` when it is not known. Only workspaces this
* 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. */
resolved: (ws: string | undefined): boolean =>
!!ws && (ws === navWorkspace.current || looked.has(ws)),
get current(): UserExt | undefined {
return userIn(workspace())
},
/** Drop the lookups that came back empty so they are asked again. Arriving at a
* workspace already does this; a long-lived editor must call this too when it starts a
* fresh session on the workspace it is already on, or a `whoami` that happened to fail
* pins it to "unknown user" for as long as it stays there. */
forgetFailures(): void {
for (const [ws, lookup] of looked) {
if (lookup.kind === 'lookup_failed') looked.delete(ws)
}
}
}
}