mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b50de89479
commit
0d767d00fb
@@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
<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'
|
||||
@@ -17,8 +17,7 @@
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
@@ -31,6 +30,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 +72,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
|
||||
@@ -83,10 +87,13 @@
|
||||
// releasing them on component teardown. `states` indexes the resulting
|
||||
// handles by workspace ID for ergonomic lookup downstream.
|
||||
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: ResourceState }>>([])
|
||||
// Plain objects keyed by workspace id, so an id that is also an `Object.prototype` key
|
||||
// (`constructor`, …) reads as already present and the resource never loads. Such ids are
|
||||
// deliberately unsupported: too unlikely to be worth guarding every read.
|
||||
let initialStates: Record<string, ResourceState> = $state({})
|
||||
let existedInitially: Record<string, boolean> = $state({})
|
||||
let fetchedResources: Record<string, Resource> = $state({})
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
const acting = useActingUser(() => selected)
|
||||
|
||||
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -221,7 +228,7 @@
|
||||
() => deployedPath,
|
||||
() => deployedUrl,
|
||||
() => resource_type,
|
||||
() => (selected ? (perWsUser[selected] ?? $userStore)?.is_admin : undefined)
|
||||
() => acting.in(selected)?.is_admin
|
||||
],
|
||||
async ([ws, path, _url, type, admin]) =>
|
||||
ws && path && type === 'git_repository' && admin
|
||||
@@ -238,15 +245,15 @@
|
||||
let resourceToEdit: Resource | undefined = $derived(
|
||||
selected ? fetchedResources[selected] : undefined
|
||||
)
|
||||
let can_write = $derived.by(() => {
|
||||
if (!selected) return true
|
||||
// `undefined` until both the resource and the acting user have landed — a pending verdict
|
||||
// is neither a grant nor the denial the read-only alert announces, so the two must stay
|
||||
// distinguishable.
|
||||
let can_write: boolean | undefined = $derived.by(() => {
|
||||
// A resource that does not exist yet has nobody's permissions on it.
|
||||
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 || !acting.resolved(selected)) return undefined
|
||||
return canWrite(current?.path ?? initialPath, r.extra_perms ?? {}, acting.in(selected))
|
||||
})
|
||||
|
||||
const dirtyWorkspaces = $derived(
|
||||
@@ -275,7 +282,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 +290,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 ?? {}, acting.in(ws))
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -296,9 +298,10 @@
|
||||
// New-resource bootstrap: seed empty state per workspace (edit mode
|
||||
// is seeded by the lazy-fetch effect below).
|
||||
$effect(() => {
|
||||
if (!selected) return
|
||||
const ws = selected
|
||||
if (!ws) return
|
||||
if (initialPath) return
|
||||
if (selected in initialStates) return
|
||||
if (ws in initialStates) return
|
||||
untrack(() => {
|
||||
const s: ResourceState = {
|
||||
path: '',
|
||||
@@ -307,9 +310,9 @@
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
}
|
||||
ensureHandle(selected, s)
|
||||
initialStates[selected] = structuredClone(s)
|
||||
existedInitially[selected] = false
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,42 +322,40 @@
|
||||
if (!ws || !initialPath) return
|
||||
if (ws in states) return
|
||||
untrack(() => {
|
||||
Promise.all([
|
||||
ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }),
|
||||
getUserExt(ws)
|
||||
]).then(([r, user]) => {
|
||||
// `.draft` already holds the editor's `ResourceState` shape.
|
||||
const savedDraftState = (r as any).draft as ResourceState | undefined
|
||||
fetchedResources[ws] = r
|
||||
// Deployed baseline as the dirty-check reference, so the banner
|
||||
// compares draft-vs-deployed and fires immediately when a draft exists.
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as any,
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }).then(
|
||||
(r) => {
|
||||
// `.draft` already holds the editor's `ResourceState` shape.
|
||||
const savedDraftState = (r as any).draft as ResourceState | undefined
|
||||
fetchedResources[ws] = r
|
||||
// Deployed baseline as the dirty-check reference, so the banner
|
||||
// compares draft-vs-deployed and fires immediately when a draft exists.
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as any,
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
openedOnDraft[ws] = !!savedDraftState
|
||||
// Gate BEFORE the handle is acquired: `stopSync` queues on a
|
||||
// not-yet-live entry, and the form can settle before the effect
|
||||
// above gets a chance to run. Only worth doing when no draft exists
|
||||
// yet — where one does, there is no phantom to prevent and
|
||||
// suspending could only drop a write.
|
||||
if (!savedDraftState) setGated(ws, true)
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
existedInitially[ws] = !(r as any).no_deployed
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
if (ws === effectiveWorkspace) {
|
||||
resource_type = r.resource_type
|
||||
}
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
openedOnDraft[ws] = !!savedDraftState
|
||||
// Gate BEFORE the handle is acquired: `stopSync` queues on a
|
||||
// not-yet-live entry, and the form can settle before the effect
|
||||
// above gets a chance to run. Only worth doing when no draft exists
|
||||
// yet — where one does, there is no phantom to prevent and
|
||||
// suspending could only drop a write.
|
||||
if (!savedDraftState) setGated(ws, true)
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
// Draft-only paths (`no_deployed`) have no row — saving must
|
||||
// CREATE, not update (update 404s).
|
||||
existedInitially[ws] = !(r as any).no_deployed
|
||||
perWsUser[ws] = user
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
if (ws === effectiveWorkspace) {
|
||||
resource_type = r.resource_type
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -421,7 +422,7 @@
|
||||
onDraftStateChange?.(!!initialPath && selectedDirty)
|
||||
})
|
||||
$effect(() => {
|
||||
onCanWriteChange?.(can_write)
|
||||
onCanWriteChange?.(can_write === true)
|
||||
})
|
||||
|
||||
export function localDraftDeployed(): ResourceState | undefined {
|
||||
@@ -555,7 +556,9 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if current}
|
||||
<!-- Held back until there is a verdict: rendering the form against a pending `can_write`
|
||||
would flash read-only controls at someone who can in fact write. -->
|
||||
{#if current && can_write !== undefined}
|
||||
{#key current}
|
||||
<ResourceForm
|
||||
bind:path={() => current!.path, setPath}
|
||||
@@ -577,6 +580,7 @@
|
||||
{resourceToEdit}
|
||||
onLoadResourceType={() => resourceTypeResource.refetch()}
|
||||
workspace={selected}
|
||||
actingUser={acting.in(selected) ?? null}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
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 { useActingUser } from '$lib/actingUser.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
|
||||
import {
|
||||
@@ -25,6 +26,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 +61,13 @@
|
||||
// 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 there is nothing to
|
||||
// answer about until one is open.
|
||||
const historyUser = useActingUser(() => (path ? historyWorkspace : 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.
|
||||
@@ -85,6 +88,7 @@
|
||||
// would still be standing when the next drawer session ends and would swallow that one's
|
||||
// anchor clear. Every session starts having to clear its own.
|
||||
keepAnchorOnClose = false
|
||||
historyUser.forgetFailures()
|
||||
resource_type = undefined
|
||||
path = p
|
||||
selected = effectiveWorkspace
|
||||
@@ -98,6 +102,7 @@
|
||||
nDefaultValues?: Record<string, any>
|
||||
): Promise<void> {
|
||||
keepAnchorOnClose = false
|
||||
historyUser.forgetFailures()
|
||||
path = undefined
|
||||
resource_type = resourceType
|
||||
defaultValues = nDefaultValues
|
||||
@@ -147,7 +152,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'
|
||||
@@ -38,7 +38,9 @@
|
||||
viewJsonSchema: boolean
|
||||
jsonError: string
|
||||
deployTo: string | undefined
|
||||
can_write: boolean
|
||||
/** `undefined` while the acting user or the resource is still being resolved: neither a
|
||||
* grant nor the denial the read-only alert announces. */
|
||||
can_write: boolean | undefined
|
||||
resource_type: string | undefined
|
||||
resourceTypeInfo: ResourceType | undefined
|
||||
resourceSchema: Schema | undefined
|
||||
@@ -48,6 +50,11 @@
|
||||
/** 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
|
||||
* `null` 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 | null
|
||||
/** 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 +80,7 @@
|
||||
resourceToEdit,
|
||||
onLoadResourceType,
|
||||
workspace = undefined,
|
||||
actingUser,
|
||||
onCredentialStored
|
||||
}: Props = $props()
|
||||
|
||||
@@ -153,7 +161,7 @@
|
||||
|
||||
{#if !hidePath}
|
||||
<div>
|
||||
{#if !can_write}
|
||||
{#if can_write === false}
|
||||
<div class="my-2">
|
||||
<Alert type="warning" title="Only read access">
|
||||
You only have read access to this resource and cannot edit it
|
||||
@@ -163,12 +171,13 @@
|
||||
<Label label="Path">
|
||||
<ResourcePathHint />
|
||||
<Path
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
|
||||
disabled={initialPath != '' && !isOwner(initialPath, actingUser ?? undefined, ws)}
|
||||
bind:path
|
||||
{initialPath}
|
||||
namePlaceholder="resource"
|
||||
kind="resource"
|
||||
workspaceOverride={workspace}
|
||||
{actingUser}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -246,7 +255,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'
|
||||
@@ -20,8 +20,7 @@
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import WsSpecificVersions from './WsSpecificVersions.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
@@ -38,8 +37,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)
|
||||
@@ -50,12 +51,15 @@
|
||||
// releasing them on component teardown. `states` indexes the resulting
|
||||
// handles by workspace ID for ergonomic lookup downstream.
|
||||
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: VariableState }>>([])
|
||||
// Plain objects keyed by workspace id, so an id that is also an `Object.prototype` key
|
||||
// (`constructor`, …) reads as already present and the variable never loads. Such ids are
|
||||
// deliberately unsupported: too unlikely to be worth guarding every read.
|
||||
let initialStates: Record<string, VariableState> = $state({})
|
||||
let existedInitially: Record<string, boolean> = $state({})
|
||||
let extraPerms: Record<string, Record<string, boolean>> = $state({})
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
const acting = useActingUser(() => selected)
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -106,11 +110,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 || !acting.resolved(selected)) return undefined
|
||||
return canWrite(editPath ?? '', perms, acting.in(selected))
|
||||
})
|
||||
const dirtyWorkspaces = $derived(
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
@@ -154,7 +161,7 @@
|
||||
const dirtyCanWrite = $derived(
|
||||
dirtyWorkspaces.every((ws) => {
|
||||
const perms = extraPerms[ws]
|
||||
return !perms || canWrite(editPath ?? '', perms, perWsUser[ws] ?? $userStore)
|
||||
return !perms || canWrite(editPath ?? '', perms, acting.in(ws))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -165,15 +172,12 @@
|
||||
if (!ws || !p) return
|
||||
if (ws in states) return
|
||||
untrack(() => {
|
||||
Promise.all([
|
||||
VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
}),
|
||||
getUserExt(ws)
|
||||
]).then(([v, user]) => {
|
||||
VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
}).then((v) => {
|
||||
// `.draft` already holds the editor's `VariableState` shape.
|
||||
const savedDraftState = (v as any).draft as VariableState | undefined
|
||||
// Deployed baseline as the dirty-check reference, so the banner
|
||||
@@ -196,7 +200,6 @@
|
||||
// CREATE, not update (update 404s).
|
||||
existedInitially[ws] = !(v as any).no_deployed
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
perWsUser[ws] = user
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -208,8 +211,8 @@
|
||||
initialStates = {}
|
||||
existedInitially = {}
|
||||
extraPerms = {}
|
||||
perWsUser = {}
|
||||
pathError = ''
|
||||
acting.forgetFailures()
|
||||
}
|
||||
|
||||
export function initNew(): void {
|
||||
@@ -329,7 +332,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>
|
||||
@@ -341,7 +344,9 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if current}
|
||||
<!-- Held back until there is a verdict: rendering the form against a pending `can_write`
|
||||
would flash read-only controls at someone who can in fact write. -->
|
||||
{#if current && can_write !== undefined}
|
||||
{#key current}
|
||||
<VariableForm
|
||||
bind:this={form}
|
||||
@@ -352,10 +357,11 @@
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
{initialPath}
|
||||
deployTo={deployTo.current}
|
||||
{can_write}
|
||||
can_write={can_write === true}
|
||||
{edit}
|
||||
onLoadSecret={loadSecret}
|
||||
{workspace}
|
||||
workspace={selected}
|
||||
actingUser={acting.in(selected) ?? null}
|
||||
/>
|
||||
{/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,11 @@
|
||||
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
|
||||
* `null` 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 | null
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,7 +52,8 @@
|
||||
can_write,
|
||||
edit,
|
||||
onLoadSecret,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
actingUser
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
@@ -71,13 +77,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 ?? undefined, ws)}
|
||||
bind:error={pathError}
|
||||
bind:path
|
||||
{initialPath}
|
||||
namePlaceholder="variable"
|
||||
kind="variable"
|
||||
workspaceOverride={workspace}
|
||||
{actingUser}
|
||||
/>
|
||||
<LabelsInput bind:labels />
|
||||
</div>
|
||||
@@ -89,7 +96,7 @@
|
||||
<Toggle
|
||||
on:change={() => edit && !hasStagedValue && onLoadSecret?.()}
|
||||
bind:checked={variable.is_secret}
|
||||
disabled={edit && ($userStore?.operator || isEncryptedDraftValue(variable.value))}
|
||||
disabled={edit && (!actingUser || actingUser.operator || isEncryptedDraftValue(variable.value))}
|
||||
/>
|
||||
{#if variable.is_secret}
|
||||
<Alert type="info" title="Audit log for each access">
|
||||
@@ -131,7 +138,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, workspaceStore } 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,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import PermissionedAsLine from '../PermissionedAsLine.svelte'
|
||||
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
|
||||
let {
|
||||
useDrawer = true,
|
||||
@@ -114,7 +115,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 +136,18 @@
|
||||
let selectedPermissionedAs = $state<string | undefined>(undefined)
|
||||
let preservePermissionedAs = $state(false)
|
||||
|
||||
const triggerWs = getTriggerWorkspace()
|
||||
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
|
||||
// `undefined` while the 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 acting = useActingUser(() => wsId)
|
||||
const actingUser = $derived(acting.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 +157,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!)}` : '')
|
||||
@@ -168,6 +182,7 @@
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
drawerLoading = true
|
||||
acting.forgetFailures()
|
||||
try {
|
||||
drawer?.openDrawer()
|
||||
setPageDrawerAnchor(SCHEDULES_PATH, ePath)
|
||||
@@ -313,6 +328,7 @@
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
drawerLoading = true
|
||||
acting.forgetFailures()
|
||||
try {
|
||||
let s: Schedule | undefined
|
||||
if (schedule_path) {
|
||||
@@ -590,7 +606,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 +853,7 @@
|
||||
namePlaceholder="schedule"
|
||||
kind="schedule"
|
||||
disableEditing={!can_write}
|
||||
actingUser={actingUser ?? null}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex justify-start w-full">
|
||||
@@ -972,7 +989,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}
|
||||
|
||||
Reference in New Issue
Block a user