From f5d4fdab7dbad0cccc647442f249def7abeaab89 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 18 Sep 2026 15:03:02 +0200 Subject: [PATCH] fix: judge trigger permissions by the user acting in the session workspace Co-Authored-By: Claude Opus 5 (1M context) --- .../components/operatingWorkspace.svelte.ts | 11 +++++++++ .../lib/components/operatingWorkspace.test.ts | 21 +++++++++++++++- .../amqp/AmqpTriggerEditorInner.svelte | 24 +++++++++++++++---- .../azure/AzureTriggerEditorInner.svelte | 20 ++++++++++++---- .../email/EmailTriggerEditorInner.svelte | 24 ++++++++++++++----- .../triggers/gcp/GcpTriggerEditorInner.svelte | 20 ++++++++++++---- .../triggers/http/RouteEditorInner.svelte | 24 ++++++++++++++----- .../kafka/KafkaTriggerEditorInner.svelte | 20 ++++++++++++---- .../mqtt/MqttTriggerEditorInner.svelte | 20 ++++++++++++---- .../nats/NatsTriggerEditorInner.svelte | 20 ++++++++++++---- .../PostgresTriggerEditorInner.svelte | 20 ++++++++++++---- .../triggers/sqs/SqsTriggerEditorInner.svelte | 20 ++++++++++++---- .../WebsocketTriggerEditorInner.svelte | 20 ++++++++++++---- 13 files changed, 214 insertions(+), 50 deletions(-) diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index cb19cef99b..93a7f2c0de 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -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 { 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 { + 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 { diff --git a/frontend/src/lib/components/operatingWorkspace.test.ts b/frontend/src/lib/components/operatingWorkspace.test.ts index a31ee8a12a..d36cb2d503 100644 --- a/frontend/src/lib/components/operatingWorkspace.test.ts +++ b/frontend/src/lib/components/operatingWorkspace.test.ts @@ -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() diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 37c9bde8d3..7e3c2c5ac1 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | 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} /> {/if} diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 40faa8f2d4..697bc00ad4 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + let permsPath = $state(undefined) + let permsForWrite = $state | 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 ) { 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)} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 9ef5024e6a..30f848004f 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -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(undefined) + let permsForWrite = $state | 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 | undefined>(undefined) let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) @@ -102,7 +111,7 @@ let originalConfig = $state(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()} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index fc897ddc09..92902aa44b 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + let permsPath = $state(undefined) + let permsForWrite = $state | 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()} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index d4f2347358..2b28ae0d8d 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -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(undefined) + let permsForWrite = $state | 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 | undefined>(undefined) let summary: string | undefined = $state() let routeDescription: string | undefined = $state() @@ -266,6 +275,8 @@ defaultConfig?: Partial ) { 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 @@ {/if} @@ -760,7 +772,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!$userStore?.operator} + allowEdit={!actingUser?.operator} clearable /> diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 3c31dd4486..3fb5668f44 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | 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 | 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)} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index c1ec513d2d..f3a42f0d49 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | 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)} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index a5e268aff1..16997be11a 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -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(undefined) + let permsForWrite = $state | 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 | 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)} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 971e05e3d2..23ce3fc43c 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -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(undefined) + let permsForWrite = $state | 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" > diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 17aa3ab467..0b3f846c97 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -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('enabled') let dirtyPath = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | 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()} diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index e813fe26f0..86285c06b3 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -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(undefined) + let permsForWrite = $state | 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 | undefined = undefined @@ -194,6 +203,8 @@ defaultConfig?: Record ) { 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)}