mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat: operators get AI sessions (#11443)
* feat: operators get AI sessions `prefersSessionHandoff` stops taking the role, so every AI entry point an operator can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off, the raw-app template picker — opens a session for them like anyone else. The sessions page drops its refusal, and the layout stops keeping them on the docked chat. In the sidebar they now get the Workspace/AI Sessions switch and session mode, and lose the Ask AI entry that stood in for them, since the pane behind it is no longer the one they are sent to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: operators get run-oriented examples in the home composer Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: note builder_flows in the operator composer examples comment Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: gate session tools and editors on operator rights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep operator AI sessions behind a browser opt-in Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep flows refused in the prompt for operators without builder rights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: decide every frontend edit check through one editRights module Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: let admins who are also operators open session editors, as drafts.rs does Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: mirror drafts.rs exactly in roleCanDraft and correct the edit-rights docs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,13 +110,14 @@
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
|
||||
import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext'
|
||||
import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { useEditRights, useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
const editRights = useEditRights()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
@@ -556,8 +557,14 @@
|
||||
// flag that answer for it are per workspace, so the nav user would answer for the wrong
|
||||
// membership when a session editor operates on another workspace.
|
||||
const user = await getUserExt(opWorkspace ?? '').catch(() => undefined)
|
||||
const operatorSettings = $userWorkspaces.find(
|
||||
(w) => w.id === opWorkspace
|
||||
)?.operator_settings
|
||||
agentCanWrite = Object.fromEntries(
|
||||
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)])
|
||||
draftAgents.map((a) => [
|
||||
a.path,
|
||||
agentDraftCanWrite(a, user ?? $userStore ?? undefined, operatorSettings)
|
||||
])
|
||||
)
|
||||
// The path is passed, so a draft that renames the agent is refused here too: a rename is
|
||||
// the agent editor's to deploy, and this dialog lists the agent under the path the flow
|
||||
@@ -1560,7 +1567,7 @@
|
||||
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
|
||||
|
||||
{#key renderCount}
|
||||
{#if !actingUser?.operator || $operatorBuilderFlows}
|
||||
{#if editRights.roleCanAuthor('flow')}
|
||||
{#if $pathStore}
|
||||
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
|
||||
{/if}
|
||||
@@ -1585,8 +1592,7 @@
|
||||
// Gated like the summary's own editor: a host that allows neither field keeps the
|
||||
// band's segment, which copies the path.
|
||||
pathTrigger:
|
||||
customUi?.topBar?.editablePath != false ||
|
||||
customUi?.topBar?.editableSummary != false
|
||||
customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false
|
||||
? flowPathTrigger
|
||||
: undefined
|
||||
}}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import Row from './table/Row.svelte'
|
||||
import Cell from './table/Cell.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Label from './Label.svelte'
|
||||
@@ -42,6 +42,7 @@
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const editRights = useEditRights()
|
||||
|
||||
const ROLE_TOOLTIPS = {
|
||||
member:
|
||||
@@ -92,7 +93,7 @@
|
||||
// Derived for the same reason as the trigger editors: the acting user's role can land after
|
||||
// the group does, and a snapshot taken first would stay read-only.
|
||||
const can_write = $derived(
|
||||
writeVerdict ?? (groupPerms !== undefined && canWrite(name, groupPerms, actingUser))
|
||||
writeVerdict ?? (groupPerms !== undefined && editRights.canEditItem('group', name, groupPerms))
|
||||
)
|
||||
let group: Group | undefined
|
||||
let instance_group: InstanceGroup | undefined = $state()
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
type Resource,
|
||||
type ResourceType
|
||||
} from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
||||
@@ -261,8 +262,16 @@
|
||||
if (!initialPath || !selected) return true
|
||||
const r = fetchedResources[selected]
|
||||
if (!r || !acting.resolved(selected)) return undefined
|
||||
return canWrite(current?.path ?? initialPath, r.extra_perms ?? {}, acting.in(selected))
|
||||
return canEditResourceIn(selected, current?.path ?? initialPath, r.extra_perms)
|
||||
})
|
||||
function canEditResourceIn(
|
||||
ws: string,
|
||||
path: string,
|
||||
extraPerms: Record<string, boolean> | undefined
|
||||
): boolean {
|
||||
const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings
|
||||
return canEditItem('resource', path, extraPerms, acting.in(ws), settings)
|
||||
}
|
||||
|
||||
const dirtyWorkspaces = $derived(
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
@@ -423,9 +432,7 @@
|
||||
const dirtyCanWrite = $derived(
|
||||
dirtyWorkspaces.every((ws) => {
|
||||
const r = fetchedResources[ws]
|
||||
return (
|
||||
!r || canWrite(states[ws]?.draft?.path ?? initialPath, r.extra_perms ?? {}, acting.in(ws))
|
||||
)
|
||||
return !r || canEditResourceIn(ws, states[ws]?.draft?.path ?? initialPath, r.extra_perms)
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -128,8 +128,9 @@
|
||||
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let {
|
||||
script = $bindable(),
|
||||
@@ -1385,7 +1386,7 @@
|
||||
onCanceled={() => (perpetualRunsToConfirm = undefined)}
|
||||
/>
|
||||
|
||||
{#if !actingUser?.operator}
|
||||
{#if editRights.roleCanAuthor('script')}
|
||||
<Drawer
|
||||
placement="right"
|
||||
bind:open={metadataOpen}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { VARIABLES_PATH } from './sessions/previewPaths'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { Save } from 'lucide-svelte'
|
||||
import VariableForm from './VariableForm.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
@@ -137,8 +138,12 @@
|
||||
if (!selected || !edit) return true
|
||||
const perms = extraPerms[selected]
|
||||
if (!perms || !acting.resolved(selected)) return undefined
|
||||
return canWrite(editPath ?? '', perms, acting.in(selected))
|
||||
return canEditVariableIn(selected, perms)
|
||||
})
|
||||
function canEditVariableIn(ws: string, perms: Record<string, boolean>): boolean {
|
||||
const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings
|
||||
return canEditItem('variable', editPath ?? '', perms, acting.in(ws), settings)
|
||||
}
|
||||
const dirtyWorkspaces = $derived(
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
@@ -301,7 +306,7 @@
|
||||
const dirtyCanWrite = $derived(
|
||||
dirtyWorkspaces.every((ws) => {
|
||||
const perms = extraPerms[ws]
|
||||
return !perms || canWrite(editPath ?? '', perms, acting.in(ws))
|
||||
return !perms || canEditVariableIn(ws, perms)
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -20,15 +20,12 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore, type UserExt } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { ExternalLink, Pen } from 'lucide-svelte'
|
||||
import { page } from '$app/state'
|
||||
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import {
|
||||
setOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
@@ -58,7 +55,6 @@
|
||||
// The app, its permission check and everything this viewer renders belong to `workspace`,
|
||||
// which on a session preview tab is the session's and not the one the nav points at.
|
||||
setOperatingWorkspace(() => workspace)
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
/** This workspace's membership for the viewer, which is what the app's `ctx.username` /
|
||||
* `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */
|
||||
@@ -72,8 +68,9 @@
|
||||
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
|
||||
undefined
|
||||
)
|
||||
const editRights = useEditRights()
|
||||
const canWriteApp = $derived(
|
||||
!!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current)
|
||||
!!appPerms && editRights.canEditItem('app', appPerms.path, appPerms.extraPerms)
|
||||
)
|
||||
/** Raw vs low-code, read from the app itself rather than from the route:
|
||||
* both kinds render here and either route serves either kind (links to a raw
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
@@ -43,7 +43,7 @@
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
flow: Flow & {
|
||||
@@ -125,7 +125,7 @@
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
|
||||
let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows)
|
||||
let hideForOperator = $derived(!editRights.roleCanAuthor('flow'))
|
||||
</script>
|
||||
|
||||
{#if menuOpen}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
type AttachedBlob
|
||||
} from './blobUtils'
|
||||
import { MessageDraft } from './messageDraft.svelte'
|
||||
import { useSessionPermissions } from '$lib/components/sessions/sessionPermissions.svelte'
|
||||
import ExpandableImage, {
|
||||
isImageViewerOpen
|
||||
} from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
@@ -134,10 +135,28 @@
|
||||
'Refactor a script to add error handling',
|
||||
'List my workspace flows and scripts'
|
||||
]
|
||||
const globalSuggestion =
|
||||
GLOBAL_PLACEHOLDER_SUGGESTIONS[
|
||||
Math.floor(Math.random() * GLOBAL_PLACEHOLDER_SUGGESTIONS.length)
|
||||
]
|
||||
// An operator authors nothing, so nothing above is theirs to ask for; one with the
|
||||
// workspace's builder right can compose flows out of deployed scripts as well.
|
||||
const OPERATOR_PLACEHOLDER_SUGGESTIONS = [
|
||||
'List my workspace flows and scripts',
|
||||
'Show the runs that failed today and why',
|
||||
'Find the script that sends a report and run it',
|
||||
'Rerun the last failed run of a flow'
|
||||
]
|
||||
const BUILDER_PLACEHOLDER_SUGGESTIONS = [
|
||||
...OPERATOR_PLACEHOLDER_SUGGESTIONS,
|
||||
'Compose a flow that chains two deployed scripts'
|
||||
]
|
||||
const permissions = useSessionPermissions(() => chatHost.operatingWorkspace)
|
||||
const suggestionPick = Math.random()
|
||||
const globalSuggestion = $derived.by(() => {
|
||||
const pool = permissions.canOpenEditor('script')
|
||||
? GLOBAL_PLACEHOLDER_SUGGESTIONS
|
||||
: permissions.canOpenEditor('flow')
|
||||
? BUILDER_PLACEHOLDER_SUGGESTIONS
|
||||
: OPERATOR_PLACEHOLDER_SUGGESTIONS
|
||||
return pool[Math.floor(suggestionPick * pool.length)]
|
||||
})
|
||||
|
||||
// Generate mode-specific placeholder
|
||||
const modePlaceholder = $derived.by(() => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { canDraftItem, roleCanDraft } from '$lib/editRights'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
@@ -212,10 +212,13 @@ import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
canOpenEditor,
|
||||
DEPLOY,
|
||||
NONE,
|
||||
RUN_FLOW_PREVIEW,
|
||||
RUN_PREVIEW,
|
||||
WRITE_DRAFT,
|
||||
WRITE_FLOW_DRAFT,
|
||||
type SessionAccess,
|
||||
type SessionTool,
|
||||
type SessionToolPolicy
|
||||
@@ -763,7 +766,7 @@ const listRunsSchema = z.object({
|
||||
// superadmin caller; every other caller gets the hedge even where nothing is hidden.
|
||||
const NO_WORKERS_VISIBLE_MESSAGE =
|
||||
'No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. ' +
|
||||
'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves. Never state that no workers are online or that the instance has none.'
|
||||
'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none.'
|
||||
const NO_WORKERS_CONNECTED_MESSAGE =
|
||||
'No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts.'
|
||||
const WORKER_PAGE_SIZE = 100
|
||||
@@ -1184,7 +1187,14 @@ const openPreviewSchema = z.object({
|
||||
* it can open, so it is not offered. Per-PATH write permission is unknowable here —
|
||||
* a workspace capability says nothing about `f/finance/*` — and is resolved in the
|
||||
* handler instead. Mirrors `open_page` re-narrowing its page enum per user. */
|
||||
const openPreviewNoPipelineKind = z
|
||||
.enum(['script', 'flow', 'raw_app'])
|
||||
.describe(
|
||||
'Item kind to preview. Use "raw_app" for code-based apps. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
|
||||
)
|
||||
|
||||
const openPreviewViewOnlySchema = openPreviewSchema.extend({
|
||||
kind: openPreviewNoPipelineKind,
|
||||
mode: z
|
||||
.literal('view')
|
||||
.optional()
|
||||
@@ -1193,6 +1203,17 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({
|
||||
)
|
||||
})
|
||||
|
||||
/** A builder operator: the flow editor, and the deployed page for everything else. */
|
||||
const openPreviewFlowEditorSchema = openPreviewSchema.extend({
|
||||
kind: openPreviewNoPipelineKind,
|
||||
mode: z
|
||||
.enum(['edit', 'view'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Which side to show. "edit" opens the flow editor and works for flows only: the only editor you can open in this workspace. Pass "view" for a script or app, the deployed page with its run form, triggers and past runs.'
|
||||
)
|
||||
})
|
||||
|
||||
const OPEN_PREVIEW_DESCRIPTION =
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
|
||||
@@ -1407,7 +1428,14 @@ const buildGlobalSystemPrompt = (
|
||||
// Each `can*` mirrors the capability the matching tools declare in `requires`, so a
|
||||
// rule cannot outlive the tool it describes. An unresolved profile keeps every block.
|
||||
const canWriteDraft = !access || access.has('write_draft')
|
||||
const canWriteFlowDraft = !access || access.has('write_flow_draft')
|
||||
// A builder operator: flow drafts and nothing else. Blocks about drafting in general
|
||||
// follow `canWriteAnyDraft`; blocks naming scripts, apps or the other kinds keep `canWriteDraft`.
|
||||
const flowsOnly = canWriteFlowDraft && !canWriteDraft
|
||||
const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft
|
||||
const canRunPreview = !access || access.has('run_preview')
|
||||
const canManageSchedules = !access || access.has('manage_schedules')
|
||||
const canManageTriggers = !access || access.has('manage_triggers')
|
||||
// The deploy tools take their kind as an argument, so `deploy` gates only the folder text.
|
||||
const canCreateFolder = !access || access.has('deploy')
|
||||
// Each gated block carries its own leading newline, so dropping one leaves no blank
|
||||
@@ -1448,9 +1476,12 @@ The current user's workspace username is "${username}".${instanceLine}
|
||||
${
|
||||
canWriteDraft
|
||||
? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.'
|
||||
: "Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit scripts, flows, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused scripts, flows and apps outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor."
|
||||
: `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${flowsOnly ? '' : 'flows, '}scripts, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${flowsOnly ? 'scripts and apps' : 'scripts, flows and apps'} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when(
|
||||
flowsOnly,
|
||||
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.'
|
||||
)}`
|
||||
}${when(
|
||||
canWriteDraft,
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
|
||||
Path conventions:
|
||||
@@ -1463,25 +1494,35 @@ Path conventions:
|
||||
'; create one with `create_folder` only when the user explicitly asks for a new folder'
|
||||
)}.${folderGuidanceBlock}`
|
||||
)}${when(
|
||||
canCreateFolder && !canWriteDraft,
|
||||
canCreateFolder && !canWriteAnyDraft,
|
||||
'\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.'
|
||||
)}
|
||||
|
||||
Rules:${when(
|
||||
canWriteDraft,
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.`
|
||||
)}
|
||||
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when(
|
||||
canWriteDraft,
|
||||
canWriteAnyDraft,
|
||||
`
|
||||
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.
|
||||
- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.`
|
||||
)}${when(
|
||||
!canWriteDraft,
|
||||
!canWriteAnyDraft,
|
||||
`
|
||||
- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace. You cannot create or edit one.`
|
||||
- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace — each only for the kinds its type lists. You cannot create or edit one.`
|
||||
)}${when(
|
||||
!canManageSchedules || !canManageTriggers,
|
||||
`
|
||||
- This workspace does not let this user change ${
|
||||
!canManageSchedules && !canManageTriggers
|
||||
? 'schedules or triggers'
|
||||
: !canManageSchedules
|
||||
? 'schedules'
|
||||
: 'triggers'
|
||||
}, here or in the editor, so never ask them to create or edit one: suggest asking a workspace admin.`
|
||||
)}
|
||||
- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when(
|
||||
canWriteDraft,
|
||||
@@ -1499,6 +1540,10 @@ ${pipelineBullet}`
|
||||
`
|
||||
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
|
||||
- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.`
|
||||
)}${when(
|
||||
flowsOnly && (!access || access.has('run_flow_preview')),
|
||||
`
|
||||
- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.`
|
||||
)}
|
||||
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.
|
||||
- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).
|
||||
@@ -1523,7 +1568,11 @@ ${pipelineBullet}`
|
||||
? `${when(
|
||||
!canWriteDraft,
|
||||
`
|
||||
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.`
|
||||
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${
|
||||
flowsOnly
|
||||
? 'The flow editor is the only editor you can open here: after writing or editing a flow draft, show it with open_preview(kind="flow", path, mode="edit").'
|
||||
: 'You cannot open item editors in this workspace, so it only ever opens the deployed page.'
|
||||
}`
|
||||
)}${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
@@ -1555,7 +1604,7 @@ Documentation:
|
||||
Flows:
|
||||
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
|
||||
- Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when(
|
||||
canWriteDraft,
|
||||
canWriteFlowDraft,
|
||||
`
|
||||
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.`
|
||||
)}
|
||||
@@ -2547,8 +2596,13 @@ export function getSessionContextPromptSection(
|
||||
// Concatenated onto an already capability-gated prompt, so it has to honour the same
|
||||
// profile rather than assume the gating happened upstream.
|
||||
const canDeploy = !access || access.has('deploy')
|
||||
const canWriteDraft = !access || access.has('write_draft')
|
||||
const canRunPreview = !access || access.has('run_preview')
|
||||
const canWriteDraft = !access || access.has('write_draft') || access.has('write_flow_draft')
|
||||
const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview')
|
||||
// The kinds no deploy rule reaches, unless the workspace withdrew them from this user.
|
||||
const unruledKinds = [
|
||||
(!access || access.has('manage_schedules')) && 'schedules',
|
||||
(!access || access.has('manage_triggers')) && 'triggers'
|
||||
].filter((k): k is string => !!k)
|
||||
const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys']
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
@@ -2587,7 +2641,13 @@ export function getSessionContextPromptSection(
|
||||
// promotes the rest instead. The rules gate deletes too, so it is owed to every such profile.
|
||||
if (!canDeploy) {
|
||||
lines.push(
|
||||
"- This workspace refuses direct deployment for this user, except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly."
|
||||
`- This workspace refuses direct deployment for this user${
|
||||
unruledKinds.length === 2
|
||||
? ', except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on'
|
||||
: unruledKinds.length === 1
|
||||
? `, except for ${unruledKinds[0]} — the only kind deploy_workspace_item and delete_workspace_item can still act on`
|
||||
: ''
|
||||
}. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly.`
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
@@ -3431,9 +3491,14 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
// Every subject is authoring guidance written around the draft tools by name, and a
|
||||
// tool result is the one place the toolset filter cannot reach.
|
||||
const access = (ctx.helpers as GlobalToolHelpers | undefined)?.access
|
||||
if (access && !access.has('write_draft')) {
|
||||
const message =
|
||||
'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.'
|
||||
const canAuthor =
|
||||
!access ||
|
||||
access.has('write_draft') ||
|
||||
(parsed.subject === 'flow' && access.has('write_flow_draft'))
|
||||
if (!canAuthor) {
|
||||
const message = access?.has('write_flow_draft')
|
||||
? 'This session can only compose flows, so flow is the one subject with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.'
|
||||
: 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.'
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' })
|
||||
return message
|
||||
}
|
||||
@@ -3781,7 +3846,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
requires: WRITE_DRAFT,
|
||||
requires: WRITE_FLOW_DRAFT,
|
||||
def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'),
|
||||
showDetails: true,
|
||||
streamArguments: true,
|
||||
@@ -3915,7 +3980,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
requires: WRITE_DRAFT,
|
||||
requires: WRITE_FLOW_DRAFT,
|
||||
def: createToolDef(
|
||||
patchFlowJsonSchema,
|
||||
'patch_flow_json',
|
||||
@@ -3965,7 +4030,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
requires: RUN_PREVIEW,
|
||||
requires: RUN_FLOW_PREVIEW,
|
||||
def: testRunFlowToolDef,
|
||||
fn: async (ctx) => {
|
||||
const parsed = testRunFlowSchema.parse(ctx.args)
|
||||
@@ -4145,16 +4210,17 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
// turns on the kind. Each kind's handler, reached through `deployDraft`'s switch:
|
||||
requires: NONE,
|
||||
kindRequires: {
|
||||
// scripts.rs `create_script_internal`, flows.rs `create_flow`/`update_flow` and
|
||||
// apps.rs `create_app_raw`/`update_app_raw` refuse operators; all run the rules.
|
||||
// scripts.rs `create_script_internal` and apps.rs `create_app_raw`/`update_app_raw`
|
||||
// refuse operators, flows.rs `create_flow`/`update_flow` those without builder
|
||||
// rights; all run the rules.
|
||||
script: ['deploy', 'manage_code'],
|
||||
flow: ['deploy', 'manage_code'],
|
||||
flow: ['deploy', 'manage_flows'],
|
||||
app: ['deploy', 'manage_code'],
|
||||
resource: DEPLOY,
|
||||
variable: DEPLOY,
|
||||
// The schedule and trigger handlers check neither.
|
||||
schedule: NONE,
|
||||
trigger: NONE
|
||||
// No deploy rules here, but `gate_operator_writes` on their routers.
|
||||
schedule: ['manage_schedules'],
|
||||
trigger: ['manage_triggers']
|
||||
} satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>,
|
||||
def: createToolDef(
|
||||
deployWorkspaceItemSchema,
|
||||
@@ -4172,8 +4238,17 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
// Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies.
|
||||
requires: WRITE_DRAFT,
|
||||
// Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies per kind.
|
||||
requires: NONE,
|
||||
kindRequires: {
|
||||
script: WRITE_DRAFT,
|
||||
flow: WRITE_FLOW_DRAFT,
|
||||
app: WRITE_DRAFT,
|
||||
resource: WRITE_DRAFT,
|
||||
variable: WRITE_DRAFT,
|
||||
schedule: WRITE_DRAFT,
|
||||
trigger: WRITE_DRAFT
|
||||
} satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>,
|
||||
def: createToolDef(
|
||||
rebaseDraftSchema,
|
||||
'rebase_draft',
|
||||
@@ -4218,13 +4293,14 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
kindRequires: {
|
||||
// scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives.
|
||||
script: ['admin'],
|
||||
// flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators.
|
||||
flow: ['deploy', 'manage_code'],
|
||||
// apps.rs `delete_app` refuses operators, flows.rs `delete_flow_by_path` those
|
||||
// without builder rights.
|
||||
flow: ['deploy', 'manage_flows'],
|
||||
app: ['deploy', 'manage_code'],
|
||||
resource: DEPLOY,
|
||||
variable: DEPLOY,
|
||||
schedule: NONE,
|
||||
trigger: NONE
|
||||
schedule: ['manage_schedules'],
|
||||
trigger: ['manage_triggers']
|
||||
} satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>,
|
||||
def: createToolDef(
|
||||
deleteWorkspaceItemSchema,
|
||||
@@ -4495,17 +4571,20 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
{
|
||||
requires: NONE,
|
||||
def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION),
|
||||
// Withhold the editor side from a session that cannot write drafts, so the model
|
||||
// never offers the user a panel it would not get.
|
||||
// Withhold the editors a session cannot save, so the model never offers the user a
|
||||
// panel it would not get.
|
||||
schemaFor: async (helpers) => {
|
||||
const access = (helpers as GlobalToolHelpers | undefined)?.access
|
||||
return access && !access.has('write_draft')
|
||||
? createToolDef(
|
||||
if (canOpenEditor(access, 'script')) {
|
||||
return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
|
||||
}
|
||||
return canOpenEditor(access, 'flow')
|
||||
? createToolDef(openPreviewFlowEditorSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
|
||||
: createToolDef(
|
||||
openPreviewViewOnlySchema,
|
||||
'open_preview',
|
||||
OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION
|
||||
)
|
||||
: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
|
||||
},
|
||||
fn: async (ctx) => {
|
||||
const parsed = openPreviewSchema.parse(ctx.args)
|
||||
@@ -4841,22 +4920,25 @@ async function openSessionPreview(
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
// A pipeline has only an editor, and its own capability gates already cover it.
|
||||
// A pipeline has only an editor, so a session that cannot save its nodes has nothing to open.
|
||||
if (args.kind === 'pipeline') {
|
||||
if (!canOpenEditor(access, 'pipeline')) {
|
||||
return 'Error: you cannot open the pipeline editor in this workspace: this user cannot save drafts. Show its scripts with open_preview mode "view" instead.'
|
||||
}
|
||||
return await openPreviewHandler({ ...args, mode: undefined, sessionId })
|
||||
}
|
||||
// Resolved before the gates, not after: the handler reads a missing mode as the editor, so
|
||||
// gating on `args.mode === 'edit'` would wave through every call that simply omitted it —
|
||||
// which is what a model whose schema only offers 'view' does.
|
||||
const mode: 'edit' | 'view' = args.mode ?? 'edit'
|
||||
// The advertised schema already withholds 'edit' from a session that cannot write
|
||||
// drafts; re-check so a model asking outside the enum can't act outside it either.
|
||||
if (mode === 'edit' && access && !access.has('write_draft')) {
|
||||
// The advertised schema already narrows 'edit' to what this session can save; re-check so
|
||||
// a model asking outside the enum can't act outside it either.
|
||||
if (mode === 'edit' && !canOpenEditor(access, args.kind)) {
|
||||
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
|
||||
return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.`
|
||||
return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'flow' ? 'flow' : 'item'} editors in this workspace.`
|
||||
}
|
||||
// `write_draft` is a workspace capability and says nothing about this path, so an
|
||||
// edit that survived it is still checked against the item's own permissions.
|
||||
// The draft capability is workspace-wide and says nothing about this path, so an edit
|
||||
// that survived it is still checked against the item's own permissions.
|
||||
if (mode === 'edit' && workspace) {
|
||||
const verdict = await canEditItemPath(workspace, args.kind, args.path)
|
||||
if (verdict !== 'allowed') {
|
||||
@@ -4884,10 +4966,12 @@ async function canEditItemPath(
|
||||
const role = await roleForWorkspace(workspace)
|
||||
if (role.kind === 'not_a_member') return 'denied'
|
||||
if (role.kind !== 'resolved' || !role.user) return 'unverified'
|
||||
if (role.user.operator) return 'denied'
|
||||
const user = role.user
|
||||
const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
|
||||
if (!roleCanDraft(kind, user, settings)) return 'denied'
|
||||
// Folder and ownership rules answer most calls without a request. An item's own
|
||||
// `extra_perms` can only widen them, so the item is fetched only to overturn a denial.
|
||||
if (canWrite(path, {}, role.user)) return 'allowed'
|
||||
if (canDraftItem(kind, path, {}, user, settings)) return 'allowed'
|
||||
try {
|
||||
const extraPerms =
|
||||
kind === 'script'
|
||||
@@ -4895,7 +4979,7 @@ async function canEditItemPath(
|
||||
: kind === 'flow'
|
||||
? (await FlowService.getFlowByPath({ workspace, path })).extra_perms
|
||||
: (await AppService.getAppByPath({ workspace, path })).extra_perms
|
||||
return canWrite(path, extraPerms ?? {}, role.user) ? 'allowed' : 'denied'
|
||||
return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied'
|
||||
} catch (e) {
|
||||
// Nothing deployed means no sharing to widen the folder rules with.
|
||||
return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified'
|
||||
|
||||
@@ -25,14 +25,35 @@ export function isGlobalAiEnabled(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATOR_SESSIONS_KEY = 'wm_operator_ai_sessions'
|
||||
|
||||
/**
|
||||
* Operators get AI sessions only in a browser that opted in, until the operator experience
|
||||
* has been tested; everywhere else they keep the docked chat and `/sessions` refuses them.
|
||||
* Set with `localStorage.setItem('wm_operator_ai_sessions', '1')` and reload. To ship it,
|
||||
* delete this and `sessionsAllowedFor`, and drop the `isOperator` argument from every caller.
|
||||
*/
|
||||
function operatorSessionsEnabled(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false
|
||||
try {
|
||||
return localStorage.getItem(OPERATOR_SESSIONS_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this user's role may use AI sessions at all. */
|
||||
export function sessionsAllowedFor(isOperator: boolean | undefined): boolean {
|
||||
return !isOperator || operatorSessionsEnabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an AI entry point hands off to a session instead of driving the docked
|
||||
* chat. Deliberately the same condition as the root layout's `disableAi`, so a
|
||||
* caller falling back on `false` always has a mounted pane to fall back to.
|
||||
* Operators keep that pane (`/sessions` refuses them) until the operator chat ships.
|
||||
*/
|
||||
export function prefersSessionHandoff(isOperator: boolean | undefined): boolean {
|
||||
return isGlobalAiEnabled() && !isOperator
|
||||
return isGlobalAiEnabled() && sessionsAllowedFor(isOperator)
|
||||
}
|
||||
|
||||
/** Clear the opt-out, then hard-reload so every gated site re-reads it. */
|
||||
|
||||
@@ -8,7 +8,7 @@ const { whoami, deployRules } = vi.hoisted(() => ({
|
||||
vi.mock('$lib/gen', () => ({ UserService: { whoami } }))
|
||||
vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules }))
|
||||
|
||||
import { resolveSessionAccess } from './sessionAccess'
|
||||
import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess'
|
||||
import { clearWorkspaceRoleCache } from '$lib/user'
|
||||
|
||||
type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean }
|
||||
@@ -45,20 +45,30 @@ describe('resolveSessionAccess', () => {
|
||||
|
||||
it('gives a developer every capability but admin', async () => {
|
||||
const caps = await capabilitiesFor({})
|
||||
expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft'])
|
||||
expect([...caps].sort()).toEqual([
|
||||
'deploy',
|
||||
'manage_code',
|
||||
'manage_flows',
|
||||
'manage_schedules',
|
||||
'manage_triggers',
|
||||
'run_flow_preview',
|
||||
'run_preview',
|
||||
'write_draft',
|
||||
'write_flow_draft'
|
||||
])
|
||||
})
|
||||
|
||||
// The folder, resource and variable handlers run the rules but have no operator check,
|
||||
// so withholding `deploy` from an operator would be stricter than the server.
|
||||
it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => {
|
||||
const caps = await capabilitiesFor({ operator: true })
|
||||
expect([...caps]).toEqual(['deploy'])
|
||||
expect([...caps].sort()).toEqual(['deploy', 'manage_schedules', 'manage_triggers'])
|
||||
})
|
||||
|
||||
it('takes deploy from the rules alone, so a rule blocks an operator too', async () => {
|
||||
deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' })
|
||||
const caps = await capabilitiesFor({ operator: true })
|
||||
expect([...caps]).toEqual([])
|
||||
expect([...caps].sort()).toEqual(['manage_schedules', 'manage_triggers'])
|
||||
})
|
||||
|
||||
// Both spellings of `authed.is_admin`, which the draft path honours and the handlers
|
||||
@@ -67,7 +77,14 @@ describe('resolveSessionAccess', () => {
|
||||
'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)',
|
||||
async (role) => {
|
||||
const caps = await capabilitiesFor({ ...role, operator: true })
|
||||
expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft'])
|
||||
expect([...caps].sort()).toEqual([
|
||||
'admin',
|
||||
'deploy',
|
||||
'manage_schedules',
|
||||
'manage_triggers',
|
||||
'write_draft',
|
||||
'write_flow_draft'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -82,6 +99,37 @@ describe('resolveSessionAccess', () => {
|
||||
expect(caps.has('write_draft')).toBe(true)
|
||||
})
|
||||
|
||||
// flows.rs, drafts.rs and run_preview_flow all let a builder through for flows, and
|
||||
// nothing else moves: scripts, apps and their previews stay refused.
|
||||
it("gives a builder operator the flow capabilities and nothing a script's handler refuses", () => {
|
||||
const caps = capabilitiesForRole({
|
||||
isAdmin: false,
|
||||
operator: true,
|
||||
deployRulesPass: true,
|
||||
operatorSettings: { builder_flows: true }
|
||||
})
|
||||
expect([...caps].sort()).toEqual([
|
||||
'deploy',
|
||||
'manage_flows',
|
||||
'manage_schedules',
|
||||
'manage_triggers',
|
||||
'run_flow_preview',
|
||||
'write_flow_draft'
|
||||
])
|
||||
})
|
||||
|
||||
// A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it.
|
||||
it('withholds schedule and trigger writes only where the workspace set them to false', () => {
|
||||
const caps = capabilitiesForRole({
|
||||
isAdmin: false,
|
||||
operator: true,
|
||||
deployRulesPass: true,
|
||||
operatorSettings: { manage_schedules: false }
|
||||
})
|
||||
expect(caps.has('manage_schedules')).toBe(false)
|
||||
expect(caps.has('manage_triggers')).toBe(true)
|
||||
})
|
||||
|
||||
it('grants everything when the role cannot be resolved', async () => {
|
||||
whoami.mockRejectedValueOnce(new Error('network'))
|
||||
const access = await resolveSessionAccess('ws')
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import type { OperatorSettings } from '$lib/gen'
|
||||
import { getWorkspaceRole } from '$lib/user'
|
||||
import { roleCanAuthor, roleCanDraft } from '$lib/editRights'
|
||||
import { checkDeployRules } from '$lib/utils_workspace_deploy'
|
||||
import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
|
||||
|
||||
type OperatorRights = Pick<
|
||||
NonNullable<OperatorSettings>,
|
||||
'builder_flows' | 'manage_schedules' | 'manage_triggers'
|
||||
>
|
||||
|
||||
const ALL_CAPABILITIES: SessionCapability[] = [
|
||||
'write_draft',
|
||||
'write_flow_draft',
|
||||
'run_preview',
|
||||
'run_flow_preview',
|
||||
'deploy',
|
||||
'manage_code',
|
||||
'manage_flows',
|
||||
'manage_schedules',
|
||||
'manage_triggers',
|
||||
'admin'
|
||||
]
|
||||
|
||||
@@ -25,19 +39,35 @@ export function capabilitiesForRole(role: {
|
||||
isAdmin: boolean
|
||||
operator: boolean
|
||||
deployRulesPass: boolean
|
||||
/** The workspace's `operator_settings`, null for a non-operator. */
|
||||
operatorSettings?: OperatorRights | null
|
||||
}): SessionAccess {
|
||||
const capabilities = new Set<SessionCapability>()
|
||||
// Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path`
|
||||
// returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs
|
||||
// `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no
|
||||
// admin escape — and on the session path that flag is never cleared for an admin.
|
||||
if (role.isAdmin || !role.operator) {
|
||||
const user = { operator: role.operator, is_admin: role.isAdmin, is_super_admin: false }
|
||||
const authorsCode = roleCanAuthor('script', user, role.operatorSettings)
|
||||
const authorsFlows = roleCanAuthor('flow', user, role.operatorSettings)
|
||||
// Drafts take the admin precedence `roleCanDraft` encodes; previews and the item handlers
|
||||
// do not — and on the session path the operator flag is never cleared for an admin.
|
||||
if (roleCanDraft('script', user, role.operatorSettings)) {
|
||||
capabilities.add('write_draft')
|
||||
}
|
||||
if (!role.operator) {
|
||||
if (roleCanDraft('flow', user, role.operatorSettings)) {
|
||||
capabilities.add('write_flow_draft')
|
||||
}
|
||||
if (authorsCode) {
|
||||
capabilities.add('run_preview')
|
||||
capabilities.add('manage_code')
|
||||
}
|
||||
if (authorsFlows) {
|
||||
capabilities.add('run_flow_preview')
|
||||
capabilities.add('manage_flows')
|
||||
}
|
||||
if (roleCanAuthor('schedule', user, role.operatorSettings)) {
|
||||
capabilities.add('manage_schedules')
|
||||
}
|
||||
if (roleCanAuthor('trigger', user, role.operatorSettings)) {
|
||||
capabilities.add('manage_triggers')
|
||||
}
|
||||
// No operator term: the rules are their own gate, and the handlers that also refuse
|
||||
// operators say so through `manage_code`. Admins bypass the rules inside the check.
|
||||
if (role.deployRulesPass) {
|
||||
@@ -63,6 +93,7 @@ export async function resolveSessionAccess(workspace: string): Promise<SessionAc
|
||||
return capabilitiesForRole({
|
||||
isAdmin: !!me.is_admin || !!me.is_super_admin,
|
||||
operator: !!me.operator,
|
||||
deployRulesPass: (await checkDeployRules(workspace, me)).ok
|
||||
deployRulesPass: (await checkDeployRules(workspace, me)).ok,
|
||||
operatorSettings: get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
|
||||
})
|
||||
}
|
||||
|
||||
@@ -75,10 +75,22 @@ const REACHABLE_PROFILES = [
|
||||
[false, true].flatMap((isAdmin) =>
|
||||
[false, true].flatMap((operator) =>
|
||||
// `checkDeployRules` bypasses on admin, so an admin has only the one outcome.
|
||||
(isAdmin ? [true] : [false, true]).map((deployRulesPass) => {
|
||||
const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass })
|
||||
return [[...access].sort().join(',') || 'none', access] as const
|
||||
})
|
||||
(isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) =>
|
||||
// The operator rights a workspace can set: none, the builder right, both locks.
|
||||
[
|
||||
undefined,
|
||||
{ builder_flows: true },
|
||||
{ manage_schedules: false, manage_triggers: false }
|
||||
].map((operatorSettings) => {
|
||||
const access = capabilitiesForRole({
|
||||
isAdmin,
|
||||
operator,
|
||||
deployRulesPass,
|
||||
operatorSettings
|
||||
})
|
||||
return [[...access].sort().join(',') || 'none', access] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
).entries()
|
||||
@@ -122,13 +134,24 @@ describe('session tool policies', () => {
|
||||
const kindsOf = (name: string, access: SessionAccess) =>
|
||||
(shipped(name, access).def.function.parameters as any).properties.type.enum
|
||||
|
||||
const developerCaps: SessionCapability[] = [
|
||||
'write_draft',
|
||||
'write_flow_draft',
|
||||
'run_preview',
|
||||
'run_flow_preview',
|
||||
'manage_code',
|
||||
'manage_flows',
|
||||
'manage_schedules',
|
||||
'manage_triggers'
|
||||
]
|
||||
|
||||
// A developer a protection rule refuses keeps the two kinds no rule reaches.
|
||||
const refused = accessWith(['write_draft', 'run_preview', 'manage_code'])
|
||||
const refused = accessWith(developerCaps)
|
||||
expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger'])
|
||||
expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger'])
|
||||
|
||||
// An operator where no rule applies: only the code handlers refuse them.
|
||||
const operator = accessWith(['deploy'])
|
||||
const operator = accessWith(['deploy', 'manage_schedules', 'manage_triggers'])
|
||||
expect(kindsOf('deploy_workspace_item', operator)).toEqual([
|
||||
'schedule',
|
||||
'trigger',
|
||||
@@ -136,8 +159,17 @@ describe('session tool policies', () => {
|
||||
'variable'
|
||||
])
|
||||
|
||||
// A workspace that withdrew schedule writes from operators: `gate_operator_writes`.
|
||||
const locked = accessWith(['deploy', 'manage_triggers'])
|
||||
expect(kindsOf('delete_workspace_item', locked)).toEqual(['trigger', 'resource', 'variable'])
|
||||
|
||||
// A builder operator deploys flows too, and still no script or app.
|
||||
const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers'])
|
||||
expect(kindsOf('deploy_workspace_item', builder)).toContain('flow')
|
||||
expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script')
|
||||
|
||||
// Deleting a script is admin-only, where deploying one is not.
|
||||
const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy'])
|
||||
const developer = accessWith([...developerCaps, 'deploy'])
|
||||
expect(kindsOf('deploy_workspace_item', developer)).toContain('script')
|
||||
expect(kindsOf('delete_workspace_item', developer)).not.toContain('script')
|
||||
|
||||
|
||||
@@ -12,14 +12,27 @@ export type SessionCapability =
|
||||
/** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and
|
||||
* triggers included, though an operator may create those directly. */
|
||||
| 'write_draft'
|
||||
/** Passes `require_can_write_path` for a flow draft, which an operator also passes where the
|
||||
* workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */
|
||||
| 'write_flow_draft'
|
||||
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
|
||||
| 'run_preview'
|
||||
/** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an
|
||||
* operator with `builder_flows`. */
|
||||
| 'run_flow_preview'
|
||||
/** Passes `check_deploy_rules`. */
|
||||
| 'deploy'
|
||||
/** Passes the operator refusal the script, flow and app handlers make before creating or
|
||||
/** Passes the operator refusal the script and app handlers make before creating or
|
||||
* deleting one. The same condition as `run_preview` today, but another handler family's
|
||||
* check, so each follows its own gate if the two ever diverge. */
|
||||
| 'manage_code'
|
||||
/** Passes the flow handlers' `check_operator_can_build_flows`. */
|
||||
| 'manage_flows'
|
||||
/** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose
|
||||
* workspace set `operator_settings.manage_schedules` to false. */
|
||||
| 'manage_schedules'
|
||||
/** The same gate on the trigger routers, read from `manage_triggers`. */
|
||||
| 'manage_triggers'
|
||||
/** Passes `require_admin`. */
|
||||
| 'admin'
|
||||
|
||||
@@ -36,9 +49,22 @@ export type SessionToolPolicy = readonly SessionCapability[]
|
||||
|
||||
export const NONE: SessionToolPolicy = []
|
||||
export const WRITE_DRAFT: SessionToolPolicy = ['write_draft']
|
||||
export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft']
|
||||
export const RUN_PREVIEW: SessionToolPolicy = ['run_preview']
|
||||
export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview']
|
||||
export const DEPLOY: SessionToolPolicy = ['deploy']
|
||||
|
||||
/** Editors a session can open. Every editor autosaves a draft, so it needs the draft
|
||||
* capability of its kind; a pipeline's nodes are script drafts. */
|
||||
export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline'
|
||||
|
||||
/** Whether `access` can save what the `kind` editor writes. `undefined` access (not
|
||||
* resolved yet, or not a session) answers true, as the toolset filter does. */
|
||||
export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean {
|
||||
if (!access) return true
|
||||
return access.has(kind === 'flow' ? 'write_flow_draft' : 'write_draft')
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool that can reach an AI session's toolset. `requires` is mandatory, and declared
|
||||
* beside the `fn` whose call is what proves it right.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
import { userWorkspaces, type UserExt } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
/**
|
||||
* Skills are resources of this type: a file resource (`format_extension = 'md'`)
|
||||
@@ -53,9 +54,10 @@ export type SkillListing = { skills: SkillResource[]; truncated: boolean }
|
||||
* `user` decides which rows the drawer offers to edit rather than only view; pass
|
||||
* the account the workspace is being browsed as. Ownership is mostly implicit in
|
||||
* the path (`u/<me>/…`, a folder the user owns), which is why this goes through
|
||||
* the shared `canWrite` rather than reading `extra_perms` alone. */
|
||||
* the shared `canEditItem` rather than reading `extra_perms` alone. */
|
||||
export async function listSkillResources(workspace: string, user?: UserExt): Promise<SkillListing> {
|
||||
if (!workspace) return { skills: [], truncated: false }
|
||||
const operatorSettings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
|
||||
const rows: SkillResource[] = []
|
||||
for (let page = 1; page <= MAX_SKILLS_PAGES; page++) {
|
||||
const resources = await ResourceService.listResource({
|
||||
@@ -70,7 +72,7 @@ export async function listSkillResources(workspace: string, user?: UserExt): Pro
|
||||
name: skillNameFromPath(r.path),
|
||||
description: r.description ?? '',
|
||||
editedAt: r.edited_at,
|
||||
canWrite: canWrite(r.path, r.extra_perms ?? {}, user)
|
||||
canWrite: canEditItem('resource', r.path, r.extra_perms, user, operatorSettings)
|
||||
}))
|
||||
)
|
||||
if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false }
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
@@ -131,7 +131,7 @@
|
||||
// A ⌘-click opens these in a new tab, which only the query names the workspace for.
|
||||
const operatingHref = useOperatingWorkspaceHref()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
// Derived, not assigned during the load: in a fork the acting user is looked up
|
||||
@@ -345,9 +345,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Operators with the builder right author flows out of deployed runnables; every other
|
||||
// operator is read-only here.
|
||||
let canAuthorFlow = $derived(!actingUser?.operator || $operatorBuilderFlows)
|
||||
let canAuthorFlow = $derived(editRights.roleCanAuthor('flow'))
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
useOperatingUser,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
@@ -173,6 +174,9 @@
|
||||
script.workspace_id == workspace &&
|
||||
canWrite(script.path, script.extra_perms!, actingUser)
|
||||
)
|
||||
const editRights = useEditRights()
|
||||
// The edit actions also need a role that authors scripts, which no operator has.
|
||||
const canEdit = $derived(can_write && editRights.roleCanAuthor('script'))
|
||||
let deploymentInProgress = $state(false)
|
||||
let expandedModuleLocks: Record<string, boolean> = $state({})
|
||||
let expandedModuleCode: Record<string, boolean> = $state({})
|
||||
@@ -576,7 +580,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
if (!script || actingUser?.operator || !can_write) {
|
||||
if (!script || !canEdit) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
@@ -1079,7 +1083,7 @@
|
||||
{#if Object.keys(script?.schema?.properties ?? {}).length > 0}
|
||||
<AIFormAssistant
|
||||
instructions={promptForAi}
|
||||
onEditInstructions={can_write && !actingUser?.operator
|
||||
onEditInstructions={canEdit
|
||||
? () => onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`)
|
||||
: undefined}
|
||||
runnableType="script"
|
||||
|
||||
@@ -3,8 +3,8 @@ import { get } from 'svelte/store'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { canEditItem, roleCanAuthor } from '$lib/editRights'
|
||||
import { userStore, userWorkspaces } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -339,10 +339,12 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
// Same rule the generic resource editor applies. The backend refuses the write
|
||||
// either way, but without this the editor would invite edits it cannot save and
|
||||
// autosave a draft on every keystroke against a resource the reader cannot deploy.
|
||||
canWriteResource = canWrite(
|
||||
canWriteResource = canEditItem(
|
||||
'agent',
|
||||
r.path,
|
||||
r.extra_perms ?? {},
|
||||
user ?? get(userStore) ?? undefined
|
||||
r.extra_perms,
|
||||
user ?? get(userStore) ?? undefined,
|
||||
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
|
||||
)
|
||||
// An agent that exists only as a draft has no deployed value to compare against or
|
||||
// fall back to, and the response's is a synthetic echo of the draft. Leaving the
|
||||
@@ -382,7 +384,11 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
if (loadedFor !== key) return
|
||||
noDeployed = true
|
||||
deployed = undefined
|
||||
canWriteResource = true
|
||||
canWriteResource = roleCanAuthor(
|
||||
'agent',
|
||||
get(userStore) ?? undefined,
|
||||
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
|
||||
)
|
||||
state = {
|
||||
path: name,
|
||||
description: '',
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
|
||||
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { userStore, userWorkspaces } from '$lib/stores'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
|
||||
import {
|
||||
AGENT_BRAIN_KEYS,
|
||||
@@ -235,8 +235,13 @@
|
||||
let unavailable = $derived(linkedInfo?.unavailable ?? false)
|
||||
let canEditAgent = $derived(
|
||||
!!agent &&
|
||||
!$userStore?.operator &&
|
||||
canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined)
|
||||
canEditItem(
|
||||
'agent',
|
||||
agent,
|
||||
linkedInfo?.extraPerms,
|
||||
$userStore ?? undefined,
|
||||
$userWorkspaces.find((w) => w.id === ws)?.operator_settings
|
||||
)
|
||||
)
|
||||
// The hint flips on the first keystroke in the agent editor, so the badge does not wait for the
|
||||
// debounced autosave and the refetch behind it; the fetched answer covers a draft written
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
type FlowModule,
|
||||
type FlowValue,
|
||||
type InputTransform,
|
||||
type OperatorSettings,
|
||||
type Resource
|
||||
} from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { dfs } from './dfs'
|
||||
import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils'
|
||||
@@ -122,11 +123,15 @@ export interface LinkedAgentDraft {
|
||||
extraPerms: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Whether `user` may write this agent's resource. Split from the load so that resolving the
|
||||
* drafts of a whole flow costs no `whoami` — only the deploy dialog needs the answer, and it
|
||||
* looks the user up once for every agent it lists. */
|
||||
export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | undefined): boolean {
|
||||
return canWrite(draft.path, draft.extraPerms, user)
|
||||
/** Whether `user` may write this agent's resource, in a workspace with these `operator_settings`.
|
||||
* Split from the load so that resolving the drafts of a whole flow costs no `whoami` — only the
|
||||
* deploy dialog needs the answer, and it looks the user up once for every agent it lists. */
|
||||
export function agentDraftCanWrite(
|
||||
draft: LinkedAgentDraft,
|
||||
user: UserExt | undefined,
|
||||
operatorSettings: OperatorSettings | undefined
|
||||
): boolean {
|
||||
return canEditItem('agent', draft.path, draft.extraPerms, user, operatorSettings)
|
||||
}
|
||||
|
||||
/** A link that cannot resolve for the user rather than because something went wrong: the agent was
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { createDropdownMenu, melt } from '@melt-ui/svelte'
|
||||
import YAML from 'yaml'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
/** Replaces the default `New` button, e.g. with an inline text link. */
|
||||
@@ -314,13 +314,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A builder composes runnables that already exist, so only flows are offered: everything else
|
||||
// here writes code, which the backend refuses from an operator.
|
||||
// Only the kinds this user's role authors: a builder operator gets flows alone.
|
||||
// Derived, not computed once: switching workspace only sets `workspaceStore`, it does not
|
||||
// remount this component, so a snapshot would keep the previous workspace's kinds.
|
||||
const options: Option[] = $derived(
|
||||
$operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions
|
||||
)
|
||||
const options: Option[] = $derived(allOptions.filter((o) => editRights.roleCanAuthor(o.key)))
|
||||
|
||||
// the doc panel only shows while an option is hovered or focused, so the menu opens compact
|
||||
let activeKey: string | undefined = $state(undefined)
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday'
|
||||
}
|
||||
]
|
||||
|
||||
/** An operator's session can run what is deployed, read runs and call MCP servers, but
|
||||
* cannot author scripts or apps. It composes flows only where the workspace grants
|
||||
* `operator_settings.builder_flows`, so none of these asks it to build anything. */
|
||||
export const homeAIOperatorExamples: { label: string; prompt: string }[] = [
|
||||
{
|
||||
label: 'Run a flow',
|
||||
prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday'
|
||||
},
|
||||
{
|
||||
label: 'Rerun failed jobs',
|
||||
prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out'
|
||||
},
|
||||
{
|
||||
label: 'Summarize GitHub issues',
|
||||
prompt: "Read this week's open GitHub issues and summarize the ones that mention billing"
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -61,17 +79,18 @@
|
||||
const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB)
|
||||
|
||||
let value = $state('')
|
||||
// The stock examples, unless the invite that brought this person here wrote prompts for
|
||||
// The stock examples for the reader's role, unless the invite that brought this person here wrote prompts for
|
||||
// them — those replace the set outright rather than joining it, since a prompt written
|
||||
// for someone's own stack next to "Ban Discord users" reads as the generic one.
|
||||
let examples = $state(homeAIExamples)
|
||||
let invitePrompts: { label: string; prompt: string }[] | undefined = $state(undefined)
|
||||
void onboardingProfile().then((p) => {
|
||||
if (!p?.starter_prompts?.length) return
|
||||
examples = p.starter_prompts
|
||||
invitePrompts = p.starter_prompts
|
||||
promptIndex = 0
|
||||
placeholder = examples[0].prompt
|
||||
})
|
||||
let placeholder = $state(homeAIExamples[0].prompt)
|
||||
let examples = $derived(
|
||||
invitePrompts ?? ($userStore?.operator ? homeAIOperatorExamples : homeAIExamples)
|
||||
)
|
||||
let placeholderVisible = $state(true)
|
||||
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
@@ -185,7 +204,8 @@
|
||||
// The index lives outside the effect so re-showing the composer resumes the rotation from the
|
||||
// prompt currently displayed rather than restarting it.
|
||||
const reducedMotion = useReducedMotion()
|
||||
let promptIndex = 0
|
||||
let promptIndex = $state(0)
|
||||
let placeholder = $derived(prompts[promptIndex % prompts.length])
|
||||
$effect(() => {
|
||||
if (!showComposer || collapsed || reducedMotion.val) return
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
@@ -194,7 +214,6 @@
|
||||
placeholderVisible = false
|
||||
timer = setTimeout(() => {
|
||||
promptIndex = (promptIndex + 1) % prompts.length
|
||||
placeholder = prompts[promptIndex]
|
||||
placeholderVisible = true
|
||||
timer = setTimeout(next, CYCLE_MS)
|
||||
}, FADE_MS)
|
||||
@@ -220,7 +239,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<p class="text-center font-regular text-3xl">Build with AI</p>
|
||||
<p class="text-center font-regular text-3xl">
|
||||
{$userStore?.operator ? 'Run with AI' : 'Build with AI'}
|
||||
</p>
|
||||
<Badge color="blue" small>Beta</Badge>
|
||||
</div>
|
||||
<!-- Anchors the send button / model settings to the input, not to the whole block — the row
|
||||
@@ -235,7 +256,9 @@
|
||||
underlyingInputEl="textarea"
|
||||
inputProps={{
|
||||
rows: 4,
|
||||
'aria-label': 'Describe what you want to build',
|
||||
'aria-label': $userStore?.operator
|
||||
? 'Describe what you want to do'
|
||||
: 'Describe what you want to build',
|
||||
onkeydown: onKeydown
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { resource } from 'runed'
|
||||
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
|
||||
import { disableHubStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import { useEditRights, useOperatorBuilderFlows } from '$lib/operatorWriteRights'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ArrowDownUp,
|
||||
@@ -50,7 +50,7 @@
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from './FlowIcon.svelte'
|
||||
import { canWrite, getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
|
||||
import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
@@ -65,6 +65,7 @@
|
||||
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
|
||||
|
||||
const operatorBuilderFlows = useOperatorBuilderFlows()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
subtab?: 'flow' | 'script' | 'app' | 'agent'
|
||||
@@ -357,11 +358,8 @@
|
||||
const base = {
|
||||
...it,
|
||||
canWrite:
|
||||
canWrite(it.path, (it.extra_perms ?? {}) as any, $userStore) &&
|
||||
(it.type === 'script' || it.workspace_id == $workspaceStore) &&
|
||||
// The builder right covers flows only; a script or an app is still off limits, so
|
||||
// the row must not offer edit or delete for those.
|
||||
(!$userStore?.operator || (it.type === 'flow' && $operatorBuilderFlows))
|
||||
editRights.canEditItem(it.type, it.path, (it.extra_perms ?? {}) as any) &&
|
||||
(it.type === 'script' || it.workspace_id == $workspaceStore)
|
||||
}
|
||||
// combinedItems reads a script's time from `created_at`; the endpoint's
|
||||
// unified `edited_at` holds exactly that for scripts.
|
||||
@@ -434,7 +432,7 @@
|
||||
agents = sorted.map((r, i) => ({
|
||||
...r,
|
||||
summary: r.description || undefined,
|
||||
canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator,
|
||||
canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any),
|
||||
ord: AGENT_ORD_BASE + i
|
||||
}))
|
||||
}
|
||||
@@ -1211,7 +1209,8 @@
|
||||
* whose direct-deploy protection cleared `showEditButtons` — must not be shown them.
|
||||
* Reading archived items is not a write, so it is not gated on this.
|
||||
*/
|
||||
let canCreateHere = $derived((!$userStore?.operator || $operatorBuilderFlows) && showEditButtons)
|
||||
// A flow is the one kind every author can create, the builder right included.
|
||||
let canCreateHere = $derived(editRights.roleCanAuthor('flow') && showEditButtons)
|
||||
|
||||
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
|
||||
// false until the first load resolves: a skeleton already means "loading", and the
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
import { OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen'
|
||||
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import {
|
||||
canWrite,
|
||||
classNames,
|
||||
debounce,
|
||||
emptySchema,
|
||||
@@ -96,6 +96,7 @@
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const editRights = useEditRights()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
|
||||
@@ -334,7 +335,7 @@
|
||||
const result = (await ResourceService.listResource(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms!, operatingUser.current) &&
|
||||
editRights.canEditItem('resource', x.path, x.extra_perms) &&
|
||||
$operatingWorkspace! == x.workspace_id,
|
||||
...x
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { canWrite, displayDate, getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
|
||||
import { useScheduleLock } from '$lib/operatorWriteRights'
|
||||
import { useEditRights, useScheduleLock } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
@@ -68,6 +68,7 @@
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const scheduleLock = useScheduleLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
@@ -416,7 +417,7 @@
|
||||
is_draft
|
||||
} = s}
|
||||
{@const hasDraft = getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft}
|
||||
{@const canEdit = canWrite && !$scheduleLock}
|
||||
{@const canEdit = editRights.canEditItem('schedule', path, extra_perms)}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const avg_s = jobs ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length : undefined}
|
||||
|
||||
@@ -551,7 +552,7 @@
|
||||
}}
|
||||
checked={!draft_only && enabled}
|
||||
on:change={(e) => {
|
||||
if (canWrite) {
|
||||
if (canEdit) {
|
||||
setScheduleEnabled(path, e.detail)
|
||||
} else {
|
||||
sendUserToast('not enough permission', true)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
previewLocationLabel,
|
||||
stripBase
|
||||
} from './previewRouter'
|
||||
import { useSessionPermissions } from './sessionPermissions.svelte'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
@@ -55,6 +56,9 @@
|
||||
untrack(() => runtime.manager)
|
||||
)
|
||||
|
||||
const permissions = useSessionPermissions(() => workspaceId)
|
||||
const canEdit = $derived(permissions.canOpenEditor(kind))
|
||||
|
||||
// This reads the deployed version over the API rather than from the editor cell the
|
||||
// chat mutates, so it is the one preview kind that does not self-sync (the invariant
|
||||
// `previewReload.ts` states for editors). A deploy from the editor topbar beside it
|
||||
@@ -197,11 +201,14 @@
|
||||
<div class="text-sm text-primary">Not deployed yet</div>
|
||||
<div class="text-xs text-tertiary max-w-sm">
|
||||
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
|
||||
version to view. Deploy it from the editor to see it here.
|
||||
version to view.{#if canEdit}
|
||||
Deploy it from the editor to see it here.{/if}
|
||||
</div>
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
|
||||
Back to editor
|
||||
</Button>
|
||||
{#if canEdit}
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
|
||||
Back to editor
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kind === 'script'}
|
||||
<ScriptDetail
|
||||
|
||||
@@ -49,6 +49,7 @@ section or the flat layout.
|
||||
previewModeFor,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
import { useSessionPermissions } from './sessionPermissions.svelte'
|
||||
|
||||
type Kind = WorkspaceItemKind
|
||||
type DrillPickerHandle = {
|
||||
@@ -90,6 +91,8 @@ section or the flat layout.
|
||||
|
||||
const kinds: Kind[] = ['flow', 'script', 'app']
|
||||
const effectiveWorkspace = $derived(workspaceId ?? $workspaceStore)
|
||||
const permissions = useSessionPermissions(() => effectiveWorkspace)
|
||||
const canEdit = (item: WorkspaceItem) => permissions.canEditItem(item)
|
||||
|
||||
let inner = $state<DrillPickerHandle | undefined>(undefined)
|
||||
export function focus() {
|
||||
@@ -155,20 +158,28 @@ section or the flat layout.
|
||||
// `mode` is stamped here rather than left to the tab: picking is the reader
|
||||
// saying "show me this", so it lands on the deployed page, and `navigate`
|
||||
// would otherwise keep whichever side the re-pointed tab happened to be on.
|
||||
// A draft-only item opens its editor, unless this user cannot save there.
|
||||
function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] {
|
||||
return nodes.map((n) =>
|
||||
n.type === 'leaf'
|
||||
? { ...n, data: { type: 'item', item: n.data, mode: previewModeFor(n.data) } }
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
type: 'item',
|
||||
item: n.data,
|
||||
mode: canEdit(n.data) ? previewModeFor(n.data) : 'view'
|
||||
}
|
||||
}
|
||||
: { ...n, children: tagItems(n.children) }
|
||||
)
|
||||
}
|
||||
|
||||
// Open the item's editor. Offered on every item row, including the draft-only ones
|
||||
// a plain click already opens there: a row that drops the button to say "you were
|
||||
// getting this anyway" reads as one that cannot be edited.
|
||||
// Open the item's editor. Offered on every item row this user can edit, including the
|
||||
// draft-only ones a plain click already opens there: a row that drops the button to say
|
||||
// "you were getting this anyway" reads as one that cannot be edited.
|
||||
function editAction(leaf: DrillLeaf<PreviewTarget>) {
|
||||
const target = leaf.data
|
||||
if (target.type !== 'item') return undefined
|
||||
if (target.type !== 'item' || !canEdit(target.item)) return undefined
|
||||
return {
|
||||
icon: Pen,
|
||||
label: 'Edit',
|
||||
@@ -205,7 +216,9 @@ section or the flat layout.
|
||||
label: 'Pages',
|
||||
icon: Compass,
|
||||
searchGroup: true,
|
||||
children: PREVIEW_PAGES.filter((p) => p.path !== '/').map(pageLeaf)
|
||||
children: PREVIEW_PAGES.filter((p) => p.path !== '/' && permissions.pageAllowed(p)).map(
|
||||
pageLeaf
|
||||
)
|
||||
})
|
||||
|
||||
// Session artifacts, when present, get their own branch between Pages and the
|
||||
@@ -258,7 +271,8 @@ section or the flat layout.
|
||||
)
|
||||
|
||||
const tree = $derived<DrillNode<PreviewTarget>[]>([
|
||||
pagesBranch,
|
||||
// An operator's workspace may enable none of them.
|
||||
...(pagesBranch.children.length > 0 ? [pagesBranch] : []),
|
||||
...(artifactsBranch ? [artifactsBranch] : []),
|
||||
...(pipelinesBranch ? [pipelinesBranch] : []),
|
||||
...tagItems(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
showsView
|
||||
} from './previewRouter'
|
||||
import { withMenuHidden } from './sessionMode.svelte'
|
||||
import { useSessionPermissions } from './sessionPermissions.svelte'
|
||||
import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte'
|
||||
import RunFormPreviewSlot from './RunFormPreviewSlot.svelte'
|
||||
import { setOverlayHost } from '../common/overlayHost.svelte'
|
||||
@@ -76,7 +77,15 @@
|
||||
const itemPath = $derived(
|
||||
slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined
|
||||
)
|
||||
const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit')
|
||||
const workspaceId = $derived(
|
||||
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
|
||||
)
|
||||
const permissions = useSessionPermissions(() => workspaceId || undefined)
|
||||
// Every route into an editor ends here, the breadcrumb and the chat's openers included, so
|
||||
// this is where an editor the user cannot save is turned into the deployed page.
|
||||
const mode = $derived(
|
||||
slot.kind === 'viewer' || (itemKind && !permissions.canOpenEditor(itemKind)) ? 'view' : 'edit'
|
||||
)
|
||||
// `new_draft` marks a raw app handed over by the new-app builder.
|
||||
const newRawApp = $derived(
|
||||
itemKind === 'raw_app' && new URL(tab.url, 'http://x').searchParams.get('new_draft') === 'true'
|
||||
@@ -103,9 +112,6 @@
|
||||
// session"). Only the in-process editors need it handed over — an iframe tab
|
||||
// loads the URL whole, params included.
|
||||
const selectedId = $derived(parsePreviewSelectedId(tab.url))
|
||||
const workspaceId = $derived(
|
||||
session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : ''
|
||||
)
|
||||
const isActiveSession = $derived(!!session && sessionState.currentSessionId === session.id)
|
||||
|
||||
// Resolved live from the session's store so an update_artifact re-renders the panel.
|
||||
@@ -394,6 +400,11 @@
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- A pipeline has no deployed page, only its editor. -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 text-center text-sm text-secondary">
|
||||
The pipeline editor saves drafts, which your role cannot do in this workspace.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'pageitem' && mounted && runtime}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
type PreviewItemRoute,
|
||||
type TriggerKind
|
||||
} from './previewPaths'
|
||||
import type { UserExt, UserWorkspace } from '$lib/stores'
|
||||
import { sidebarPageAllowed, type OperatorPageKey } from '../sidebar/operatorRoutes'
|
||||
// Re-exported so the preview code that already reads locations through this module keeps
|
||||
// one import, while a caller needing only a path can reach for the leaf instead.
|
||||
export {
|
||||
@@ -103,22 +105,40 @@ export function previewModeFor(item: Pick<WorkspaceItem, 'draftOnly'>): PreviewI
|
||||
return item.draftOnly ? 'edit' : 'view'
|
||||
}
|
||||
|
||||
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
|
||||
export type PreviewPage = {
|
||||
label: string
|
||||
path: string
|
||||
icon: DrillIcon
|
||||
/** The `operator_settings` key that admits an operator, as the sidebar reads it. */
|
||||
operatorKey?: OperatorPageKey
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
/** Whether `page` loads for `user` rather than showing its "not available" state. An unknown
|
||||
* user is let through to every page but the admin-only ones. */
|
||||
export function previewPageAllowed(
|
||||
page: PreviewPage,
|
||||
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined,
|
||||
workspace: UserWorkspace | undefined
|
||||
): boolean {
|
||||
if (page.adminOnly) return !!user && (user.is_admin || user.is_super_admin)
|
||||
return !page.operatorKey || sidebarPageAllowed(user?.operator, workspace, page.operatorKey)
|
||||
}
|
||||
|
||||
// Core workspace-level destinations the preview can route to. Intentionally
|
||||
// curated — the main pages, not every trigger sub-page (those live behind
|
||||
// EE/feature gating in SidebarContent and aren't worth duplicating here).
|
||||
export const PREVIEW_PAGES: PreviewPage[] = [
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Runs', path: RUNS_PATH, icon: Play },
|
||||
{ label: 'Variables', path: VARIABLES_PATH, icon: DollarSign },
|
||||
{ label: 'Resources', path: RESOURCES_PATH, icon: Boxes },
|
||||
{ label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar },
|
||||
{ label: 'Assets', path: ASSETS_PATH, icon: Database },
|
||||
{ label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen },
|
||||
{ label: 'Groups', path: GROUPS_PATH, icon: Users },
|
||||
{ label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings },
|
||||
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText }
|
||||
{ label: 'Runs', path: RUNS_PATH, icon: Play, operatorKey: 'runs' },
|
||||
{ label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, operatorKey: 'variables' },
|
||||
{ label: 'Resources', path: RESOURCES_PATH, icon: Boxes, operatorKey: 'resources' },
|
||||
{ label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, operatorKey: 'schedules' },
|
||||
{ label: 'Assets', path: ASSETS_PATH, icon: Database, operatorKey: 'assets' },
|
||||
{ label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, operatorKey: 'folders' },
|
||||
{ label: 'Groups', path: GROUPS_PATH, icon: Users, operatorKey: 'groups' },
|
||||
{ label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true },
|
||||
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, operatorKey: 'audit_logs' }
|
||||
]
|
||||
|
||||
// The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import {
|
||||
checkDeployPermission,
|
||||
checkDeployRules,
|
||||
checkItemExists,
|
||||
deployPermissionForKind,
|
||||
getItemValue,
|
||||
@@ -228,6 +231,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
|
||||
// button disables with a reason instead of failing on click. `ok` defaults
|
||||
// true while resolving (fail-open).
|
||||
let deployPerm = $state<DeployPermission>({ ok: true })
|
||||
// `checkDeployPermission` refuses every operator, but flows.rs lets one with the
|
||||
// workspace's `builder_flows` right deploy flows, under the rules alone.
|
||||
let builderFlowPerm = $state<DeployPermission | undefined>(undefined)
|
||||
let deployPermFetchedFor = ''
|
||||
$effect(() => {
|
||||
const ws = getArgs().workspaceId
|
||||
@@ -237,12 +243,25 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
|
||||
// Reset to fail-open for the new workspace and drop a stale resolution
|
||||
// (a slower fetch for the previous workspace must not gate this one).
|
||||
deployPerm = { ok: true }
|
||||
void checkDeployPermission(ws).then((perm) => {
|
||||
if (deployPermFetchedFor === ws) deployPerm = perm
|
||||
builderFlowPerm = undefined
|
||||
void checkDeployPermission(ws).then(async (perm) => {
|
||||
if (deployPermFetchedFor !== ws) return
|
||||
deployPerm = perm
|
||||
const builder =
|
||||
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings?.builder_flows === true
|
||||
if (perm.refusedBy === 'operator' && builder) {
|
||||
const rules = await checkDeployRules(ws)
|
||||
if (deployPermFetchedFor === ws) builderFlowPerm = rules
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function permissionFor(kind: Kind): DeployPermission {
|
||||
if (kind === 'flow' && builderFlowPerm) return deployPermissionForKind(builderFlowPerm, kind)
|
||||
return deployPermissionForKind(deployPerm, kind)
|
||||
}
|
||||
|
||||
// ── Deploy execution ─────────────────────────────────────────────────────
|
||||
// Per-item transient deploy state (keyed by DeployItem.key): loading or
|
||||
// failed. Success has no status — the row's badge flipping to Deployed is
|
||||
@@ -292,8 +311,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
|
||||
// Don't attempt a deploy we know the user can't make (no write permission on
|
||||
// the path, or refused by the preflight for this kind) — the UI disables it
|
||||
// too; this is the guard behind that.
|
||||
if (!discard && (!item.canWrite || !deployPermissionForKind(deployPerm, item.deployKind).ok))
|
||||
return false
|
||||
if (!discard && (!item.canWrite || !permissionFor(item.deployKind).ok)) return false
|
||||
setStatus(item.key, { status: 'loading' })
|
||||
deploying = true
|
||||
try {
|
||||
@@ -389,7 +407,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
|
||||
* that kind stays deployable while a script row does not.
|
||||
*/
|
||||
deployPermissionForKind(kind: Kind): DeployPermission {
|
||||
return deployPermissionForKind(deployPerm, kind)
|
||||
return permissionFor(kind)
|
||||
},
|
||||
deployRow,
|
||||
discardRow
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fromStore } from 'svelte/store'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { previewPageAllowed, type PreviewPage } from './previewRouter'
|
||||
|
||||
/**
|
||||
* What the session UI may offer in `workspace`. Its editors save drafts, so it reads
|
||||
* `roleCanDraft` / `canDraftItem` — the rule the chat's draft tools and `open_preview` follow — so
|
||||
* a control and the tool behind it never disagree. An unknown user reads as able to draft, as the toolset does before access
|
||||
* resolves: hiding every editor from a developer while `whoami` is in flight is the worse
|
||||
* mistake, and the server refuses an operator anyway. Reads context and registers an effect:
|
||||
* call during component initialisation.
|
||||
*/
|
||||
export function useSessionPermissions(workspace: () => string | undefined) {
|
||||
const rights = useEditRights(workspace)
|
||||
const user = useActingUser(workspace)
|
||||
const workspaces = fromStore(userWorkspaces)
|
||||
const entry = $derived(workspaces.current.find((w) => w.id === workspace()))
|
||||
|
||||
return {
|
||||
/** Whether this user can save the drafts the `kind` editor writes. */
|
||||
canOpenEditor: (kind: string) => rights.roleCanDraft(kind),
|
||||
/** The same, and this item's own permissions let them write it. */
|
||||
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'path' | 'extraPerms'>) =>
|
||||
rights.canDraftItem(item.kind, item.path, item.extraPerms),
|
||||
/** Whether the page loads for this user rather than refusing them. */
|
||||
pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
|
||||
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
|
||||
import TriggerModeToggle from './TriggerModeToggle.svelte'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { getHttpRoute } from './http/utils'
|
||||
import { getEmailAddress, getEmailDomain } from './email/utils'
|
||||
import { TRIGGER_LIST_CONFIG, type TriggerRow } from './triggerListConfig'
|
||||
@@ -84,6 +84,7 @@
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let { triggerKind }: { triggerKind: TriggerKind } = $props()
|
||||
|
||||
@@ -697,7 +698,7 @@
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const live = isLive(row)}
|
||||
{@const copy = copyUrl(row)}
|
||||
{@const canEdit = canWrite && !$triggerLock}
|
||||
{@const canEdit = editRights.canEditItem('trigger', path, extra_perms)}
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -46,7 +46,7 @@
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -112,8 +112,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -185,6 +186,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -46,7 +46,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let initialPath = $state('')
|
||||
@@ -66,8 +66,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
|
||||
@@ -161,6 +162,7 @@
|
||||
) {
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
drawerLoading = true
|
||||
try {
|
||||
drawer?.openDrawer()
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -43,7 +43,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let {
|
||||
useDrawer = true,
|
||||
@@ -91,8 +91,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
|
||||
let error_handler_path: string | undefined = $state()
|
||||
@@ -152,6 +153,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
drawerLoading = true
|
||||
let loader = setTimeout(() => {
|
||||
showLoader = true
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -49,7 +49,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let initialPath = $state('')
|
||||
@@ -70,8 +70,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let topic_id: string = $state('')
|
||||
@@ -177,6 +178,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
drawerLoading = true
|
||||
try {
|
||||
drawer?.openDrawer()
|
||||
|
||||
@@ -22,14 +22,8 @@
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import {
|
||||
canWrite,
|
||||
capitalize,
|
||||
emptyString,
|
||||
generateRandomString,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, generateRandomString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, Pipette, Plus } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -82,7 +76,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let {
|
||||
useDrawer = true,
|
||||
@@ -165,8 +159,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
|
||||
let summary: string | undefined = $state()
|
||||
@@ -278,6 +273,7 @@
|
||||
) {
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
drawerLoading = true
|
||||
let loader = setTimeout(() => {
|
||||
showLoader = true
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
|
||||
import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, RotateCcw } from 'lucide-svelte'
|
||||
@@ -42,7 +42,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -108,8 +108,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -187,6 +188,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -51,7 +51,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -118,8 +118,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -182,6 +183,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
saveNativeTriggerFromCfg
|
||||
} from './utils'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { Button } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
@@ -37,6 +37,7 @@
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
service: NativeServiceName
|
||||
@@ -119,9 +120,9 @@
|
||||
// Derived, not snapshotted at load: the acting user's role arrives on its own schedule, and
|
||||
// a trigger that loaded first would otherwise stay read-only until reopened.
|
||||
const can_write = $derived(
|
||||
(writeVerdict ??
|
||||
(permsScriptPath === undefined || canWrite(permsScriptPath, {}, actingUser))) &&
|
||||
!$triggerLock
|
||||
writeVerdict === undefined && permsScriptPath !== undefined
|
||||
? editRights.canEditItem('trigger', permsScriptPath, {})
|
||||
: writeVerdict !== false && editRights.roleCanAuthor('trigger')
|
||||
)
|
||||
let originalConfig = $state<Record<string, any> | undefined>(undefined)
|
||||
let initialConfig = $state<Record<string, any> | undefined>(undefined)
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import type { NativeServiceName, TriggerMode } from '$lib/gen/types.gen'
|
||||
import type { ExtendedNativeTrigger } from './utils'
|
||||
import { getServiceConfig } from './utils'
|
||||
import { canWrite, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
@@ -19,6 +18,7 @@
|
||||
import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
{#each triggers as trigger (trigger.external_id)}
|
||||
{@const isFlow = trigger.is_flow}
|
||||
{@const href = `${isFlow ? '/flows/get' : '/scripts/get'}/${trigger.script_path}`}
|
||||
{@const canEdit = canWrite(trigger.script_path, {}, $userStore) && !$triggerLock}
|
||||
{@const canEdit = editRights.canEditItem('trigger', trigger.script_path, {})}
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
|
||||
import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -37,7 +37,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -103,8 +103,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -170,6 +171,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -59,7 +59,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -124,8 +124,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading: boolean = $state(true)
|
||||
let showLoading: boolean = $state(false)
|
||||
@@ -271,6 +272,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
type Schedule,
|
||||
type ErrorHandler
|
||||
} from '$lib/gen'
|
||||
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
|
||||
import { useScheduleLock } from '$lib/operatorWriteRights'
|
||||
import { emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { base } from '$lib/base'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { List, Loader2, AlertTriangle } from 'lucide-svelte'
|
||||
@@ -52,7 +52,7 @@
|
||||
import PermissionedAsLine from '../PermissionedAsLine.svelte'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
const scheduleLock = useScheduleLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
let {
|
||||
useDrawer = true,
|
||||
@@ -145,7 +145,9 @@
|
||||
const acting = useActingUser(() => wsId)
|
||||
const actingUser = $derived(acting.current)
|
||||
const can_write = $derived(
|
||||
(permsPath === undefined || canWrite(permsPath, extraPerms, actingUser)) && !$scheduleLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('schedule')
|
||||
: editRights.canEditItem('schedule', permsPath, extraPerms)
|
||||
)
|
||||
// Editing the runnable is closed to operators, and an unresolved acting user is no
|
||||
// evidence that this one isn't.
|
||||
@@ -182,6 +184,7 @@
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
drawerLoading = true
|
||||
acting.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
saveDraftHandler = undefined
|
||||
try {
|
||||
drawer?.openDrawer()
|
||||
@@ -287,6 +290,7 @@
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
drawerLoading = true
|
||||
acting.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
try {
|
||||
let s: Schedule | undefined
|
||||
if (schedule_path) {
|
||||
@@ -511,7 +515,6 @@
|
||||
deploymentLoading = false
|
||||
}
|
||||
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
|
||||
let pathC: Path | undefined = $state()
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { capitalize, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
@@ -43,7 +43,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -109,8 +109,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -165,6 +166,7 @@
|
||||
saveDraftHandler = undefined
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -23,14 +23,15 @@ import {
|
||||
type NativeTrigger,
|
||||
type NativeServiceName
|
||||
} from '$lib/gen'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
|
||||
|
||||
import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils'
|
||||
import { get, type Writable } from 'svelte/store'
|
||||
import type { TriggerType } from './utils'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import type { ScheduleTrigger } from '../triggers'
|
||||
import { canWrite, formatCron, generateRandomString } from '$lib/utils'
|
||||
import { formatCron, generateRandomString } from '$lib/utils'
|
||||
import { canEditItem } from '$lib/editRights'
|
||||
|
||||
export class Triggers {
|
||||
#triggers = $state<Trigger[]>([])
|
||||
@@ -130,9 +131,14 @@ export class Triggers {
|
||||
updateTriggers(
|
||||
remoteTriggers: any[],
|
||||
type: TriggerType,
|
||||
workspaceId: string,
|
||||
user: UserExt | undefined = undefined
|
||||
): number {
|
||||
const currentTriggers = this.#triggers
|
||||
const operatorSettings = get(userWorkspaces).find(
|
||||
(w) => w.id === workspaceId
|
||||
)?.operator_settings
|
||||
const kind = type === 'schedule' ? 'schedule' : 'trigger'
|
||||
// Identify triggers with draftConfig to preserve
|
||||
const configuredTriggers = currentTriggers.filter(
|
||||
(t) => t.type === type && !t.isDraft && t.draftConfig
|
||||
@@ -151,7 +157,7 @@ export class Triggers {
|
||||
path: trigger.path,
|
||||
isPrimary: type === 'schedule' && trigger.path === trigger.script_path,
|
||||
isDraft: false,
|
||||
canWrite: canWrite(trigger.path, trigger.extra_perms, user),
|
||||
canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, operatorSettings),
|
||||
draftConfig: draftConfig,
|
||||
lightConfig: getLightConfig(type, trigger)
|
||||
}
|
||||
@@ -199,7 +205,7 @@ export class Triggers {
|
||||
isFlow
|
||||
})
|
||||
|
||||
const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', user)
|
||||
const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', workspaceId, user)
|
||||
const updatedPrimarySchedule = this.#triggers.find((s) => s.isPrimary)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
@@ -234,7 +240,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const wsCount = this.updateTriggers(wsTriggers, 'websocket', user)
|
||||
const wsCount = this.updateTriggers(wsTriggers, 'websocket', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -260,7 +266,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const pgCount = this.updateTriggers(pgTriggers, 'postgres', user)
|
||||
const pgCount = this.updateTriggers(pgTriggers, 'postgres', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -286,7 +292,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', user)
|
||||
const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -312,7 +318,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const natsCount = this.updateTriggers(natsTriggers, 'nats', user)
|
||||
const natsCount = this.updateTriggers(natsTriggers, 'nats', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -338,7 +344,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', user)
|
||||
const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -364,7 +370,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const amqpCount = this.updateTriggers(amqpTriggers, 'amqp', user)
|
||||
const amqpCount = this.updateTriggers(amqpTriggers, 'amqp', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -390,7 +396,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', user)
|
||||
const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -416,7 +422,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', user)
|
||||
const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -442,7 +448,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const azureCount = this.updateTriggers(azureTriggers, 'azure', user)
|
||||
const azureCount = this.updateTriggers(azureTriggers, 'azure', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -468,7 +474,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const httpCount = this.updateTriggers(httpTriggers, 'http', user)
|
||||
const httpCount = this.updateTriggers(httpTriggers, 'http', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -494,7 +500,7 @@ export class Triggers {
|
||||
path,
|
||||
isFlow
|
||||
})
|
||||
const emailCount = this.updateTriggers(emailTriggers, 'email', user)
|
||||
const emailCount = this.updateTriggers(emailTriggers, 'email', workspaceId, user)
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
...(triggersCount ?? {}),
|
||||
@@ -530,7 +536,7 @@ export class Triggers {
|
||||
extra_perms: {},
|
||||
service_config: t.service_config
|
||||
}))
|
||||
const count = this.updateTriggers(triggerData, serviceName, user)
|
||||
const count = this.updateTriggers(triggerData, serviceName, workspaceId, user)
|
||||
const countProperty = `${serviceName}_count`
|
||||
triggersCountStore.update((triggersCount) => {
|
||||
return {
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
import { emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2, X, Plus } from 'lucide-svelte'
|
||||
@@ -59,7 +59,7 @@
|
||||
useOperatingWorkspace,
|
||||
useOperatingWorkspaceHref
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
const triggerLock = useTriggerLock()
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -137,8 +137,9 @@
|
||||
// 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 || canWrite(permsPath, permsForWrite ?? {}, actingUser)) &&
|
||||
!$triggerLock
|
||||
permsPath === undefined
|
||||
? editRights.roleCanAuthor('trigger')
|
||||
: editRights.canEditItem('trigger', permsPath, permsForWrite)
|
||||
)
|
||||
let drawerLoading = $state(true)
|
||||
let showLoading = $state(false)
|
||||
@@ -206,6 +207,7 @@
|
||||
) {
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
editRights.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
|
||||
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite, isOwner, truncate } from '$lib/utils'
|
||||
import { isOwner, truncate } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
import {
|
||||
Plus,
|
||||
@@ -64,6 +65,7 @@
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const editRights = useEditRights()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
function editVariable(path: string) {
|
||||
@@ -205,7 +207,7 @@
|
||||
const result = (await VariableService.listVariable(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms!, operatingUser.current) &&
|
||||
editRights.canEditItem('variable', x.path, x.extra_perms) &&
|
||||
x.workspace_id == $operatingWorkspace,
|
||||
...x
|
||||
}
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import DarkModeObserver from '../DarkModeObserver.svelte'
|
||||
import { AppService, type ListableApp } from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import type { AppViewerContext } from '../apps/types'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const editRights = useEditRights()
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
@@ -32,9 +28,8 @@
|
||||
).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, actingUser) &&
|
||||
app.workspace_id == $operatingWorkspace &&
|
||||
!actingUser?.operator,
|
||||
editRights.canEditItem('app', app.path!, app.extra_perms) &&
|
||||
app.workspace_id == $operatingWorkspace,
|
||||
...app
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ export type WorkspaceItem = {
|
||||
* so read it with `draftOnly` to tell "nothing deployed" from "deployed, with
|
||||
* edits pending". */
|
||||
hasDraft?: boolean
|
||||
/** The item's own sharing, which `canEditItem` reads with the folder rules. */
|
||||
extraPerms?: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Path to display (and group by) in pickers: the friendly draft path when
|
||||
@@ -140,7 +142,8 @@ export async function loadKind(
|
||||
kind: 'flow' as const,
|
||||
draftPath: f.draft_path,
|
||||
draftOnly: f.draft_only ?? false,
|
||||
hasDraft: f.is_draft ?? false
|
||||
hasDraft: f.is_draft ?? false,
|
||||
extraPerms: f.extra_perms
|
||||
}))
|
||||
} else if (kind === 'script') {
|
||||
const scripts = await ScriptService.listScripts({
|
||||
@@ -154,7 +157,8 @@ export async function loadKind(
|
||||
kind: 'script' as const,
|
||||
draftPath: s.draft_path,
|
||||
draftOnly: s.draft_only ?? false,
|
||||
hasDraft: s.is_draft ?? false
|
||||
hasDraft: s.is_draft ?? false,
|
||||
extraPerms: s.extra_perms
|
||||
}))
|
||||
} else {
|
||||
const apps = await AppService.listApps({
|
||||
@@ -168,7 +172,8 @@ export async function loadKind(
|
||||
raw_app: a.raw_app ?? false,
|
||||
draftPath: a.draft_path,
|
||||
draftOnly: a.draft_only ?? false,
|
||||
hasDraft: a.is_draft ?? false
|
||||
hasDraft: a.is_draft ?? false,
|
||||
extraPerms: a.extra_perms
|
||||
}))
|
||||
}
|
||||
// Only commit if the cache version hasn't changed since we started —
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canEditItem, roleCanAuthor, roleCanDraft } from './editRights'
|
||||
|
||||
function user(overrides: Partial<UserExt>): UserExt {
|
||||
return {
|
||||
email: 'op@windmill.dev',
|
||||
username: 'op',
|
||||
name: 'op',
|
||||
is_admin: false,
|
||||
is_super_admin: false,
|
||||
created_at: '',
|
||||
groups: [],
|
||||
pgroups: [],
|
||||
folders: [],
|
||||
folders_owners: [],
|
||||
folders_read: [],
|
||||
operator: true,
|
||||
...overrides
|
||||
} as UserExt
|
||||
}
|
||||
|
||||
describe('edit rights', () => {
|
||||
it('lets a builder operator author flows and nothing else', () => {
|
||||
const builder = { builder_flows: true }
|
||||
expect(roleCanAuthor('flow', user({}), builder)).toBe(true)
|
||||
expect(roleCanAuthor('script', user({}), builder)).toBe(false)
|
||||
expect(roleCanAuthor('flow', user({}), undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// drafts.rs admits an admin before its operator branch; the item handlers do not.
|
||||
it('lets an admin who is also an operator save drafts, but not author code', () => {
|
||||
const adminOperator = user({ is_admin: true })
|
||||
expect(roleCanDraft('script', adminOperator, undefined)).toBe(true)
|
||||
expect(roleCanAuthor('script', adminOperator, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// drafts.rs refuses an operator every draft but a builder flow, rights to write directly or not.
|
||||
it('refuses an operator drafts of what they may write directly', () => {
|
||||
expect(roleCanAuthor('trigger', user({}), undefined)).toBe(true)
|
||||
expect(roleCanDraft('trigger', user({}), undefined)).toBe(false)
|
||||
expect(roleCanDraft('resource', user({}), undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// The role alone is not enough: a builder still cannot edit a flow in a folder they only read.
|
||||
it('requires write access to the item on top of the role', () => {
|
||||
const builder = { builder_flows: true }
|
||||
expect(canEditItem('flow', 'f/demo/sum', {}, user({}), builder)).toBe(false)
|
||||
expect(canEditItem('flow', 'u/op/sum', {}, user({}), builder)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { OperatorSettings } from '$lib/gen'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
|
||||
/**
|
||||
* The frontend's one copy of "may this user edit this item". The server decides — drafts.rs
|
||||
* `require_can_write_path`, the item handlers' operator refusals, `gate_operator_writes`, and
|
||||
* row-level security — and this mirrors it so a control the server would refuse is never
|
||||
* offered. Check edits through `canEditItem` (or `useEditRights` in a component), never by
|
||||
* pairing `canWrite` with an operator test of your own.
|
||||
*/
|
||||
|
||||
/** Kinds whose role half differs. Any other kind is code an operator never authors. */
|
||||
export type EditKind =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'agent'
|
||||
| 'pipeline'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'group'
|
||||
| (string & {})
|
||||
|
||||
type OperatorRights =
|
||||
| Pick<NonNullable<OperatorSettings>, 'builder_flows' | 'manage_schedules' | 'manage_triggers'>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
/**
|
||||
* Whether a user's role lets them author items of `kind` — create, edit, deploy or delete one —
|
||||
* in a workspace with these `operator_settings`: the role half of every check against the item
|
||||
* handlers. Saving a draft is `roleCanDraft`'s rule, and which items they may write is
|
||||
* `canEditItem`'s other half. For an operator:
|
||||
* - code (scripts, apps, agents, pipelines): never;
|
||||
* - flows: where the workspace grants `builder_flows` (`check_operator_can_build_flows`);
|
||||
* - schedules and triggers: unless the workspace set `manage_*` to false — a withdrawable
|
||||
* right, so an unset key grants it (docs/operator-write-rights.md);
|
||||
* - resources, variables and groups: always, their permissions alone decide.
|
||||
*/
|
||||
export function roleCanAuthor(
|
||||
kind: EditKind,
|
||||
user: Pick<UserExt, 'operator'> | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
if (!user?.operator) return true
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
return operatorSettings?.builder_flows === true
|
||||
case 'schedule':
|
||||
return operatorSettings?.manage_schedules !== false
|
||||
case 'trigger':
|
||||
return operatorSettings?.manage_triggers !== false
|
||||
case 'resource':
|
||||
case 'variable':
|
||||
case 'group':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user may save drafts of `kind` — what every session editor writes. Mirrors drafts.rs
|
||||
* `require_can_write_path`, which differs from the item handlers both ways: it admits an admin
|
||||
* before its operator branch, and it refuses every operator draft but a flow under
|
||||
* `builder_flows` — schedules, triggers, resources and variables included, though an operator may
|
||||
* write those directly.
|
||||
*/
|
||||
export function roleCanDraft(
|
||||
kind: EditKind,
|
||||
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
if (user?.is_admin || user?.is_super_admin || !user?.operator) return true
|
||||
return kind === 'flow' && operatorSettings?.builder_flows === true
|
||||
}
|
||||
|
||||
/** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */
|
||||
export function canDraftItem(
|
||||
kind: EditKind,
|
||||
path: string,
|
||||
extraPerms: Record<string, boolean> | undefined,
|
||||
user: UserExt | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
return roleCanDraft(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user)
|
||||
}
|
||||
|
||||
/** Whether `user` may edit the item at `path`: their role authors `kind`, and the item's own
|
||||
* permissions (`canWrite`: ownership, folder write, `extra_perms`) let them write it. */
|
||||
export function canEditItem(
|
||||
kind: EditKind,
|
||||
path: string,
|
||||
extraPerms: Record<string, boolean> | undefined,
|
||||
user: UserExt | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
return roleCanAuthor(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user)
|
||||
}
|
||||
@@ -1,29 +1,56 @@
|
||||
import { derived, type Readable } from 'svelte/store'
|
||||
import { derived, fromStore, type Readable } from 'svelte/store'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights'
|
||||
|
||||
/**
|
||||
* The `editRights` rules (`roleCanAuthor` / `canEditItem` for the item handlers, `roleCanDraft` /
|
||||
* `canDraftItem` for drafts) answered for the user acting in the operating workspace,
|
||||
* with that workspace's `operator_settings` — or in `workspace`, for a component that is handed
|
||||
* one rather than sitting under a host that declares it. Reads context and registers an effect:
|
||||
* call during component initialisation.
|
||||
*/
|
||||
export function useEditRights(workspace?: () => string | undefined) {
|
||||
const operating = fromStore(useOperatingWorkspace())
|
||||
const ws = workspace ?? (() => operating.current)
|
||||
const user = useActingUser(ws)
|
||||
const workspaces = fromStore(userWorkspaces)
|
||||
const settings = () => workspaces.current.find((w) => w.id === ws())?.operator_settings
|
||||
return {
|
||||
roleCanAuthor: (kind: string) => roleCanAuthor(kind, user.current, settings()),
|
||||
canEditItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) =>
|
||||
canEditItem(kind, path, extraPerms, user.current, settings()),
|
||||
roleCanDraft: (kind: string) => roleCanDraft(kind, user.current, settings()),
|
||||
canDraftItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) =>
|
||||
canDraftItem(kind, path, extraPerms, user.current, settings()),
|
||||
/** This hook looks the user up on its own, so an editor that retries a failed lookup on
|
||||
* its acting user (`forgetFailures`) must retry this one too, or it stays read-only. */
|
||||
forgetFailures: () => user.forgetFailures()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why writes of this kind are locked in the operating workspace, or `undefined` when they are not —
|
||||
* the shape `title` and `disabled` both want. See `docs/operator-write-rights.md`.
|
||||
*
|
||||
* Only `false` locks. A workspace that never configured the key and a non-operator (whose
|
||||
* `operator_settings` is null) both hold the right, so `=== true` here would disable the controls
|
||||
* for everyone.
|
||||
* The role half of `roleCanAuthor`, phrased for the controls that explain it. `operator_settings`
|
||||
* is non-null exactly for an operator (`isOperatorInWorkspace`), so it stands in for the user.
|
||||
*/
|
||||
function writeLock(key: 'manage_schedules' | 'manage_triggers', noun: string) {
|
||||
function writeLock(kind: 'schedule' | 'trigger', noun: string) {
|
||||
return derived([userWorkspaces, useOperatingWorkspace()], ([$userWorkspaces, $workspace]) => {
|
||||
const settings = $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings
|
||||
// Worded as the server words its refusal of the same write.
|
||||
return settings?.[key] === false
|
||||
? `Operators cannot manage ${noun} in this workspace`
|
||||
: undefined
|
||||
return roleCanAuthor(kind, { operator: settings != null }, settings)
|
||||
? undefined
|
||||
: `Operators cannot manage ${noun} in this workspace`
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads context: call during component initialisation. */
|
||||
export const useScheduleLock = () => writeLock('manage_schedules', 'schedules')
|
||||
export const useScheduleLock = () => writeLock('schedule', 'schedules')
|
||||
/** Reads context: call during component initialisation. */
|
||||
export const useTriggerLock = () => writeLock('manage_triggers', 'triggers')
|
||||
export const useTriggerLock = () => writeLock('trigger', 'triggers')
|
||||
|
||||
/**
|
||||
* True when the user is an operator of `workspace` and it granted operators the right to compose
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
|
||||
import SessionPicker from '$lib/components/sessions/SessionPicker.svelte'
|
||||
import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { isGlobalAiEnabled, sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import {
|
||||
isPageItemListPath,
|
||||
@@ -300,11 +300,10 @@
|
||||
// Session mode is route-derived: the rail shows the sessions sidebar on the
|
||||
// /sessions page and the workspace navigation everywhere else. The switch
|
||||
// (SessionModeSwitch) just navigates in and out of that route.
|
||||
// The route decides, except for an operator: /sessions refuses them, so handing their
|
||||
// sidebar over to a session list would offer what the page behind it is declining.
|
||||
let sessionMode = $derived(
|
||||
page.url.pathname.startsWith(base + '/sessions') && !$userStore?.operator
|
||||
)
|
||||
// An operator without the opt-in (`gate.ts`) is refused by /sessions, so their sidebar is not
|
||||
// handed over to a session list the page behind it declines, and they keep the docked chat.
|
||||
const sessionsAllowed = $derived(sessionsAllowedFor($userStore?.operator))
|
||||
let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions') && sessionsAllowed)
|
||||
// Session mode points the bottom settings entry at the open session's own
|
||||
// workspace. An unsent draft hasn't committed one yet, so it falls back to
|
||||
// the family root.
|
||||
@@ -326,12 +325,10 @@
|
||||
// A workspace that hid the assistant (`ai_config.copilot_disabled`) loses both entry
|
||||
// points: the Workspace ⇄ Sessions switch and the legacy Ask-AI button.
|
||||
const sessionsSwitchShown = $derived(
|
||||
globalAiEnabled && !$copilotInfo.workspaceDisabled && !$userStore?.operator
|
||||
globalAiEnabled && sessionsAllowed && !$copilotInfo.workspaceDisabled
|
||||
)
|
||||
// The legacy pane's entry point. An operator keeps that pane whatever the beta gate says
|
||||
// (`disableAi` above), so the sidebar must still offer them a way into it.
|
||||
const askAiShown = $derived(
|
||||
(!globalAiEnabled || $userStore?.operator) && !$copilotInfo.workspaceDisabled
|
||||
(!globalAiEnabled || !sessionsAllowed) && !$copilotInfo.workspaceDisabled
|
||||
)
|
||||
|
||||
if (page.status == 404) {
|
||||
@@ -891,7 +888,7 @@
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
const ready = sessionState.hydrated && $usersWorkspaceStore !== undefined
|
||||
if (globalAiEnabled && ready && ws && !$userStore?.operator) {
|
||||
if (globalAiEnabled && sessionsAllowed && ready && ws) {
|
||||
untrack(() => restoreSessionBackups(ws))
|
||||
}
|
||||
})
|
||||
@@ -1488,9 +1485,9 @@
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly || menuHidden}
|
||||
disableAi={globalAiEnabled && !$userStore?.operator ? true : sessionMode}
|
||||
disableAi={globalAiEnabled && sessionsAllowed ? true : sessionMode}
|
||||
loadAiConfig={!sessionMode}
|
||||
showSessionsBetaBanner={!$userStore?.operator}
|
||||
showSessionsBetaBanner={sessionsAllowed}
|
||||
sidebarWidth={railWidth}
|
||||
transitionClass={sidebarTransitionClass}
|
||||
isMobile={useDrawer}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { Pen, Plus, Trash } from 'lucide-svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
type GroupW = Group & { canWrite: boolean }
|
||||
|
||||
const editRights = useEditRights()
|
||||
|
||||
let restricted = $derived(
|
||||
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
|
||||
)
|
||||
@@ -34,7 +36,7 @@
|
||||
|
||||
async function loadGroups(): Promise<void> {
|
||||
groups = (await GroupService.listGroups({ workspace: $workspaceStore! })).map((x) => {
|
||||
return { canWrite: canWrite(x.name, x.extra_perms ?? {}, $userStore), ...x }
|
||||
return { canWrite: editRights.canEditItem('group', x.name, x.extra_perms), ...x }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
type ScriptArgs
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
computeSharableHash,
|
||||
copyToClipboard,
|
||||
encodeState,
|
||||
@@ -124,7 +123,9 @@
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { useEditRights } from '$lib/operatorWriteRights'
|
||||
import { AGENT_STEP_ID } from '$lib/components/flows/conversations/agentEditorChat'
|
||||
const editRights = useEditRights()
|
||||
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
|
||||
let jobUpdateLastFetch: Date | undefined = $state()
|
||||
|
||||
@@ -1046,7 +1047,7 @@
|
||||
{/if}
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
|
||||
{#if !$userStore?.operator}
|
||||
{#if canWrite(job?.script_path ?? '', {}, $userStore)}
|
||||
{#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})}
|
||||
<Button
|
||||
href={`${stem}/edit/${job?.script_path}?workspace=${$workspaceStore}`}
|
||||
on:click={() => {
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
import { markSessionRecovered } from '$lib/components/sessions/sessionRecoveryNotice.svelte'
|
||||
import {
|
||||
isGlobalAiEnabled,
|
||||
clearSessionsBetaOptOut
|
||||
clearSessionsBetaOptOut,
|
||||
sessionsAllowedFor
|
||||
} from '$lib/components/copilot/chat/global/gate'
|
||||
import { setToolCompletionListener } from '$lib/components/copilot/chat/shared'
|
||||
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
|
||||
@@ -205,7 +206,7 @@
|
||||
// load. `recovering` also guards re-entry: recovery mutates the session list
|
||||
// this effect tracks, while the URL that would stop it only updates on `goto`.
|
||||
$effect(() => {
|
||||
if (embedded || !globalEnabled || $userStore?.operator) return
|
||||
if (embedded || !globalEnabled || !sessionsAllowedFor($userStore?.operator)) return
|
||||
if (!sessionState.hydrated || recovering) return
|
||||
// A deliberate delete removes the open session ahead of its own navigation.
|
||||
// Claiming that gap would take over the URL and tell the user the session
|
||||
@@ -1021,9 +1022,9 @@
|
||||
}}>Open sessions</Button
|
||||
>
|
||||
</div>
|
||||
{:else if $userStore?.operator}
|
||||
<!-- Operators are exempt from the sessions beta (the layout keeps their
|
||||
legacy docked chat); a direct URL must not bypass that. -->
|
||||
{:else if !sessionsAllowedFor($userStore?.operator)}
|
||||
<!-- Operators without the opt-in keep the legacy docked chat (see `gate.ts`); a direct
|
||||
URL must not bypass that. -->
|
||||
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
|
||||
<p class="text-primary font-medium">AI Sessions are not available for operators</p>
|
||||
<p>Use the Ask AI chat instead.</p>
|
||||
|
||||
Reference in New Issue
Block a user