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
This commit is contained in:
Diego Imbert
2026-09-08 19:29:22 +02:00
co-authored by Claude Opus 5
parent d23da87c16
commit 4ec64d9ed6
9 changed files with 130 additions and 59 deletions
+22 -12
View File
@@ -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. Defaults to the navigation `$userStore`, who is only a member of the
* navigation workspace — a caller pointed anywhere else must pass the user resolved
* for that workspace (`getUserExt`). */
actingUser?: UserExt
/** 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; everything below
// reads `user` so a caller acting on another workspace is never mixed with
// the navigation user's memberships.
let user = $derived(actingUser ?? $userStore)
$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 @@
<div>
{#if meta.ownerKind === 'user'}
{@const userOwnerDisabled =
disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing}
disabled || !($superadmin || (user?.is_admin ?? false)) || disableEditing}
<label class="block shrink min-w-0">
<TextInput
class={twMerge('!border-none', userOwnerDisabled && '!bg-transparent')}
@@ -528,7 +538,7 @@
underlyingInputEl="div"
bind:value={meta.owner}
inputProps={{
placeholder: $userStore?.username ?? '',
placeholder: user?.username ?? '',
onkeydown: setDirty,
disabled: userOwnerDisabled
}}
@@ -9,7 +9,7 @@
} from '$lib/gen'
import { canWrite } from '$lib/utils'
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
import ResourceForm from './ResourceForm.svelte'
@@ -31,6 +31,9 @@
hidePath?: boolean
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
defaultValues?: Record<string, any> | undefined
/** Workspace this editor acts in — every call, permission check and cache key below
* derives from it. Optional for the packaged component; the navigation workspace is
* substituted once, at `effectiveWorkspace`, and nowhere else. */
workspace?: string | undefined
selected?: string | undefined
/** Show the value as JSON rather than as the resource type's form. Bindable so a caller can
@@ -70,6 +73,8 @@
const dispatch = createEventDispatcher()
// Sole ambient read in this file: the acting workspace is an input, and only its
// default comes from the navigation store.
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
// Fallback to `effectiveWorkspace` insulates against reactify-style
// parents that re-spread props without `selected` — otherwise it
@@ -86,6 +91,10 @@
let initialStates: Record<string, ResourceState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let fetchedResources: Record<string, Resource> = $state({})
// The user acting in each loaded workspace, fetched alongside the resource. `undefined`
// stands for "we don't know" — a lookup still in flight or one that failed — and
// `canWrite` refuses for an unknown user, which is the only safe answer: the navigation
// user's rights are another workspace's.
let perWsUser: Record<string, UserExt | undefined> = $state({})
const handlesArray = UserDraft.useMany<ResourceState>(() =>
@@ -221,7 +230,7 @@
() => deployedPath,
() => deployedUrl,
() => resource_type,
() => (selected ? (perWsUser[selected] ?? $userStore)?.is_admin : undefined)
() => (selected ? perWsUser[selected]?.is_admin : undefined)
],
async ([ws, path, _url, type, admin]) =>
ws && path && type === 'git_repository' && admin
@@ -239,14 +248,13 @@
selected ? fetchedResources[selected] : undefined
)
let can_write = $derived.by(() => {
if (!selected) return true
// A resource that does not exist yet has nobody's permissions on it. In edit mode the
// resource and the acting user land together, so a missing one is also a missing
// other, and neither may read as writable.
if (!initialPath || !selected) return true
const r = fetchedResources[selected]
if (!r) return true
return canWrite(
current?.path ?? initialPath,
r.extra_perms ?? {},
perWsUser[selected] ?? $userStore
)
if (!r) return false
return canWrite(current?.path ?? initialPath, r.extra_perms ?? {}, perWsUser[selected])
})
const dirtyWorkspaces = $derived(
@@ -275,7 +283,7 @@
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
? dirtyWorkspaces.filter((ws) => ws !== effectiveWorkspace)
: dirtyWorkspaces
)
const dirtyValid = $derived(dirtyWorkspaces.every((ws) => perWsValid[ws] !== false))
@@ -283,12 +291,7 @@
dirtyWorkspaces.every((ws) => {
const r = fetchedResources[ws]
return (
!r ||
canWrite(
states[ws]?.draft?.path ?? initialPath,
r.extra_perms ?? {},
perWsUser[ws] ?? $userStore
)
!r || canWrite(states[ws]?.draft?.path ?? initialPath, r.extra_perms ?? {}, perWsUser[ws])
)
})
)
@@ -577,6 +580,7 @@
{resourceToEdit}
onLoadResourceType={() => resourceTypeResource.refetch()}
workspace={selected}
actingUser={selected ? perWsUser[selected] : undefined}
/>
{/key}
{/if}
@@ -5,8 +5,10 @@
import { History, Loader2, Save } from 'lucide-svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { isOwner } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { resource } from 'runed'
import LocalDraftBanner from './LocalDraftBanner.svelte'
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
@@ -25,6 +27,8 @@
onRestored = undefined,
onSaved = undefined
}: {
/** Workspace this drawer acts in. Optional; the navigation workspace is substituted
* once, at `effectiveWorkspace`, and nowhere else in this file. */
workspace?: string
disableChatOffset?: boolean
onRestored?: () => void
@@ -58,13 +62,16 @@
// The editor renders whichever workspace-specific variant `selected` points at, so history has
// to follow it too — otherwise a restore would write over the variant the user is not looking at.
let historyWorkspace = $derived(selected ?? effectiveWorkspace)
// Clearing is irreversible and the backend gates it on ownership, not write access. $userStore
// describes the user in the workspace they are signed into, so it can only answer for that one:
// history pointed anywhere else — a ws-specific variant, or an explicit `workspace` prop — gets
// no Clear button rather than a verdict computed from the wrong membership.
let canClearSelected = $derived(
historyWorkspace === $workspaceStore && isOwner(path ?? '', $userStore, $workspaceStore)
// Gated on `path`: this drawer outlives every resource it opens, so an ungated lookup
// would fire on every page that mounts it, for nothing.
const historyUser = resource(
() => (path ? historyWorkspace : undefined),
async (ws) => (ws ? await getUserExt(ws) : undefined)
)
// Clearing is irreversible and the backend gates it on ownership, not write access, so the
// verdict has to come from the membership `historyWorkspace` knows about. An unresolved
// user gets no Clear button rather than one computed from another workspace's rights.
let canClearSelected = $derived(isOwner(path ?? '', historyUser.current, historyWorkspace))
// A close reaches `on:close` on a later flush, by which point a caller that closed this drawer to
// open another editor has already anchored the new one. Clearing then would strip that anchor.
@@ -147,7 +154,7 @@
{path}
{resource_type}
{defaultValues}
{workspace}
workspace={effectiveWorkspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
@@ -8,7 +8,7 @@
import Path from './Path.svelte'
import LabelsInput from './LabelsInput.svelte'
import Required from './Required.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore, type UserExt } from '$lib/stores'
import SchemaForm from './SchemaForm.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import FilesetEditor from './FilesetEditor.svelte'
@@ -48,6 +48,10 @@
/** Workspace the path is validated against and the connection is tested in;
* defaults to the nav workspace. */
workspace?: string | undefined
/** The user acting in `workspace`, resolved by the editor above. `undefined` while
* that lookup is pending or after it failed: every check below then refuses, rather
* than answering with the navigation user's rights in another workspace. */
actingUser: UserExt | undefined
/** Fired once the GitLab picker has stored the picked project's token, so a
* form that would otherwise file the URL as a secret knows it holds none. */
onCredentialStored?: () => void
@@ -73,6 +77,7 @@
resourceToEdit,
onLoadResourceType,
workspace = undefined,
actingUser,
onCredentialStored
}: Props = $props()
@@ -163,12 +168,13 @@
<Label label="Path">
<ResourcePathHint />
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
disabled={initialPath != '' && !isOwner(initialPath, actingUser, ws)}
bind:path
{initialPath}
namePlaceholder="resource"
kind="resource"
workspaceOverride={workspace}
{actingUser}
/>
</Label>
</div>
@@ -246,7 +252,7 @@
workspaceOverride={workspace}
/>
{/if}
{#if resource_type === 'git_repository' && $workspaceStore && ($userStore?.is_admin || $userStore?.is_super_admin)}
{#if resource_type === 'git_repository' && ws && (actingUser?.is_admin || actingUser?.is_super_admin)}
<GitHubAppIntegration
resourceType={resource_type}
{args}
@@ -1,7 +1,7 @@
<script lang="ts">
import { VariableService, WorkspaceService } from '$lib/gen'
import { createEventDispatcher, untrack } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -38,8 +38,10 @@
// The "current" workspace this editor defaults New/Edit actions to. Session
// editors pass their acting workspace so secrets are created/updated there
// rather than in the navigation workspace. Defaults to $workspaceStore.
// rather than in the navigation workspace.
let { workspace = undefined }: { workspace?: string } = $props()
// Sole ambient read in this file: the acting workspace is an input, and only its
// default comes from the navigation store.
let curWs = $derived(workspace ?? $workspaceStore)
let editPath: string | undefined = $state(undefined)
@@ -53,6 +55,10 @@
let initialStates: Record<string, VariableState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let extraPerms: Record<string, Record<string, boolean>> = $state({})
// The user acting in each loaded workspace, fetched alongside the variable. `undefined`
// stands for "we don't know" — a lookup still in flight or one that failed — and
// `canWrite` refuses for an unknown user, which is the only safe answer: the navigation
// user's rights are another workspace's.
let perWsUser: Record<string, UserExt | undefined> = $state({})
let selected: string | undefined = $state(undefined)
let pathError = $state('')
@@ -106,11 +112,14 @@
pageDrawerSessionSource(VARIABLES_PATH, editPath, selected ?? curWs)
)
const current = $derived(selected ? states[selected]?.draft : undefined)
const can_write = $derived.by(() => {
// `undefined` until the selected workspace's permissions and acting user have both
// landed — a pending verdict is neither a grant nor the denial the read-only alert
// announces, so the two must stay distinguishable.
const can_write: boolean | undefined = $derived.by(() => {
if (!selected || !edit) return true
const perms = extraPerms[selected]
if (!perms) return true
return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore)
if (!perms) return undefined
return canWrite(editPath ?? '', perms, perWsUser[selected])
})
const dirtyWorkspaces = $derived(
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
@@ -154,7 +163,7 @@
const dirtyCanWrite = $derived(
dirtyWorkspaces.every((ws) => {
const perms = extraPerms[ws]
return !perms || canWrite(editPath ?? '', perms, perWsUser[ws] ?? $userStore)
return !perms || canWrite(editPath ?? '', perms, perWsUser[ws])
})
)
@@ -329,7 +338,7 @@
/>
{/snippet}
<div class="flex flex-col gap-8 pb-2">
{#if !can_write}
{#if can_write === false}
<Alert type="warning" title="Only read access">
You only have read access to this resource and cannot edit it
</Alert>
@@ -352,10 +361,11 @@
bind:wsSpecific={current.wsSpecific}
{initialPath}
deployTo={deployTo.current}
{can_write}
can_write={can_write === true}
{edit}
onLoadSecret={loadSecret}
{workspace}
actingUser={selected ? perWsUser[selected] : undefined}
/>
{/key}
{/if}
@@ -10,7 +10,7 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Loader2, RotateCcw } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore, type UserExt } from '$lib/stores'
import { isOwner } from '$lib/utils'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import EncryptedDraftField from './EncryptedDraftField.svelte'
@@ -34,6 +34,10 @@
onLoadSecret?: () => void
/** Workspace the path is validated against; defaults to the nav workspace. */
workspace?: string | undefined
/** The user acting in `workspace`, resolved by the editor above. `undefined` while
* that lookup is pending or after it failed: every check below then refuses, rather
* than answering with the navigation user's rights in another workspace. */
actingUser: UserExt | undefined
}
let {
@@ -47,7 +51,8 @@
can_write,
edit,
onLoadSecret,
workspace = undefined
workspace = undefined,
actingUser
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
@@ -71,13 +76,14 @@
<div class="flex flex-col gap-1">
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
disabled={initialPath != '' && !isOwner(initialPath, actingUser, ws)}
bind:error={pathError}
bind:path
{initialPath}
namePlaceholder="variable"
kind="variable"
workspaceOverride={workspace}
{actingUser}
/>
<LabelsInput bind:labels />
</div>
@@ -89,7 +95,7 @@
<Toggle
on:change={() => edit && !hasStagedValue && onLoadSecret?.()}
bind:checked={variable.is_secret}
disabled={edit && ($userStore?.operator || isEncryptedDraftValue(variable.value))}
disabled={edit && (actingUser?.operator || isEncryptedDraftValue(variable.value))}
/>
{#if variable.is_secret}
<Alert type="info" title="Audit log for each access">
@@ -131,7 +137,7 @@
>
Reset
</Button>
{:else if $userStore?.operator}
{:else if actingUser?.operator}
<div class="p-2 border">Operators cannot load secret value</div>
{:else}
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
@@ -29,7 +29,7 @@
type Schedule,
type ErrorHandler
} from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore, type UserExt } from '$lib/stores'
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
import { base } from '$lib/base'
import Section from '$lib/components/Section.svelte'
@@ -50,6 +50,8 @@
import { twMerge } from 'tailwind-merge'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
import { getUserExt } from '$lib/user'
import { resource } from 'runed'
let {
useDrawer = true,
@@ -114,7 +116,10 @@
let showLoading = $state(false)
let initialConfig: Record<string, any> | undefined = undefined
let extraPerms: Record<string, boolean> = $state({})
let can_write = $state(true)
// Path the permissions above were loaded for — the verdict is about the schedule as
// stored, not about a rename being typed into the form. `undefined` until a config has
// been loaded, when there is no deployed schedule to deny access to.
let permsPath: string | undefined = $state(undefined)
let initNewPath = $state(false)
let path: string = $state('')
let enabled: boolean = $state(false)
@@ -132,6 +137,25 @@
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
// `$userStore` is loaded for the navigation workspace and only answers for that one, so
// an acting workspace anywhere else has to be asked who is acting in it.
const otherWsUser = resource(
() => (wsId && wsId !== $workspaceStore ? wsId : undefined),
async (ws) => (ws ? await getUserExt(ws) : undefined)
)
// `undefined` while that lookup is in flight or after it failed; the checks below then
// refuse rather than fall back to rights that belong to another workspace.
const actingUser: UserExt | undefined = $derived(
wsId === $workspaceStore ? $userStore : otherWsUser.current
)
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, extraPerms, actingUser)
)
// Editing the runnable is closed to operators, and an unresolved acting user is no
// evidence that this one isn't.
const canEditRunnable = $derived(actingUser !== undefined && !actingUser.operator)
const saveDisabled = $derived(
!allowSchedule ||
pathError != '' ||
@@ -141,8 +165,6 @@
emptyString(errorHandlerExtraArgs['channel'])) ||
!can_write
)
const triggerWs = getTriggerWorkspace()
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
// Carry the acting workspace onto "create from template" routes when a
// session override is set, so the script is created in the session workspace.
const wsParam = $derived(triggerWs?.() ? `&workspace=${encodeURIComponent(wsId!)}` : '')
@@ -590,7 +612,7 @@
dynamicSkipPath = cfg.dynamic_skip
args = cfg.args ?? {}
extraPerms = cfg.extra_perms ?? {}
can_write = canWrite(cfg.path, cfg.extra_perms, $userStore)
permsPath = cfg.path
tag = cfg.tag
permissionedAs = cfg.permissioned_as
selectedPermissionedAs = cfg.permissioned_as
@@ -837,6 +859,7 @@
namePlaceholder="schedule"
kind="schedule"
disableEditing={!can_write}
{actingUser}
/>
{:else}
<div class="flex justify-start w-full">
@@ -972,7 +995,7 @@
allowFlow={true}
{itemKind}
allowView={script_path != '' && !!runnable}
allowEdit={script_path != '' && !!runnable && !$userStore?.operator}
allowEdit={script_path != '' && !!runnable && canEditRunnable}
/>
{/if}
{#if itemKind == 'flow'}
@@ -1546,6 +1546,7 @@
<ResourceEditorDrawer
bind:this={resourceEditor}
workspace={$workspaceStore}
on:refresh={loadResources}
onRestored={loadResources}
/>
@@ -316,7 +316,11 @@
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
<VariableEditor
bind:this={variableEditor}
workspace={$workspaceStore}
on:create={loadVariables}
/>
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}