fix: judge trigger permissions by the user acting in the session workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-18 15:03:02 +02:00
co-authored by Claude Opus 5
parent fffd8b5217
commit f5d4fdab7d
13 changed files with 214 additions and 50 deletions
@@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { fromStore, toStore, type Readable } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import { withWorkspaceParam } from './sessions/sessionMode.svelte'
import { useActingUser } from '$lib/actingUser.svelte'
// The workspace a subtree acts on. An AI session edits its (possibly forked) workspace while
// `workspaceStore` stays on the navigation workspace, and a component that reads the navigation
@@ -29,6 +30,16 @@ export function useOperatingWorkspace(): Readable<string | undefined> {
return resolve ? toStore(() => resolve() ?? navigation.current) : workspaceStore
}
/** The user acting in this subtree's operating workspace: `$userStore` describes the navigation
* workspace, so a permission check reading it inside a fork's editor answers about the parent —
* enabling or disabling controls by the wrong roles. Unresolved reads as unknown, which
* `canWrite`/`isOwner` refuse; ask `resolved` before rendering that as a denial. Reads context
* and registers an effect: call during component initialisation. */
export function useOperatingUser(): ReturnType<typeof useActingUser> {
const operating = fromStore(useOperatingWorkspace())
return useActingUser(() => operating.current)
}
/** Stamps this component's operating workspace onto an in-app link, which otherwise opens in
* the navigation workspace. Reads context: call during component initialisation. */
export function useOperatingWorkspaceHref(): (href: string) => string {
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -66,6 +66,25 @@ function readsNavigationStore(file: string): boolean {
return /\$workspaceStore\b|\bget\(workspaceStore\)/.test(source)
}
describe('trigger editors', () => {
it('judge permissions by the acting user, not the navigation one', () => {
// `$userStore` describes the navigation workspace: a session tab editing a fork would
// enable or disable its controls by the parent's roles. `useOperatingUser()` answers for
// the workspace the editor acts on.
const editors = readdirSync(join(components, 'triggers'), {
recursive: true,
encoding: 'utf-8'
})
.filter((f) => f.endsWith('EditorInner.svelte'))
.map((f) => join('triggers', f))
expect(editors.length).toBeGreaterThan(10)
const offenders = editors.filter((f) =>
/\$userStore\b/.test(readFileSync(join(components, f), 'utf-8'))
)
expect(offenders).toEqual([])
})
})
describe('components under a session editor', () => {
it('read the operating workspace, not the navigation store', () => {
const reachable = reachableComponents()
@@ -11,7 +11,7 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -42,7 +42,10 @@
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
interface Props {
useDrawer?: boolean
@@ -86,6 +89,8 @@
cloudDisabled = false
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
let amqp_resource_path: string = $state('')
@@ -101,7 +106,13 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let queue_name: string = $state('')
@@ -166,6 +177,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -250,7 +263,8 @@
is_flow = cfg?.is_flow
path = cfg?.path
mode = cfg?.mode ?? 'enabled'
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -541,7 +555,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
/>
</Section>
{/if}
@@ -9,7 +9,7 @@
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
@@ -41,6 +41,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -56,7 +57,15 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let azure_resource_path: string = $state('')
@@ -148,6 +157,8 @@
defaultValues?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
try {
drawer?.openDrawer()
@@ -251,7 +262,8 @@
event_type_filters = cfg?.event_type_filters
path = cfg?.path
mode = cfg?.mode ?? 'enabled'
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -506,7 +518,7 @@
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
allowEdit={!actingUser?.operator}
clearable
/>
{#if emptyString(script_path)}
@@ -18,7 +18,7 @@
type Retry,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -39,6 +39,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -62,6 +63,8 @@
customSaveBehavior = undefined
} = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -82,7 +85,13 @@
let workspaced_local_part = $state(false)
let drawerLoading = $state(true)
let showLoader = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
@@ -102,7 +111,7 @@
let originalConfig = $state<NewEmailTrigger | undefined>(undefined)
let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {}))
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const isAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin)
const emailConfig = $derived.by(getEmailTriggerConfig)
const draftSync = useTriggerDraftSync({
@@ -135,6 +144,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
@@ -223,7 +234,8 @@
local_part = cfg?.local_part ?? ''
workspaced_local_part = cfg?.workspaced_local_part ?? false
extraPerms = cfg?.extra_perms ?? undefined
can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore)
permsPath = path
permsForWrite = cfg?.extra_perms ?? {}
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -275,7 +287,7 @@
saveCfg,
edit,
wsId!,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds
)
if (isSaved) {
@@ -415,7 +427,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
promptClass="text-xs mt-3 mb-1 text-primary"
>
{#snippet createButton()}
@@ -9,7 +9,7 @@
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
@@ -44,6 +44,7 @@
import Subsection from '$lib/components/Subsection.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -60,7 +61,15 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let topic_id: string = $state('')
let gcp_resource_path: string | undefined = $state('')
@@ -159,6 +168,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
try {
drawer?.openDrawer()
@@ -281,7 +292,8 @@
path = cfg?.path
mode = cfg?.mode ?? 'enabled'
topic_id = cfg?.topic_id ?? ''
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -554,7 +566,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
promptText="Pick a script or flow to be triggered "
>
{#snippet createButton()}
@@ -22,7 +22,7 @@
type Retry,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import {
canWrite,
capitalize,
@@ -78,6 +78,7 @@
import UserSettings from '$lib/components/UserSettings.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -101,6 +102,8 @@
customSaveBehavior = undefined
} = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -156,7 +159,13 @@
let authentication_resource_path = $state('')
let variable_path = $state('')
let signature_options_type = $state<'custom_script' | 'custom_signature'>('custom_signature')
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
let summary: string | undefined = $state()
let routeDescription: string | undefined = $state()
@@ -266,6 +275,8 @@
defaultConfig?: Partial<NewHttpTrigger>
) {
if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
@@ -387,7 +398,8 @@
is_static_website = cfg?.is_static_website ?? false
}
extraPerms = cfg?.extra_perms ?? undefined
can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore)
permsPath = path
permsForWrite = cfg?.extra_perms ?? {}
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -433,7 +445,7 @@
saveCfg,
edit,
wsId!,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds
)
if (isSaved) {
@@ -549,7 +561,7 @@
<UserSettings
bind:this={userSettings}
newTokenWorkspace={wsId}
newTokenLabel={`http-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
newTokenLabel={`http-${actingUser?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
{/if}
@@ -760,7 +772,7 @@
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
allowEdit={!actingUser?.operator}
clearable
/>
@@ -12,7 +12,7 @@
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -38,6 +38,7 @@
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -84,6 +85,8 @@
onReset = undefined
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -99,7 +102,13 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let initialConfig: Record<string, any> | undefined = undefined
@@ -170,6 +179,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -263,7 +274,8 @@
autoCommit = cfg?.auto_commit ?? true
mode = cfg?.mode ?? 'enabled'
extra_perms = cfg?.extra_perms
can_write = canWrite(path, cfg?.extra_perms, $userStore)
permsPath = path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -565,7 +577,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
>
{#snippet createButton()}
{#if emptyString(script_path)}
@@ -11,7 +11,7 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -47,6 +47,7 @@
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -93,6 +94,8 @@
cloudDisabled = false
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -109,7 +112,13 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let subscribe_topics: MqttSubscribeTopic[] = $state([])
@@ -165,6 +174,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -258,7 +269,8 @@
v3_config = cfg?.v3_config ?? DEFAULT_V3_CONFIG
v5_config = cfg?.v5_config ?? DEFAULT_V5_CONFIG
client_id = cfg?.client_id ?? ''
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -539,7 +551,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
>
{#snippet createButton()}
{#if emptyString(script_path)}
@@ -11,7 +11,7 @@
import Path from '$lib/components/Path.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -33,6 +33,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -80,6 +81,8 @@
onReset = undefined
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -94,7 +97,13 @@
let path: string = $state('')
let pathError = $state('')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let defaultValues: Record<string, any> | undefined = $state(undefined)
@@ -153,6 +162,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -252,7 +263,8 @@
consumer_name: useJetstream ? cfg?.consumer_name || '' : undefined
}
mode = cfg?.mode ?? 'enabled'
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -534,7 +546,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
>
{#snippet createButton()}
{#if emptyString(script_path)}
@@ -19,7 +19,7 @@
type Retry,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -55,6 +55,7 @@
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import { capitalize } from '$lib/utils'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -101,6 +102,8 @@
onReset = undefined
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -115,7 +118,13 @@
let path: string = $state('')
let pathError = $state('')
let dirtyPath: boolean = $state(false)
let can_write: boolean = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading: boolean = $state(true)
let showLoading: boolean = $state(false)
let postgres_resource_path: string = $state('')
@@ -254,6 +263,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -382,7 +393,8 @@
postgres_resource_path = cfg?.postgres_resource_path
publication_name = cfg?.publication_name
replication_slot_name = cfg?.replication_slot_name
can_write = canWrite(path, cfg?.extra_perms, $userStore)
permsPath = path
permsForWrite = cfg?.extra_perms
transaction_to_track = [...cfg?.publication?.transaction_to_track]
relations = cfg?.publication?.table_to_track ?? []
error_handler_path = cfg?.error_handler_path
@@ -718,7 +730,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
promptText="Pick a script or flow to be triggered "
promptClass="text-xs text-primary"
>
@@ -9,7 +9,7 @@
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { Loader2 } from 'lucide-svelte'
@@ -39,6 +39,7 @@
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -85,6 +86,8 @@
onReset = undefined
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -100,7 +103,13 @@
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let aws_resource_path: string = $state('')
@@ -148,6 +157,8 @@
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.sqs.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -238,7 +249,8 @@
path = cfg?.path
mode = cfg?.mode ?? 'enabled'
aws_auth_resource_type = cfg?.aws_auth_resource_type
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
permsPath = cfg?.path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -518,7 +530,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!$userStore?.operator}
isOperator={!!actingUser?.operator}
promptText="Pick a script or flow to be triggered "
>
{#snippet createButton()}
@@ -25,7 +25,7 @@
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
@@ -55,6 +55,7 @@
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { capitalize } from '$lib/utils'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
@@ -102,6 +103,8 @@
cloudDisabled = false
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
@@ -128,7 +131,13 @@
let heartbeat_message = $state('')
let heartbeat_state_field = $state('')
let dirtyPath = $state(false)
let can_write = $state(true)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading = $state(true)
let showLoading = $state(false)
let initialConfig: Record<string, any> | undefined = undefined
@@ -194,6 +203,8 @@
defaultConfig?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.websocket.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -289,7 +300,8 @@
heartbeat_interval_secs = hb?.interval_secs ?? 41
heartbeat_message = hb?.message ?? ''
heartbeat_state_field = hb?.state_field ?? ''
can_write = canWrite(path, cfg?.extra_perms, $userStore)
permsPath = path
permsForWrite = cfg?.extra_perms
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
@@ -614,7 +626,7 @@
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
allowEdit={!actingUser?.operator}
clearable
/>
{#if emptyString(script_path)}