From 0d767d00fb340b6684807ec2c6892c648e940e47 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:51:00 +0200 Subject: [PATCH] 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) Claude-Session: https://claude.ai/code/session_01YLxwAsiXJ1Au8CBDBmH7iY --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/actingUser.svelte.ts | 74 ++++++++++ frontend/src/lib/components/Path.svelte | 34 +++-- .../src/lib/components/ResourceEditor.svelte | 128 +++++++++--------- .../components/ResourceEditorDrawer.svelte | 23 ++-- .../src/lib/components/ResourceForm.svelte | 19 ++- .../src/lib/components/VariableEditor.svelte | 54 ++++---- .../src/lib/components/VariableForm.svelte | 17 ++- .../schedules/ScheduleEditorInner.svelte | 29 +++- .../(root)/(logged)/resources/+page.svelte | 1 + .../(root)/(logged)/variables/+page.svelte | 6 +- 10 files changed, 261 insertions(+), 124 deletions(-) create mode 100644 frontend/src/lib/actingUser.svelte.ts diff --git a/frontend/src/lib/actingUser.svelte.ts b/frontend/src/lib/actingUser.svelte.ts new file mode 100644 index 0000000000..30da65ff83 --- /dev/null +++ b/frontend/src/lib/actingUser.svelte.ts @@ -0,0 +1,74 @@ +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() + // 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) + } + } + } +} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 37eef75bf5..b45d980246 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -26,7 +26,7 @@ AzureTriggerService, EmailTriggerService } from '$lib/gen' - import { superadmin, userStore, workspaceStore } from '$lib/stores' + import { superadmin, userStore, workspaceStore, type UserExt } from '$lib/stores' import { createEventDispatcher, getContext, untrack } from 'svelte' import { writable } from 'svelte/store' import { Alert, Button } from './common' @@ -85,6 +85,11 @@ * workspace when the editor operates on a workspace other than the one the * top nav points at (see the sessions preview / dev-workspace flows). */ workspaceOverride?: string + /** The user acting in `workspaceOverride`, for the owner suggestion and the folder + * write flags. Omit it to stand in the navigation `$userStore`, who is a member of + * the navigation workspace only; pass `null` for "not known (yet)", which that user + * must not answer for either. */ + actingUser?: UserExt | null /** One path that does not count as taken, for a caller creating something that may * already have written there itself — a setup flow correcting its own failed attempt. * Every other existing path is still refused. */ @@ -110,11 +115,16 @@ size = 'md', drawerOffset = 0, workspaceOverride = undefined, + actingUser = undefined, allowedExistingPath = undefined, warnOnRename = true }: Props = $props() let ws = $derived(workspaceOverride ?? $workspaceStore) + // Sole place this component falls back to the ambient user, and only for a caller that + // passed none; everything below reads `user`, so a caller acting on another workspace is + // never mixed with the navigation user's memberships. + let user = $derived(actingUser === undefined ? $userStore : (actingUser ?? undefined)) $effect.pre(() => { if (path == undefined) { @@ -169,17 +179,17 @@ export async function reset() { if (path == '' || path == 'u//' || path?.startsWith('tmp/') || path?.startsWith('hub/')) { - if ($lastMetaUsed == undefined || $lastMetaUsed.owner != $userStore?.username) { + if ($lastMetaUsed == undefined || $lastMetaUsed.owner != user?.username) { meta = { ownerKind: hideUser ? 'folder' : 'user', name: fullNamePlaceholder ?? random_adj() + '_' + namePlaceholder, owner: '' } if (!hideUser) { - if ($userStore?.username?.includes('@')) { - meta.owner = $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') + if (user?.username?.includes('@')) { + meta.owner = user!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') } else { - meta.owner = $userStore!.username! + meta.owner = user!.username! } } } else { @@ -229,9 +239,9 @@ .map((x) => ({ name: x, write: - $userStore?.folders?.includes(x) == true || - ($userStore?.is_admin ?? false) || - ($userStore?.is_super_admin ?? false) + user?.folders?.includes(x) == true || + (user?.is_admin ?? false) || + (user?.is_super_admin ?? false) })) ) } @@ -423,7 +433,7 @@ }) }) $effect.pre(() => { - if (ws && $userStore) { + if (ws && user) { untrack(() => { loadFolders() initPath() @@ -506,7 +516,7 @@ } else { // 'group' is unreachable here (Select only offers user/folder) // but validateName still accepts it for forward-compat. - meta.owner = $userStore?.username?.split('@')[0] ?? '' + meta.owner = user?.username?.split('@')[0] ?? '' } } } @@ -520,7 +530,7 @@
{#if meta.ownerKind === 'user'} {@const userOwnerDisabled = - disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing} + disabled || !($superadmin || (user?.is_admin ?? false)) || disableEditing}