feat: workspace roles settings UI and capability-based gating (#11824)

* feat: configurable workspace roles with per-workspace overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: close role review gaps in pickers, declarative sync and removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep resolve_role_name doc on its function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: validate overrides on instance role changes and bill admins fully

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: allow pipeline drafts and keep role checks on one connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: list data pipelines in operator_can_draft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: expect a 403 role refusal when an operator resolves jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: require a valid license for custom workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: freeze workspace roles when the license lapses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee companion for workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: broadcast role override changes from the write path, not a trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

This commit updates the EE repository reference after PR #891 was merged in windmill-ee-private.

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* feat: add workspace roles settings UI and role pickers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show the server's reason when a settings save fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: default role pickers to a defined role and confirm resets of unknown capabilities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat null role capabilities as unset and key role drafts by map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: gate the frontend on workspace role capabilities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: gate deploys, bulk actions and chat composition per role kind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat a superadmin as full in deploy permission checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: leave page visibility out of isRestricted, as the server does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a member discard their own draft without the authoring right

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show custom roles as inactive without a valid license

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the user store import on the agent page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show custom roles as frozen without a valid license

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: reorder instance roles with a drag handle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let an admin row that kept the operator flag hold full rights everywhere

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat superadmin members as admins and return role names from get user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: move the instance role label into the expanded panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-21 16:31:04 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 21d2a49971
commit 8dbdfa1949
143 changed files with 2652 additions and 1593 deletions
+46 -46
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { globalEmailInvite, superadmin, workspaceStore, enterpriseLicense } from '$lib/stores'
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
import { SettingService, UserService, WorkspaceService, type EffectiveRole } from '$lib/gen'
import { Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import { sendUserToast } from '$lib/toast'
@@ -14,6 +14,15 @@
import { UserPlus } from 'lucide-svelte'
import Select from './select/Select.svelte'
import { resource } from 'runed'
import RolePicker from './settings/RolePicker.svelte'
import { roleRequestFields, validRoleOr } from '$lib/workspaceRoles'
interface Props {
/** The workspace's roles; undefined while loading. */
roles: EffectiveRole[] | undefined
}
let { roles }: Props = $props()
const dispatch = createEventDispatcher()
@@ -84,7 +93,7 @@
getAutomateUsernameCreationSetting()
async function addUser() {
if (selected === 'service_account') {
if (isServiceAccount) {
if (!username) return
await WorkspaceService.createServiceAccount({
workspace: $workspaceStore!,
@@ -99,15 +108,19 @@
} else {
// Read once: the picker's own state changes as soon as the request lands.
const email = effectiveEmail!
await WorkspaceService.addUser({
workspace: $workspaceStore!,
requestBody: {
email,
username: automateUsernameCreation ? undefined : username,
is_admin: selected == 'admin',
operator: selected == 'operator'
}
})
try {
await WorkspaceService.addUser({
workspace: $workspaceStore!,
requestBody: {
email,
username: automateUsernameCreation ? undefined : username,
...roleRequestFields(selected)
}
})
} catch (e) {
sendUserToast(`Failed to add ${email}: ${e?.body ?? e}`, true)
return
}
sendUserToast(`Added ${email}`)
// The picker excludes members server-side, so it has to refetch to drop this one.
addedCount++
@@ -140,12 +153,13 @@
dispatch('new')
}
type UserRole = 'operator' | 'developer' | 'admin' | 'service_account'
type ServiceAccountRole = 'operator' | 'developer' | 'admin'
let selected: UserRole = $state('developer' as UserRole)
// A role name, or `admin`.
let picked: string | undefined = $state(undefined)
const selected = $derived(validRoleOr(roles, picked))
let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole)
let addToDeployers: boolean = $state(true)
let isServiceAccount = $derived(selected === 'service_account')
let isServiceAccount = $state(false)
</script>
<Popover placement="bottom-end" bind:isOpen={popoverOpen} onClose={() => (typedEmail = '')}>
@@ -157,6 +171,18 @@
{#snippet content()}
<div class="flex flex-col w-[28rem] p-4">
<span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span>
<div class="mb-2">
<Toggle
bind:checked={isServiceAccount}
disabled={!$enterpriseLicense}
size="xs"
options={{
right: $enterpriseLicense ? 'Service account' : 'Service account (EE)',
rightTooltip:
'A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins.'
}}
/>
</div>
{#if isServiceAccount}
<span class="text-xs mb-1 leading-6">Username</span>
@@ -217,38 +243,12 @@
{/if}
{/if}
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
<ToggleButtonGroup bind:selected class="mb-4">
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
<ToggleButton
value="service_account"
label={$enterpriseLicense ? 'Service Account' : 'Service Account (EE)'}
tooltip="A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins."
disabled={!$enterpriseLicense}
{item}
/>
{/snippet}
</ToggleButtonGroup>
{#if isServiceAccount}
{#if !isServiceAccount}
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
<div class="mb-4">
<RolePicker {roles} disablePortal value={selected} onchange={(v) => (picked = v)} />
</div>
{:else}
<span class="text-xs mb-1 leading-6">Service account role</span>
<ToggleButtonGroup bind:selected={serviceAccountRole} class="mb-4">
{#snippet children({ item })}
@@ -34,6 +34,7 @@
} from '$lib/workspaceDrafts.svelte'
import type { Kind as LayoutKind } from '$lib/utils_deployable'
import { userStore, userWorkspaces } from '$lib/stores'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
interface Props {
@@ -329,7 +330,12 @@
workspaceDeployPerm,
visibleItems
.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
.map((i) => i.draftKind)
.map((i) => i.draftKind),
capabilitiesIn(
// Superadmin is global; a workspace's admin flag is not.
{ is_super_admin: $userStore?.is_super_admin },
$userWorkspaces.find((w) => w.id === currentWorkspaceId)
)
)
)
// Select all on the first non-empty load (acting on everything is the common
@@ -37,6 +37,7 @@
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { capabilitiesIn } from '$lib/roleCapabilities'
import type { Kind } from '$lib/utils_deployable'
import {
@@ -1090,7 +1091,12 @@
workspaceDeployPerm,
(comparison?.diffs ?? [])
.filter((d) => selectedItems.includes(getItemKey(d)))
.map((d) => d.kind)
.map((d) => d.kind),
capabilitiesIn(
// Superadmin is global; a workspace's admin flag is not.
{ is_super_admin: $userStore?.is_super_admin },
$userWorkspaces.find((w) => w.id == deployTargetWorkspace)
)
)
)
+10 -5
View File
@@ -18,7 +18,8 @@
import { goto } from '$lib/navigation'
import { OtherUserDraftLoad, editRouteFor } from '$lib/components/otherUserDraftLoad.svelte'
import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff'
import { userStore } from '$lib/stores'
import { roleCapabilities, userStore } from '$lib/stores'
import { roleCanAuthor } from '$lib/editRights'
type DraftUser = { username?: string | null }
@@ -116,6 +117,10 @@
// Inline actions need full context to fetch and fork drafts.
const actionsEnabled = $derived(!!workspace && !!itemKind && !!path && draft_users.length > 0)
// The editors these open are the full ones, which a role without code cannot use.
const canOpenEditor = $derived(
!!itemKind && $roleCapabilities.code && roleCanAuthor(itemKind, $roleCapabilities)
)
const kindLabel = $derived(
itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script'
@@ -303,7 +308,7 @@
</span>
{#if actionsEnabled && isSelf}
<!-- Own draft: jump straight into the editor. -->
{#if !$userStore?.operator && itemKind && path}
{#if canOpenEditor && itemKind && path}
<Button
variant="subtle"
size="xs3"
@@ -326,9 +331,9 @@
View Diff
</Button>
{/if}
<!-- Operators can't edit items, so Load is hidden (View Diff stays, it's
read-only). `allowFork=false` (deploy/review page) hides it too. -->
{#if allowFork && !$userStore?.operator}
<!-- Load is hidden from a role that can't edit the item (View Diff stays,
it's read-only). `allowFork=false` (deploy/review page) hides it too. -->
{#if allowFork && canOpenEditor}
<Button
variant="subtle"
size="xs3"
@@ -19,7 +19,7 @@
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import Toggle from './Toggle.svelte'
import {
DynamicInput,
@@ -36,14 +36,12 @@
import Section from '$lib/components/Section.svelte'
import Editor from './Editor.svelte'
import AddPropertyV2 from './schema/AddPropertyV2.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
// Dynamic input functions are inline code a compose-only flow role cannot author.
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
// export let openEditTab: () => void = () => {}
const dispatch = createEventDispatcher()
@@ -482,7 +480,7 @@
dynCode,
dynLang,
deployedFlowPath,
operatingUser.current?.operator
!$roleCaps.code
)}
prettifyHeader={isAppInput}
disabled={!!previewSchema}
@@ -496,7 +494,7 @@
{@render runButton?.()}
{#if dynamicFunctions.length > 0 && !$operatorBuilderFlows}
{#if dynamicFunctions.length > 0 && !composeOnlyFlows}
<Section
label="Dynamic input functions"
collapsable={true}
@@ -825,7 +823,7 @@
{#each typeOptions as x}
<ToggleButton value={x[1]} label={x[0]} {item} />
{/each}
{#if showDynOpt && !$operatorBuilderFlows}
{#if showDynOpt && !composeOnlyFlows}
{#each DYNAMIC_OPTIONS as x}
<ToggleButton value={x[1]} label={x[0]} {item} />
{/each}
@@ -37,14 +37,11 @@
import { isS3Uri } from '$lib/utils'
import { Database, File, HardDriveIcon } from 'lucide-svelte'
import DucklakeIcon from './icons/DucklakeIcon.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const roleCaps = useRoleCapabilities()
const {
asset,
@@ -83,7 +80,7 @@
</script>
<Button
disabled={actingUser?.operator || disabled}
disabled={!$roleCaps.code || disabled}
unifiedSize={'md'}
variant={buttonVariant}
wrapperClasses={className}
@@ -117,13 +117,14 @@
import { UserDraft } from '$lib/userDraft.svelte'
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext'
import { useEditRights, useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights'
import { useEditRights, useRoleCapabilities, useTriggerLock } from '$lib/operatorWriteRights'
import { capabilitiesIn } from '$lib/roleCapabilities'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const triggerLock = useTriggerLock()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
const editRights = useEditRights()
const operatingWorkspace = useOperatingWorkspace()
@@ -561,11 +562,12 @@
const user =
(await getUserExt(opWorkspace ?? '')) ??
(opWorkspace === $workspaceStore ? $userStore : undefined)
const operatorSettings = $userWorkspaces.find(
(w) => w.id === opWorkspace
)?.operator_settings
const caps = capabilitiesIn(
user,
$userWorkspaces.find((w) => w.id === opWorkspace)
)
agentCanWrite = Object.fromEntries(
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user, operatorSettings)])
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user, caps)])
)
// 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
@@ -1875,7 +1877,7 @@
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi &&
customUi?.topBar?.aiBuilder != false &&
!$operatorBuilderFlows}
$roleCaps.flows !== 'compose'}
toggleAiChat={() => aiChatManager.toggleOpen()}
{sessionOpen}
onOpenPreview={flowPreviewButtons?.openPreview}
@@ -49,9 +49,11 @@
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
interface Props {
previewMode: 'upTo' | 'whole'
@@ -568,7 +570,7 @@
| ScriptLang
| undefined,
$initialPathStore,
operatingUser.current?.operator
!$roleCaps.code
)}
/>
</div>
@@ -3,7 +3,13 @@
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { Badge } from './common'
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte'
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
import {
roleCapabilities,
workspaceStore,
userWorkspaces,
userStore,
type UserExt
} from '$lib/stores'
import type { WorkspaceComparison } from '$lib/gen'
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
import { Button } from './common'
@@ -27,18 +33,18 @@
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
let isFork = $derived(parentWorkspaceId != null)
let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData))
// Operators run scripts and flows, they never deploy a fork, so the banner and
// its CTA are noise for them. Gates the fetches too, not just the markup: the
// fork/parent comparison is an expensive tally no operator can act on.
// A role without `code` never deploys a fork, so the banner and its CTA are noise
// for it. Gates the fetches too, not just the markup: the fork/parent comparison
// is an expensive tally such a role cannot act on.
//
// Only a role fetched for the workspace we are on answers this. `$workspaceStore`
// Only a user fetched for the workspace we are on answers this. `$workspaceStore`
// flips synchronously on a switch while `$userStore` still holds the workspace we
// left, so trusting it unqualified would flash the banner at (and start the tally
// for) an operator entering a fork from a workspace where they are not one.
function isConfirmedNonOperator(user: UserExt | undefined, ws: string | undefined): boolean {
return !!user && !!ws && user.workspace_id === ws && !user.operator
// for) a restricted member entering a fork from a workspace where they are not one.
function isUserFor(user: UserExt | undefined, ws: string | undefined): boolean {
return !!user && !!ws && user.workspace_id === ws
}
let isNotOperator = $derived(isConfirmedNonOperator($userStore, $workspaceStore))
let isNotOperator = $derived(isUserFor($userStore, $workspaceStore) && $roleCapabilities.code)
let showBanner = $derived(isFork && isNotOperator)
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
@@ -22,6 +22,7 @@
import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte'
import RetentionPeriodOverrides from './instanceSettings/RetentionPeriodOverrides.svelte'
import AgentWorkloadIdentityIssuers from './instanceSettings/AgentWorkloadIdentityIssuers.svelte'
import WorkspaceRolesSetting from './instanceSettings/WorkspaceRolesSetting.svelte'
import SmtpSettings from './instanceSettings/SmtpSettings.svelte'
import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte'
import ExternalInstancePgSettings from './instanceSettings/ExternalInstancePgSettings.svelte'
@@ -342,6 +343,8 @@
getInstanceConfig() has populated the persisted overrides (which would let a save drop
them). -->
<RetentionPeriodOverrides {values} disabled={!$enterpriseLicense || loading} />
{:else if setting.fieldType == 'workspace_roles'}
<WorkspaceRolesSetting {values} {loading} unlicensed={!$enterpriseLicense} />
{:else}
<SettingCard
label={setting.key === 'disable_stats'
@@ -1140,6 +1140,12 @@
description="Configure default timeouts and retention policies for job execution."
link="https://www.windmill.dev/docs/advanced/instance_settings#jobs"
/>
{:else if category == 'Workspace roles'}
<SettingsPageHeader
title="Workspace roles"
description="Define the roles workspace members can hold and what each one may author, manage and see. Workspace admin is separate and holds everything."
link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
/>
{:else if category == 'Service logs'}
<SettingsPageHeader
title="Service logs"
+11 -30
View File
@@ -1,16 +1,19 @@
<script lang="ts">
import { Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import RolePicker from './settings/RolePicker.svelte'
import type { EffectiveRole } from '$lib/gen'
import { validRoleOr } from '$lib/workspaceRoles'
import { MailPlus } from 'lucide-svelte'
interface Props {
inviteUser: (email: string, selected: 'operator' | 'developer' | 'admin') => Promise<void>
/** `selected` is a role name, or `admin`. */
inviteUser: (email: string, selected: string) => Promise<void>
/** The workspace's roles; undefined while loading. */
roles: EffectiveRole[] | undefined
}
const { inviteUser }: Props = $props()
const { inviteUser, roles }: Props = $props()
let email: string = $state('')
@@ -23,7 +26,8 @@
}
}
let selected: 'operator' | 'developer' | 'admin' = $state('developer')
let picked: string | undefined = $state(undefined)
const selected = $derived(validRoleOr(roles, picked))
</script>
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
@@ -46,30 +50,7 @@
bind:value={email}
class="mr-4"
/>
<ToggleButtonGroup bind:selected>
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
<RolePicker {roles} disablePortal value={selected} onchange={(v) => (picked = v)} />
<Button
variant="accent"
unifiedSize="md"
+2 -2
View File
@@ -28,7 +28,7 @@
import PocketIdIcon from '$lib/components/icons/PocketIdIcon.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores'
import { usersWorkspaceStore, workspaceStore, userStore, roleCapabilities } from '$lib/stores'
import { emptyString, escapeHtml, parseQueryParams } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
@@ -337,7 +337,7 @@
workspaceStore.set(allWorkspaces[0].id)
$userStore = await getUserExt($workspaceStore!)
if (!$userStore?.is_super_admin && $userStore?.operator) {
if (!$roleCapabilities.code) {
let defaultApp = await WorkspaceService.getWorkspaceDefaultApp({
workspace: $workspaceStore!
})
@@ -25,9 +25,11 @@
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
// The shared rules state holds the navigation workspace's rules, so a host acting on another
// workspace (an AI session's fork) has to ask for that one's.
@@ -97,7 +99,7 @@
)
</script>
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
{#if ($roleCaps.code || $roleCaps.flows !== 'none' || $roleCaps.apps !== 'none') && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
@@ -1,6 +1,6 @@
<script lang="ts">
import type { ProtectionRuleset } from '$lib/gen'
import { userStore, type UserExt } from '$lib/stores'
import { roleCapabilities, type UserExt } from '$lib/stores'
import { getUserExt } from '$lib/user'
import {
fetchProtectionRulesForWorkspace,
@@ -71,7 +71,7 @@
})
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
{#if ($roleCapabilities.code || $roleCapabilities.flows !== 'none' || $roleCapabilities.apps !== 'none') && activeDeployRulesets.length > 0}
<!-- Named for the deploy destination, not the lineage: the merge UI also points
this at a target outside the fork lineage. -->
<Alert type="info" title="Target workspace protection active" class="my-2">
@@ -9,6 +9,7 @@
} from '$lib/gen'
import { canEditItem } from '$lib/editRights'
import { userWorkspaces } from '$lib/stores'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
import { sendUserToast } from '$lib/toast'
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
@@ -269,8 +270,12 @@
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 user = acting.in(ws)
const caps = capabilitiesIn(
user,
$userWorkspaces.find((w) => w.id === ws)
)
return canEditItem('resource', path, extraPerms, user, caps)
}
const dirtyWorkspaces = $derived(
@@ -3,31 +3,30 @@
import Label from './Label.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import { userStore, workerTags, workspaceStore } from '$lib/stores'
import { roleCapabilities, userStore, workerTags, workspaceStore } from '$lib/stores'
import { Button } from './common'
import { WorkerService } from '$lib/gen'
import DateTimeInput from './DateTimeInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
interface Props {
runnable:
| {
summary?: string
description?: string
path?: string
is_template?: boolean
hash?: string
kind?: string
can_write?: boolean
created_at?: string
created_by?: string
extra_perms?: Record<string, boolean>
}
| undefined;
scheduledForStr: string | undefined;
invisible_to_owner: boolean | undefined;
overrideTag: string | undefined;
runnable:
| {
summary?: string
description?: string
path?: string
is_template?: boolean
hash?: string
kind?: string
can_write?: boolean
created_at?: string
created_by?: string
extra_perms?: Record<string, boolean>
}
| undefined
scheduledForStr: string | undefined
invisible_to_owner: boolean | undefined
overrideTag: string | undefined
}
let {
@@ -35,7 +34,7 @@
scheduledForStr = $bindable(),
invisible_to_owner = $bindable(),
overrideTag = $bindable()
}: Props = $props();
}: Props = $props()
// As in WorkerTagPicker: the shared `workerTags` cache is the navigation workspace's, so
// tags of another operating workspace live in a local list rather than overwriting it.
const operatingWorkspace = useOperatingWorkspace()
@@ -87,7 +86,7 @@
</div>
{/if}
</div>
{#if !$userStore?.operator}
{#if $roleCapabilities.code}
{#if currentTags && currentTags.length > 0}
<div class="w-full">
<select
+22 -21
View File
@@ -11,12 +11,10 @@
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores'
import { superadmin, devopsRole, enterpriseLicense } from '$lib/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RunChart from '$lib/components/RunChart.svelte'
@@ -83,7 +81,7 @@
// the filter row renders in the page, and requests go to the workspace it names.
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
let paths: string[] = $state([])
let usernames: string[] = $state([])
@@ -178,9 +176,7 @@
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
typeOfChart(
(hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')
)
typeOfChart((hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph'))
)
let innerWidth = $state(window.innerWidth)
let jobsLoader = useJobsLoader(() => ({
@@ -230,7 +226,9 @@
}
async function loadPaths() {
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' })
const npaths_scripts = await ScriptService.listScriptPaths({
workspace: $operatingWorkspace ?? ''
})
const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' })
paths = npaths_scripts.concat(npaths_flows).sort()
}
@@ -452,11 +450,14 @@
// workaround because EventSource does not support POST requests
// https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425
const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const response = await fetch(
`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}
)
await new Promise(async (resolve) => {
const reader = response?.body?.pipeThrough(new TextDecoderStream()).getReader()
let reRanUuids: string[] = []
@@ -572,8 +573,8 @@
askingForConfirmation = {
title: `Confirm re-running the selected jobs`,
confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`,
// The server skips resolution for operators, who cannot resolve.
description: operatingUser.current?.operator
// The server skips resolution for a role without `code`, which cannot resolve.
description: !$roleCaps.code
? undefined
: "Failed or canceled jobs among them will be marked resolved, with the new run's id as the note.",
type: 'reload',
@@ -955,10 +956,10 @@
}}
/>
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs}
{#if $operatingWorkspace && !$roleCaps.visibility.runs}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
<p>Page not available for your role</p>
</div>
{:else}
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
@@ -1166,10 +1167,10 @@
batchRerunOptionsIsOpen = true
}
},
...(!operatingUser.current?.operator
...($roleCaps.code
? [
{
// Operators are rejected by the endpoint, so offering it would only 403.
// A role without `code` is rejected by the endpoint, so offering it would only 403.
displayName: 'Resolve failed or canceled jobs',
action: () => (
(manualSelectionMode = 'resolve'),
@@ -52,7 +52,9 @@
})
.catch((error) => {
console.error('Save failed:', error)
sendUserToast(error?.message ?? 'Save failed', true)
// An API error's message is only the status text; the server's reason is its body.
const body = typeof error?.body === 'string' && error.body ? error.body : undefined
sendUserToast(body ?? error?.message ?? 'Save failed', true)
saveStatus = 'error'
statusTimeout = setTimeout(() => {
saveStatus = null
@@ -15,6 +15,7 @@
import { sendUserToast } from '$lib/toast'
import { canEditItem } from '$lib/editRights'
import { userWorkspaces } from '$lib/stores'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { Save } from 'lucide-svelte'
import VariableForm from './VariableForm.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
@@ -141,8 +142,12 @@
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 user = acting.in(ws)
const caps = capabilitiesIn(
user,
$userWorkspaces.find((w) => w.id === ws)
)
return canEditItem('variable', editPath ?? '', perms, user, caps)
}
const dirtyWorkspaces = $derived(
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
@@ -10,7 +10,8 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Loader2, RotateCcw } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { type UserExt } from '$lib/stores'
import { type UserExt, userWorkspaces } from '$lib/stores'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { isOwner } from '$lib/utils'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import EncryptedDraftField from './EncryptedDraftField.svelte'
@@ -66,6 +67,12 @@
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
let caps = $derived(
capabilitiesIn(
actingUser ?? undefined,
$userWorkspaces.find((w) => w.id === ws)
)
)
// Loading the deployed secret overwrites the draft row this form shares with the AI
// chat, so every path that would trigger it has to be blocked while that row stages a
@@ -115,9 +122,9 @@ stretch the hit area over the whole row and the alert. The toggle carries its na
on:change={(e) => onSecretChange?.(e.detail)}
bind:checked={variable.is_secret}
disabled={unsecureNeedsLoad ||
(edit && (!actingUser || actingUser.operator || isEncryptedDraftValue(variable.value)))}
(edit && (!actingUser || !caps.code || isEncryptedDraftValue(variable.value)))}
/>
{#if unsecureNeedsLoad && can_write && !actingUser?.operator}
{#if unsecureNeedsLoad && can_write && caps.code}
<span class="text-2xs text-secondary">
Load the secret value or enter a new one to make it non-secret
</span>
@@ -163,8 +170,8 @@ stretch the hit area over the whole row and the alert. The toggle carries its na
>
Reset
</Button>
{:else if actingUser?.operator}
<div class="p-2 border">Operators cannot load secret value</div>
{:else if !caps.code}
<div class="p-2 border">Your role cannot load secret values</div>
{:else}
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
Load secret value<Tooltip>Will generate an audit log</Tooltip>
@@ -26,7 +26,7 @@
import { Alert, Button, Tab } from '$lib/components/common'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import Tabs from '$lib/components/common/tabs/TabsV2.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import {
classNames,
getModifierKey,
@@ -883,7 +883,7 @@
/>
<!-- {$focusedGrid?.parentComponentId} -->
{#if !$userStore?.operator}
{#if $roleCapabilities.apps === 'full'}
{#if $appStore}
<AppEditorHeader
{ownsPageHeader}
@@ -3,7 +3,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import Badge from '$lib/components/Badge.svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { AlertTriangle, Loader2 } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -34,7 +34,9 @@
const operatingWorkspaceStore = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
// The server forces a composed app into sandbox isolation.
const composeOnlyApps = $derived($roleCaps.apps === 'compose')
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -383,7 +385,7 @@
rightTooltip:
"Controls what the app's browser-side code can reach in each viewer's browser, separately from who its runnables run as. A sandboxed app gets a narrowly scoped token on every surface, public URL and in-workspace. On by default for new apps. Turn it off only if the app needs IndexedDB, third-party sign-in or OAuth redirects."
}}
checked={$operatorBuilderApps || policy.sandbox == true}
checked={composeOnlyApps || policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
// Counted where the toggle is flipped rather than where the policy is
@@ -406,19 +408,19 @@
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
}
}}
disabled={!savedApp || $operatorBuilderApps}
disabled={!savedApp || composeOnlyApps}
/>
{#if $operatorBuilderApps || newApp}
{#if composeOnlyApps || newApp}
<div class="text-2xs text-secondary mt-1">
{#if $operatorBuilderApps}
Required for apps built by operators, and cannot be turned off.
{#if composeOnlyApps}
Required for apps composed by your role, and cannot be turned off.
{/if}
{#if newApp}
Takes effect when you first deploy this app.
{/if}
</div>
{/if}
{#if !$operatorBuilderApps && policy.sandbox != true}
{#if !composeOnlyApps && policy.sandbox != true}
<div class="mt-3">
<Alert type="warning" title="Not isolated" size="xs">
The app's frontend code runs with each viewer's full Windmill permissions. A future
@@ -8,7 +8,7 @@
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore } from '$lib/stores'
import { roleCapabilities, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { Globe, Loader2, Save } from 'lucide-svelte'
import { deployRawAppValue } from '$lib/rawAppDeploy'
@@ -111,7 +111,7 @@
{/if}
{#snippet actions()}
{#if !$userStore?.operator}
{#if $roleCapabilities.apps === 'full'}
<Button
on:click={saveApp}
startIcon={{ icon: isDraftOnly ? Save : Globe }}
@@ -15,7 +15,7 @@
import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte'
import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte'
import { defaultScripts } from '$lib/stores'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
import type { Preview } from '$lib/gen'
import type { InlineScript } from '../../sharedTypes'
@@ -39,7 +39,8 @@
let picker: Drawer | undefined = $state(undefined)
const dispatch = createEventDispatcher()
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
const composeOnlyApps = $derived($roleCaps.apps === 'compose')
async function inferInlineScriptSchema(
language: Preview['language'],
@@ -106,7 +107,7 @@
<Tabs bind:selected={tab}>
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
{#if !$operatorBuilderApps}
{#if !composeOnlyApps}
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{/if}
</Tabs>
@@ -128,13 +129,13 @@
<div class="flex flex-col px-4 gap-2 text-sm" id="app-editor-empty-runnable">
<div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header">
<div class="font-bold items-baseline truncate">
{$operatorBuilderApps ? 'Choose a script or flow' : 'Choose a language'}
{composeOnlyApps ? 'Choose a script or flow' : 'Choose a language'}
</div>
<div class="flex gap-2">
{#if showScriptPicker}
<RunnableSelector {unusedInlineScripts} {rawApps} on:pick hideCreateScript />
{/if}
{#if !$operatorBuilderApps}
{#if !composeOnlyApps}
<Button
on:click={() => picker?.openDrawer()}
size="xs"
@@ -161,7 +162,7 @@
<!-- Operators with builder rights compose runnables that already exist: the language pickers
below all author code, which the backend refuses from them. -->
<div class="flex flex-row w-full gap-8" class:hidden={$operatorBuilderApps}>
<div class="flex flex-row w-full gap-8" class:hidden={composeOnlyApps}>
<div id="app-editor-backend-runnables">
<div class="mb-1 text-sm font-semibold flex gap-4">Backend <DefaultScripts /> </div>
@@ -18,7 +18,7 @@
CtxAppInput
} from '../../inputType'
import type { AppViewerContext } from '../../types'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { createEventDispatcher } from 'svelte'
import { deepEqual } from 'fast-equals'
import { computeFields } from './utils'
@@ -37,7 +37,7 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
interface Props {
runnable: RunnableByPath
@@ -363,7 +363,7 @@
>
Edit
</Button>
{#if !$operatorBuilderApps}
{#if $roleCaps.apps !== 'compose'}
<Button
size="xs"
variant="default"
@@ -12,10 +12,10 @@
import { emptySchema } from '$lib/utils'
import { loadSchema } from '$lib/infer'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
import { buildPathRunnableSelection } from './runnableSelectorUtils'
type TabType = 'hubscripts' | 'hubflows' | 'workspacescripts' | 'workspaceflows' | 'inlinescripts'
@@ -165,7 +165,7 @@
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
{/if}
<Tab value="workspaceflows" label="Workspace Flows" icon={Building} />
{#if !$operatorBuilderApps}
{#if $roleCaps.apps !== 'compose'}
{#if !onlyFlow}
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { userStore, workspaceStore } from '$lib/stores'
import { roleCapabilities, workspaceStore } from '$lib/stores'
import { goto } from '$app/navigation'
import Button from '$lib/components/common/button/Button.svelte'
import FolderPicker from '$lib/components/FolderPicker.svelte'
@@ -91,8 +91,8 @@
</section>
{/if}
{#if !$userStore?.operator}
<!-- Operators only view existing pipelines — opening an arbitrary
{#if $roleCapabilities.code}
<!-- A role without code only views existing pipelines: opening an arbitrary
folder is a build-a-new-pipeline affordance. -->
<section class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">Pick or create a folder</h3>
@@ -6,7 +6,7 @@
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import type { ListableResource } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { Pen } from 'lucide-svelte'
import Button from '../button/Button.svelte'
@@ -119,7 +119,7 @@
canWrite: agent.canWrite,
draftOnly: Boolean(agent.draft_only),
wsSpecific: agent.ws_specific,
operator: $userStore?.operator,
noCode: !$roleCapabilities.code,
onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'),
onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'),
onHistory: () => history?.open(),
@@ -5,8 +5,8 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { AppService, type ListableApp } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { roleCanAuthor } from '$lib/editRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
@@ -68,7 +68,9 @@
}: Props = $props()
const dispatch = createEventDispatcher()
const operatorBuilderApps = useOperatorBuilderApps()
// A compose-only app role authors full-code apps, never low-code ones.
const canAuthorApp = $derived(roleCanAuthor(app.raw_app ? 'raw_app' : 'app', $roleCapabilities))
const fullApps = $derived($roleCapabilities.apps === 'full')
let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined)
let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined)
@@ -174,7 +176,7 @@
</Button>
</div>
{/if}
{#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
{#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
<EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} />
{/if}
</span>
@@ -200,7 +202,7 @@
})
},
disabled: !showEditButton,
hide: $userStore?.operator
hide: !fullApps
},
{
displayName: 'Delete',
@@ -221,12 +223,16 @@
type: 'delete',
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
// discarding it never requires write permission on the path. The server
// lets one's own draft go without authoring rights; only a legacy one needs them.
disabled: !showEditButton,
hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app)
hide:
!canAuthorApp &&
app.draft_users?.length === 1 &&
app.draft_users[0]?.username == null
},
{
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
icon: File,
action: () => {
loadAppJson()
@@ -241,7 +247,7 @@
icon: GitFork,
href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator
hide: !fullApps
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
@@ -250,7 +256,7 @@
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
hide:
$userStore?.operator ||
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
},
@@ -261,7 +267,7 @@
moveDrawer.openDrawer(path, summary, 'app')
},
disabled: !canEdit,
hide: $userStore?.operator
hide: !fullApps
},
...(isDeployable('app', path, await getDeployUiSettings())
? [
@@ -271,12 +277,12 @@
action: () => {
deploymentDrawer.openDrawer(path, 'app')
},
hide: $userStore?.operator
hide: !fullApps
}
]
: []),
{
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
icon: FileJson,
action: () => {
loadAppJson()
@@ -286,7 +292,7 @@
displayName: 'Deployments',
icon: History,
action: () => appDeploymentHistory?.open(),
hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app)
hide: !canAuthorApp
},
{
displayName: 'Permissions',
@@ -294,7 +300,7 @@
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'app')
},
hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app)
hide: !canAuthorApp
},
{
displayName: 'Copy path',
@@ -340,7 +346,7 @@
},
type: 'delete',
disabled: !canEdit,
hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app)
hide: !canAuthorApp
}
]
}}
@@ -7,7 +7,7 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
@@ -207,8 +207,8 @@
</Button>
</div>
{/if}
<!-- Hidden from every operator, builder rights included. -->
{#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<!-- Hidden from every role without code, flow composers included. -->
{#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
</span>
@@ -234,7 +234,7 @@
})
},
disabled: !showEditButton,
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: 'Delete',
@@ -279,7 +279,7 @@
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'flow', path),
hide:
$userStore?.operator ||
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
},
@@ -287,7 +287,7 @@
displayName: 'Audit logs',
icon: Eye,
href: `/audit_logs?resource=${path}`,
hide: $userStore?.operator
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
},
{
displayName: 'Move/Rename',
@@ -314,7 +314,7 @@
deploymentDrawer.openDrawer(path, 'flow')
},
disabled: archived,
hide: $userStore?.operator
hide: $roleCapabilities.flows !== 'full'
}
]
: []),
@@ -333,7 +333,7 @@
scheduleEditor?.openNew(true, path)
},
disabled: archived,
hide: hideForOperator
hide: hideForOperator || !$roleCapabilities.manage_schedules
},
{
displayName: 'Permissions',
@@ -6,7 +6,7 @@
planPipelineDelete,
type PipelineDeletePlan
} from '$lib/components/assets/AssetGraph/pipelineDelete'
import { userStore, workspaceStore } from '$lib/stores'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Trash } from 'lucide-svelte'
import Row from './Row.svelte'
@@ -78,7 +78,7 @@
{depth}
>
{#snippet actions()}
{#if !$userStore?.operator}
{#if $roleCapabilities.code}
<Dropdown
items={[
{
@@ -11,6 +11,7 @@
import {
disableHubStore,
hubBaseUrlStore,
roleCapabilities,
userStore,
userWorkspaces,
workspaceStore
@@ -243,7 +244,7 @@
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator}
{#if $roleCapabilities.code}
{#if showEditButton}
{#if script.use_codebase}
<Badge
@@ -299,7 +300,7 @@
})
},
disabled: !showEditButton,
hide: $userStore?.operator
hide: !$roleCapabilities.code
},
{
displayName: 'Delete',
@@ -352,7 +353,7 @@
icon: GitFork,
href: `/scripts/add?template=${script.path}`,
disabled: !showEditButton,
hide: $userStore?.operator
hide: !$roleCapabilities.code
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
@@ -361,7 +362,7 @@
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'script', script.path),
hide:
$userStore?.operator ||
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
},
@@ -372,7 +373,7 @@
moveDrawer.openDrawer(script.path, script.summary, 'script')
},
disabled: !owner || script.archived || !canEdit,
hide: $userStore?.operator
hide: !$roleCapabilities.code
},
...(isDeployable('script', script.path, await getDeployUiSettings())
? [
@@ -383,7 +384,7 @@
deploymentDrawer.openDrawer(script.path, 'script')
},
disabled: script.archived,
hide: $userStore?.operator
hide: !$roleCapabilities.code
}
]
: []),
@@ -403,7 +404,7 @@
displayName: 'Audit logs',
icon: Eye,
href: `/audit_logs?resource=${script.path}`,
hide: $userStore?.operator
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
},
{
displayName: 'Schedule',
@@ -412,7 +413,7 @@
scheduleEditor?.openNew(false, script.path)
},
disabled: script.archived,
hide: $userStore?.operator
hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules
},
{
displayName: 'Permissions',
@@ -421,7 +422,7 @@
shareModal.openDrawer && shareModal.openDrawer(script.path, 'script')
},
disabled: script.archived,
hide: $userStore?.operator
hide: !$roleCapabilities.code
},
{
displayName: 'Copy path',
@@ -454,7 +455,7 @@
}
},
// Operators can't write scripts, so they have nothing to publish.
hide: $disableHubStore || $userStore?.operator
hide: $disableHubStore || !$roleCapabilities.code
},
{
displayName: script.archived ? 'Unarchive' : 'Archive',
@@ -466,7 +467,7 @@
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator
hide: !$roleCapabilities.code
},
...($userStore?.is_admin || $userStore?.is_super_admin
@@ -485,7 +486,7 @@
},
type: dlt,
disabled: !canEdit,
hide: $userStore?.operator
hide: !$roleCapabilities.code
}
]
: [])
@@ -5,7 +5,7 @@
import { AIBtnClasses } from './chat/AIButtonStyle'
import { prefersSessionHandoff } from './chat/global/gate'
import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
import { roleCapabilities } from '$lib/stores'
interface Props {
label?: string
initialInput?: string
@@ -16,7 +16,7 @@
// The label stays short ("Ask AI") for the search bar's inline row; the hover
// text is where "new AI session" fits.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
const handsOffToSession = $derived(prefersSessionHandoff(!$roleCapabilities.code))
export function onClick() {
// No item to preview here — this carries a question, not a target — so the
@@ -97,7 +97,7 @@ import { untrack } from 'svelte'
import { fromStore, get } from 'svelte/store'
import type { ProviderToolSummary } from './agentContext'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { roleCapabilities, workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
@@ -173,11 +173,6 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout
import { appendAttachedFilesRoster } from './files/fileTools'
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
import {
navigationOperatorBuilderApps,
navigationOperatorBuilderFlows,
navigationOperatorBuilderRights
} from '$lib/operatorWriteRights'
// Compaction of the stored history: once the projected request size
// (contextTokens — the provider's report when current, a fresh chars/4
@@ -310,9 +305,7 @@ export function supportsPlanMode(mode: AIMode): boolean {
return PLAN_MODES.has(mode)
}
const isOperatorBuilder = fromStore(navigationOperatorBuilderRights)
const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows)
const isOperatorBuilderApps = fromStore(navigationOperatorBuilderApps)
const navCapabilities = fromStore(roleCapabilities)
export function isAIModeVisible(mode: AIMode): boolean {
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
@@ -1486,23 +1479,22 @@ export class AIChatManager implements ChatViewHost {
.map((s) => ({ ...s, kind: 'skill' as const }))
])
// The flow, app and script builders all write code, which the backend refuses from an
// operator with builder rights: leaving them reachable would only produce work that cannot be
// deployed.
// The flow, app and script builders all write code, which the backend refuses from a role
// that only composes: leaving them reachable would only produce work that cannot be deployed.
allowedModes: Record<AIMode, boolean> = $derived({
script:
this.flowAiChatHelpers === undefined &&
this.scriptEditorOptions !== undefined &&
!this.disabledModes.script &&
!isOperatorBuilder.current,
navCapabilities.current.code,
flow:
this.flowAiChatHelpers !== undefined &&
!this.disabledModes.flow &&
!isOperatorBuilderFlows.current,
navCapabilities.current.flows === 'full',
app:
this.appAiChatHelpers !== undefined &&
!this.disabledModes.app &&
!isOperatorBuilderApps.current,
navCapabilities.current.apps === 'full',
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
API: !this.disabledModes.API,
@@ -114,7 +114,16 @@ vi.mock('$lib/stores', () => {
// non-membership once this has resolved.
usersWorkspaceStore: readable({ workspaces: [] as unknown[] }),
NON_MEMBER_USERNAME: 'superadmin',
enterpriseLicense: readable(undefined)
enterpriseLicense: readable(undefined),
// The admin above holds every role capability.
roleCapabilities: readable({
code: true,
flows: 'full',
apps: 'full',
manage_schedules: true,
manage_triggers: true,
visibility: {}
})
}
})
@@ -3,7 +3,7 @@
The Permissions section of the assistant settings modal: what the user's role lets them create
and edit in the chat's workspace, next to what the assistant can make for them from the chat.
The assistant acts with the user's own rights, but its write tools go through drafts, so for
operators the second column is narrower.
a restricted role the second column can be narrower.
-->
<script lang="ts">
import { Badge, Section } from '$lib/components/common'
@@ -11,6 +11,7 @@ operators the second column is narrower.
import { userWorkspaces } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import { capitalize } from '$lib/utils/capitalize'
import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities'
import { OPERATOR_SETTINGS_LOCATION, permissionRows, roleName } from './assistantPermissions'
let { ws }: { ws: string } = $props()
@@ -18,9 +19,11 @@ operators the second column is narrower.
const acting = useActingUser(() => ws)
let user = $derived(acting.current)
let operatorSettings = $derived($userWorkspaces.find((w) => w.id === ws)?.operator_settings)
let role = $derived(user ? roleName(user) : undefined)
let rows = $derived(user ? permissionRows(user, operatorSettings) : [])
let entry = $derived($userWorkspaces.find((w) => w.id === ws))
let caps = $derived(capabilitiesIn(user, entry))
let restricted = $derived(isRestricted(caps))
let role = $derived(user ? roleName(user, entry) : undefined)
let rows = $derived(user ? permissionRows(user, caps) : [])
let chatNarrower = $derived(rows.some((r) => r.user && !r.assistant))
</script>
@@ -31,7 +34,7 @@ operators the second column is narrower.
>
{#snippet action()}
{#if role}
<Badge color={role === 'operator' ? 'yellow' : 'gray'}>{capitalize(role)}</Badge>
<Badge color={restricted ? 'yellow' : 'gray'}>{capitalize(role)}</Badge>
{/if}
{/snippet}
@@ -76,9 +79,9 @@ operators the second column is narrower.
Items in folders you can't read are hidden from you and from the assistant. Someone with
access to the folder can run them.
</p>
{#if role === 'operator'}
{#if restricted}
<p>
A workspace admin can give operators more rights in {OPERATOR_SETTINGS_LOCATION}.
A workspace admin can give your role more rights in {OPERATOR_SETTINGS_LOCATION}.
</p>
{/if}
</div>
@@ -1,10 +1,10 @@
import type { OperatorSettings } from '$lib/gen'
import type { UserExt } from '$lib/stores'
import type { RoleCapabilities } from '$lib/gen'
import type { UserExt, UserWorkspace } from '$lib/stores'
import { roleCanAuthor, type EditKind } from '$lib/editRights'
import { capabilitiesForRole } from './global/sessionAccess'
import type { SessionAccess } from './sessionCapabilities'
/** Where a workspace admin changes what operators may do. Named in the prompt and the
/** Where a workspace admin changes what a role may do. Named in the prompt and the
* permissions section alike, so the two send the user to the same place. */
export const OPERATOR_SETTINGS_LOCATION = 'Workspace settings → Users → Operator settings'
@@ -25,35 +25,35 @@ const ROWS: PermissionRow[] = [
{ kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_variable_draft') }
]
export type RoleName = 'admin' | 'developer' | 'operator'
/** `admin`, or the member's workspace role. A server that predates roles names none, and its
* role is then `operator` or `developer` from the legacy flag. */
export function roleName(
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>
): RoleName {
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>,
workspace: Pick<UserWorkspace, 'role'> | undefined
): string {
if (user.is_admin || user.is_super_admin) return 'admin'
return user.operator ? 'operator' : 'developer'
return workspace?.role ?? (user.operator ? 'operator' : 'developer')
}
/**
* Per item kind, whether the user's role lets them create and edit it themselves, and whether
* the chat can for them. Both act with the user's rights, but the user's column follows the
* item handlers (`roleCanAuthor`) and the chat's the draft endpoint every write tool goes
* through (`capabilitiesForRole`), which refuses operators more than the handlers do.
* through (`capabilitiesForRole`), which refuses a restricted role more than the handlers do.
*/
export function permissionRows(
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>,
operatorSettings: OperatorSettings | null | undefined
user: Pick<UserExt, 'is_admin' | 'is_super_admin'>,
caps: RoleCapabilities
): { label: string; user: boolean; assistant: boolean }[] {
const access = capabilitiesForRole({
isAdmin: !!user.is_admin || !!user.is_super_admin,
operator: !!user.operator,
// Deploy rules gate deploying, not the drafts these rows are about.
deployRulesPass: true,
operatorSettings
capabilities: caps
})
return ROWS.map((row) => ({
label: row.label,
user: roleCanAuthor(row.kind, user, operatorSettings),
user: roleCanAuthor(row.kind, caps),
assistant: row.assistant(access)
}))
}
@@ -79,7 +79,9 @@ import {
} from '$lib/components/raw_apps/dataTableRefUtils'
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy'
import { operatorBuilderAppsIn } from '$lib/operatorWriteRights'
import { capabilitiesInWorkspace } from '$lib/operatorWriteRights'
import { capabilitiesIn } from '$lib/roleCapabilities'
import type { RoleCapabilities } from '$lib/gen'
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
import { dataUrlToImagePart, normalizeImageDataUrl, type AttachedImage } from '../imageUtils'
import { sanitizeAttachmentName, textLineCount, type AttachedTextFile } from '../textFileUtils'
@@ -1493,11 +1495,13 @@ const buildGlobalSystemPrompt = (
const canDraftTriggers = !access || access.has('write_trigger_draft')
const canDraftResources = !access || access.has('write_resource_draft')
const canDraftVariables = !access || access.has('write_variable_draft')
// A builder operator: drafts of the kinds the workspace lets them compose, and nothing else.
const inlineFlowCode = !access || access.has('write_inline_flow_code')
const inlineAppCode = !access || access.has('write_inline_app_code')
// A role that composes a kind out of deployed runnables, with no inline code of its own.
// Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the
// other kinds keep `canWriteDraft`.
const buildsFlows = canWriteFlowDraft && !canWriteDraft
const buildsApps = canWriteAppDraft && !canWriteDraft
const buildsFlows = canWriteFlowDraft && !inlineFlowCode
const buildsApps = canWriteAppDraft && !inlineAppCode
const canWriteAnyDraft =
canWriteDraft ||
canWriteFlowDraft ||
@@ -1537,6 +1541,11 @@ const buildGlobalSystemPrompt = (
(k): k is string => !!k
)
const canRunPreview = !access || access.has('run_preview')
const canRunFlowPreview = !access || access.has('run_flow_preview')
// A role may code without composing flows, so the flow test tool is named only where it ships.
const testRunTools = canRunFlowPreview
? 'test_run_script, test_run_flow, or test_run_step'
: 'test_run_script or test_run_step'
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 and
@@ -1598,7 +1607,7 @@ 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 ${listJoin(refusedKinds, 'disjunction')} 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 ${listJoin(refusedCode)} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor.${when(
: `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 ${listJoin(refusedKinds, 'disjunction')} 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 ${listJoin(refusedCode)} outside this chat too, so for those suggest asking a workspace admin, who can grant their role more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor.${when(
canManageSchedules,
' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.'
)} When you decline something, give the reason stated here and keep to it.${when(
@@ -1685,7 +1694,11 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i
)}${when(
canWriteDraft,
`
- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.
- Use search_resource_types before write_resource${
canDraftTriggers
? ', and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.'
: '.'
}
- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.
- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing.
- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.
@@ -1695,7 +1708,7 @@ ${pipelineBullet}`
)}${when(
canRunPreview && canWriteDraft,
`
- 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.
- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} 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(
buildsFlows && (!access || access.has('run_flow_preview')),
@@ -1712,7 +1725,7 @@ ${pipelineBullet}`
)}${when(
canRunPreview,
`
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.`
- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${canRunFlowPreview ? 'test_run_script, test_run_flow, test_run_step' : 'test_run_script, test_run_step'}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.`
)}${when(
!canRunPreview,
`
@@ -1762,7 +1775,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(
- Use read_flow_module_code${when(inlineFlowCode && canWriteFlowDraft, ' and set_flow_module_code')} for inline script bodies.${when(
canWriteFlowDraft,
`
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.`
@@ -1773,7 +1786,7 @@ Raw apps:
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when(
canWriteAppDraft,
`
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. ${canDeployAnyKind ? 'deploy_workspace_item' : 'A deploy'} says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${
@@ -1781,7 +1794,7 @@ Raw apps:
? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.'
: 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.'
}
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
${when(canDeployAnyKind, '- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.')}`
)}
Data Tables:
@@ -2240,7 +2253,7 @@ async function saveAppDraft(
path: string,
value: AppDraftValue
): Promise<DraftPersistResult> {
if (operatorBuilderAppsIn(workspace)) {
if (capabilitiesInWorkspace(workspace).apps === 'compose') {
const role = await roleForWorkspace(workspace)
value.policy ??= {}
conformBuilderAppPolicy(value.policy, role.kind === 'resolved' ? role.user : undefined)
@@ -3084,40 +3097,25 @@ function allowedTriggerKinds(): PageTriggerKind[] {
return (Object.keys(TRIGGER_PAGES) as PageTriggerKind[]).filter((k) => ee || !TRIGGER_PAGES[k].ee)
}
// Pages an operator may reach: exactly the ones enabled in the workspace's
// operator_settings, the same source the sidebar gates on. Operators are never admins, so
// workspace_settings is excluded whatever the settings say.
function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] {
const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings
return OPEN_PAGE_NAMES.filter(
(p) =>
p !== 'workspace_settings' && settings?.[p as keyof NonNullable<typeof settings>] === true
// Pages a role may reach: the ones its visibility shows, the same source the sidebar gates
// on. workspace_settings is admin/superadmin only, and compare deploys code, so it follows
// `code`.
function roleOpenPages(caps: RoleCapabilities, isAdmin: boolean): OpenPageName[] {
return OPEN_PAGE_NAMES.filter((p) =>
p === 'workspace_settings' ? isAdmin : p === 'compare' ? caps.code : caps.visibility[p]
)
}
// workspace_settings is admin/superadmin only; the rest are open to any non-operator member.
const NON_ADMIN_OPEN_PAGES = new Set<OpenPageName>([
'runs',
'assets',
'schedules',
'variables',
'resources',
'audit_logs',
'folders',
'groups',
'triggers',
'compare'
])
// What to advertise before the role in `workspaceId` has been resolved.
// `user_workspaces` nulls operator_settings for a workspace the user is not an operator
// in, so a non-null value narrows an operator correctly; the converse does not hold,
// which is why the real role is still fetched.
// What to advertise before the role in `workspaceId` has been resolved: the role the
// workspace list names, which is why the real role is still fetched.
function restrictedOpenPages(workspaceId: string | undefined): OpenPageName[] {
if (get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings) {
return operatorOpenPages(workspaceId)
}
return OPEN_PAGE_NAMES.filter((p) => NON_ADMIN_OPEN_PAGES.has(p))
return roleOpenPages(
capabilitiesIn(
undefined,
get(userWorkspaces).find((w) => w.id === workspaceId)
),
false
)
}
// The role to gate `workspaceId` on. `userStore` answers only when it describes that same
@@ -3209,14 +3207,12 @@ async function allowedOpenPages(
return { pages: restrictedOpenPages(workspaceId), roleUnverified: false }
}
const u = role.user
if (u.operator) return { pages: operatorOpenPages(workspaceId), roleUnverified: false }
const isAdmin = !!u.is_admin || !!u.is_super_admin || !!get(superadmin)
return {
pages: OPEN_PAGE_NAMES.filter(
(p) => NON_ADMIN_OPEN_PAGES.has(p) || (isAdmin && p === 'workspace_settings')
),
roleUnverified: false
}
const caps = capabilitiesIn(
{ ...u, is_admin: isAdmin },
get(userWorkspaces).find((w) => w.id === workspaceId)
)
return { pages: roleOpenPages(caps, isAdmin), roleUnverified: false }
}
// The Runs page only offers its cross-workspace filter to a superadmin or devops user in
@@ -3457,8 +3453,8 @@ function buildOpenPageDefSchema(
roleUnverified = false
): z.ZodTypeAny {
const full = openPageFullSchema.shape as Record<string, z.ZodTypeAny>
// z.enum() rejects an empty list, and a user with no reachable pages (e.g. an operator
// with every operator_settings flag off) yields exactly that. Fall back to a plain
// z.enum() rejects an empty list, and a user with no reachable pages (e.g. a role with
// every visibility flag off) yields exactly that. Fall back to a plain
// string so schema-building can't throw; the handler still fails closed on every page.
// The model reads this before it can reach the handler's message, so an unresolved role
// must not be described as a denial here either.
@@ -3816,9 +3812,17 @@ export const globalTools: SessionTool<{}>[] = [
? `${parsed.subject} (${parsed.language})`
: parsed.subject
toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` })
// Past the gate without `write_draft` means a builder: guidance for inline code would
// steer the model at writes the server refuses and tools it was not given.
const composeOnly = !!access && !access.has('write_draft')
// Guidance for inline code would steer a composing role at writes the server refuses
// and tools it was not given.
const composeOnly =
!!access &&
!access.has(
parsed.subject === 'flow'
? 'write_inline_flow_code'
: parsed.subject === 'app'
? 'write_inline_app_code'
: 'write_draft'
)
return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly)
}
},
@@ -4594,7 +4598,7 @@ export const globalTools: SessionTool<{}>[] = [
def: createToolDef(
rebaseDraftSchema,
'rebase_draft',
'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.',
'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when a deploy reports the draft was started from an older deployed version.',
{ strict: false }
),
showDetails: true,
@@ -4812,7 +4816,7 @@ export const globalTools: SessionTool<{}>[] = [
}
},
{
requires: WRITE_DRAFT,
requires: [...WRITE_FLOW_DRAFT, 'write_inline_flow_code'],
def: createToolDef(
setFlowModuleCodeSchema,
'set_flow_module_code',
@@ -5364,11 +5368,14 @@ async function canEditItemPath(
if (role.kind === 'not_a_member') return 'denied'
if (role.kind !== 'resolved' || !role.user) return 'unverified'
const user = role.user
const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
if (!roleCanDraft(kind, user, settings)) return 'denied'
const caps = capabilitiesIn(
user,
get(userWorkspaces).find((w) => w.id === workspace)
)
if (!roleCanDraft(kind, caps)) 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 (canDraftItem(kind, path, {}, user, settings)) return 'allowed'
if (canDraftItem(kind, path, {}, user, caps)) return 'allowed'
try {
const extraPerms =
kind === 'script'
@@ -5376,7 +5383,7 @@ async function canEditItemPath(
: kind === 'flow'
? (await FlowService.getFlowByPath({ workspace, path })).extra_perms
: (await AppService.getAppByPath({ workspace, path })).extra_perms
return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied'
return canDraftItem(kind, path, extraPerms, user, caps) ? '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'
@@ -7145,7 +7152,7 @@ async function initApp(
})
// A builder can save no inline runnable, so they start from a static page instead of the tour.
const builder = operatorBuilderAppsIn(workspace)
const builder = capabilitiesInWorkspace(workspace).apps === 'compose'
const value: AppDraftValue = {
summary,
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] },
@@ -10,6 +10,8 @@ vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })
import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess'
import { clearWorkspaceRoleCache } from '$lib/user'
import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities'
import type { OperatorSettings, RoleCapabilities } from '$lib/gen'
type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean }
@@ -57,6 +59,8 @@ describe('resolveSessionAccess', () => {
'write_app_draft',
'write_draft',
'write_flow_draft',
'write_inline_app_code',
'write_inline_flow_code',
'write_resource_draft',
'write_schedule_draft',
'write_trigger_draft',
@@ -92,20 +96,27 @@ describe('resolveSessionAccess', () => {
])
})
// Both spellings of `authed.is_admin`, which the draft path honours and the handlers
// refusing `authed.is_operator` do not — the session path never clears that flag.
// Both spellings of `authed.is_admin`: `caller_capabilities` gives an admin every role
// capability, whatever role the membership row names.
it.each([{ is_admin: true }, { is_super_admin: true }])(
'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)',
'gives an admin whose role is operator everything (%o)',
async (role) => {
const caps = await capabilitiesFor({ ...role, operator: true })
expect([...caps].sort()).toEqual([
'admin',
'deploy',
'manage_apps',
'manage_code',
'manage_flows',
'manage_schedules',
'manage_triggers',
'run_flow_preview',
'run_preview',
'write_app_draft',
'write_draft',
'write_flow_draft',
'write_inline_app_code',
'write_inline_flow_code',
'write_resource_draft',
'write_schedule_draft',
'write_trigger_draft',
@@ -130,9 +141,8 @@ describe('resolveSessionAccess', () => {
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 }
capabilities: legacyOperatorCapabilities({ builder_flows: true } as OperatorSettings)
})
expect([...caps].sort()).toEqual([
'deploy',
@@ -152,9 +162,8 @@ describe('resolveSessionAccess', () => {
it('gives an app builder operator the app capabilities alone', () => {
const caps = capabilitiesForRole({
isAdmin: false,
operator: true,
deployRulesPass: true,
operatorSettings: { builder_apps: true }
capabilities: legacyOperatorCapabilities({ builder_apps: true } as OperatorSettings)
})
expect([...caps].sort()).toEqual([
'deploy',
@@ -173,9 +182,8 @@ describe('resolveSessionAccess', () => {
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 }
capabilities: legacyOperatorCapabilities({ manage_schedules: false } as OperatorSettings)
})
expect(caps.has('manage_schedules')).toBe(false)
expect(caps.has('write_schedule_draft')).toBe(false)
@@ -183,6 +191,20 @@ describe('resolveSessionAccess', () => {
expect(caps.has('write_trigger_draft')).toBe(true)
})
// A role that codes but builds no apps: scripts and flows, and nothing apps.rs refuses.
it('follows the capabilities of a custom role rather than the operator flag', () => {
const scripter: RoleCapabilities = { ...FULL_CAPABILITIES, apps: 'none' }
const caps = capabilitiesForRole({
isAdmin: false,
deployRulesPass: true,
capabilities: scripter
})
expect(caps.has('manage_code')).toBe(true)
expect(caps.has('manage_flows')).toBe(true)
expect(caps.has('manage_apps')).toBe(false)
expect(caps.has('write_app_draft')).toBe(false)
})
it('grants everything when the role cannot be resolved', async () => {
whoami.mockRejectedValueOnce(new Error('network'))
const access = await resolveSessionAccess('ws')
@@ -1,20 +1,18 @@
import { get } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import type { OperatorSettings } from '$lib/gen'
import type { RoleCapabilities } from '$lib/gen'
import { getWorkspaceRole } from '$lib/user'
import { roleCanAuthor, roleCanDraft } from '$lib/editRights'
import { capabilitiesIn, FULL_CAPABILITIES } from '$lib/roleCapabilities'
import { checkDeployRules } from '$lib/utils_workspace_deploy'
import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
type OperatorRights = Pick<
NonNullable<OperatorSettings>,
'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers'
>
const ALL_CAPABILITIES: SessionCapability[] = [
'write_draft',
'write_flow_draft',
'write_app_draft',
'write_inline_flow_code',
'write_inline_app_code',
'write_schedule_draft',
'write_trigger_draft',
'write_resource_draft',
@@ -40,60 +38,61 @@ export function fullSessionAccess(): SessionAccess {
/** Pure, so the profiles this can produce are enumerable rather than listed by hand.
* `isAdmin` is one field because the server's `authed.is_admin` is
* `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */
* `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. An admin holds every
* role capability, as `caller_capabilities` gives them. */
export function capabilitiesForRole(role: {
isAdmin: boolean
operator: boolean
deployRulesPass: boolean
/** The workspace's `operator_settings`, null for a non-operator. */
operatorSettings?: OperatorRights | null
capabilities: RoleCapabilities
}): SessionAccess {
const capabilities = new Set<SessionCapability>()
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)) {
const caps = role.isAdmin ? FULL_CAPABILITIES : role.capabilities
if (roleCanDraft('script', caps)) {
capabilities.add('write_draft')
}
if (roleCanDraft('flow', user, role.operatorSettings)) {
if (roleCanDraft('flow', caps)) {
capabilities.add('write_flow_draft')
}
if (roleCanDraft('raw_app', user, role.operatorSettings)) {
if (roleCanDraft('raw_app', caps)) {
capabilities.add('write_app_draft')
}
if (roleCanDraft('schedule', user, role.operatorSettings)) {
if (caps.flows === 'full') {
capabilities.add('write_inline_flow_code')
}
if (caps.apps === 'full') {
capabilities.add('write_inline_app_code')
}
if (roleCanDraft('schedule', caps)) {
capabilities.add('write_schedule_draft')
}
if (roleCanDraft('trigger', user, role.operatorSettings)) {
if (roleCanDraft('trigger', caps)) {
capabilities.add('write_trigger_draft')
}
if (roleCanDraft('resource', user, role.operatorSettings)) {
if (roleCanDraft('resource', caps)) {
capabilities.add('write_resource_draft')
}
if (roleCanDraft('variable', user, role.operatorSettings)) {
if (roleCanDraft('variable', caps)) {
capabilities.add('write_variable_draft')
}
if (authorsCode) {
if (roleCanAuthor('script', caps)) {
capabilities.add('run_preview')
capabilities.add('manage_code')
}
if (authorsFlows) {
if (roleCanAuthor('flow', caps)) {
capabilities.add('run_flow_preview')
capabilities.add('manage_flows')
}
if (roleCanAuthor('raw_app', user, role.operatorSettings)) {
if (roleCanAuthor('raw_app', caps)) {
capabilities.add('manage_apps')
}
if (roleCanAuthor('schedule', user, role.operatorSettings)) {
if (roleCanAuthor('schedule', caps)) {
capabilities.add('manage_schedules')
}
if (roleCanAuthor('trigger', user, role.operatorSettings)) {
if (roleCanAuthor('trigger', caps)) {
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.
// No role term: the rules are their own gate, and the handlers that also refuse a role
// say so through `manage_code`. Admins bypass the rules inside the check.
if (role.deployRulesPass) {
capabilities.add('deploy')
}
@@ -116,8 +115,10 @@ 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,
operatorSettings: get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
capabilities: capabilitiesIn(
me,
get(userWorkspaces).find((w) => w.id === workspace)
)
})
}
@@ -29,6 +29,8 @@ import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssemb
import { filterSessionTools, sessionToolAllowed } from './sessionToolset'
import { capabilitiesForRole, fullSessionAccess } from './sessionAccess'
import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities'
import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities'
import type { AuthorLevel, OperatorSettings, RoleCapabilities } from '$lib/gen'
const ASSEMBLY_OPTS = {
previewTools: true,
@@ -72,33 +74,40 @@ function accessWith(capabilities: SessionCapability[]): SessionAccess {
return new Set(capabilities)
}
const LEVELS: AuthorLevel[] = ['none', 'compose', 'full']
/** Every role a workspace can define, over the capabilities `capabilitiesForRole` reads: `full`
* flows or apps require `code`, as `RoleCapabilities::validate` refuses otherwise. */
const ROLES: RoleCapabilities[] = [false, true]
.flatMap((code) =>
LEVELS.flatMap((flows) =>
LEVELS.flatMap((apps) =>
[false, true].flatMap((manage_schedules) =>
[false, true].map((manage_triggers) => ({
...FULL_CAPABILITIES,
code,
flows,
apps,
manage_schedules,
manage_triggers
}))
)
)
)
)
.filter((c) => c.code || (c.flows !== 'full' && c.apps !== 'full'))
/** The profiles a real session can hold, keyed for the test name, over every combination
* of the role facts `capabilitiesForRole` reads. */
const REACHABLE_PROFILES = [
...new Map(
[false, true].flatMap((isAdmin) =>
[false, true].flatMap((operator) =>
// `checkDeployRules` bypasses on admin, so an admin has only the one outcome.
(isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) =>
// The operator rights a workspace can set: none, either builder right or both, either lock, both locks.
[
undefined,
{ builder_flows: true },
{ builder_apps: true },
{ builder_flows: true, builder_apps: true },
{ manage_schedules: false },
{ manage_triggers: false },
{ manage_schedules: false, manage_triggers: false }
].map((operatorSettings) => {
const access = capabilitiesForRole({
isAdmin,
operator,
deployRulesPass,
operatorSettings
})
return [[...access].sort().join(',') || 'none', access] as const
})
)
// `checkDeployRules` bypasses on admin, so an admin has only the one outcome.
(isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) =>
ROLES.map((capabilities) => {
const access = capabilitiesForRole({ isAdmin, deployRulesPass, capabilities })
return [[...access].sort().join(',') || 'none', access] as const
})
)
)
).entries()
@@ -306,14 +315,13 @@ describe('session tool policies', () => {
// The operator's own rights outrun the chat's tools: what they schedule in the UI must not
// be sent to an admin.
it("sends an operator to the UI for what their role allows and the chat's tools do not", () => {
const prompt = (operatorSettings: { manage_schedules?: boolean }): string =>
const prompt = (operatorSettings: Partial<OperatorSettings>): string =>
prepareGlobalSystemMessage(undefined, {
user: { username: 'op' },
access: capabilitiesForRole({
isAdmin: false,
operator: true,
deployRulesPass: true,
operatorSettings
capabilities: legacyOperatorCapabilities(operatorSettings as OperatorSettings)
})
}).content as string
@@ -9,41 +9,42 @@ import type { Tool } from './shared'
* what the model is offered and guarantees nothing.
*/
export type SessionCapability =
/** Passes drafts.rs `require_can_write_path` for a draft of any kind. A tool writing only
* schedule or trigger drafts requires the narrower capability below instead, which an
* operator can hold. */
/** Passes drafts.rs `require_can_write_path` for a script draft: the role's `code`. A tool
* writing another kind requires that kind's capability below instead. */
| '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. */
/** Passes `require_can_write_path` for a flow draft: `flows` at `compose` or above. At
* `compose` the server then refuses a draft carrying inline code. */
| 'write_flow_draft'
/** The same for a raw app draft, under `builder_apps`. */
/** The same for a raw app draft, under `apps`. */
| 'write_app_draft'
/** Passes `require_can_write_path` for a schedule draft: everyone `write_draft` covers, and an
* operator unless the workspace set `manage_schedules` to false. */
/** Inline code in a flow draft: `flows` at `full`. At `compose` the server validates the draft
* as a pure composition, even for a role that may write scripts. */
| 'write_inline_flow_code'
/** Inline and hub runnables in an app draft: `apps` at `full`. */
| 'write_inline_app_code'
/** Passes `require_can_write_path` for a schedule draft: the role's `manage_schedules`. */
| 'write_schedule_draft'
/** The same for a trigger draft, read from `manage_triggers`. */
| 'write_trigger_draft'
/** Passes `require_can_write_path` for a resource draft, which every operator does. */
/** Passes `require_can_write_path` for a resource draft, which every role does. */
| 'write_resource_draft'
/** The same for a variable draft. */
| 'write_variable_draft'
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
/** Passes the `code` 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`. */
/** Passes `run_preview_flow`'s `check_operator_can_build`: composed flows only at `compose`. */
| 'run_flow_preview'
/** Passes `check_deploy_rules`. */
| 'deploy'
/** Passes the operator refusal the script handlers make before creating or
/** Passes the `code` refusal the script 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`. */
/** Passes the flow handlers' `check_operator_can_build`. */
| 'manage_flows'
/** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */
/** Passes the raw app handlers' `check_operator_can_build`, read from `apps`. */
| 'manage_apps'
/** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose
* workspace set `operator_settings.manage_schedules` to false. */
/** Passes `gate_operator_writes` on the schedule router: the role's `manage_schedules`. */
| 'manage_schedules'
/** The same gate on the trigger routers, read from `manage_triggers`. */
| 'manage_triggers'
@@ -1,5 +1,6 @@
import { ResourceService } from '$lib/gen'
import { canEditItem } from '$lib/editRights'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { userWorkspaces, type UserExt } from '$lib/stores'
import { get } from 'svelte/store'
@@ -57,7 +58,10 @@ export type SkillListing = { skills: SkillResource[]; truncated: boolean }
* 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 caps = capabilitiesIn(
user,
get(userWorkspaces).find((w) => w.id === workspace)
)
const rows: SkillResource[] = []
for (let page = 1; page <= MAX_SKILLS_PAGES; page++) {
const resources = await ResourceService.listResource({
@@ -72,7 +76,7 @@ export async function listSkillResources(workspace: string, user?: UserExt): Pro
name: skillNameFromPath(r.path),
description: r.description ?? '',
editedAt: r.edited_at,
canWrite: canEditItem('resource', r.path, r.extra_perms, user, operatorSettings)
canWrite: canEditItem('resource', r.path, r.extra_perms, user, caps)
}))
)
if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false }
@@ -374,6 +374,7 @@
if (pendingRun) args = { ...untrack(() => pendingRun.proposedArgs) }
let canAuthorFlow = $derived(editRights.roleCanAuthor('flow'))
const caps = $derived(editRights.capabilities)
let moveDrawer: MoveDrawer | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
@@ -410,12 +411,7 @@
})
}
if (
flow &&
!actingUser?.operator &&
!isCloudHosted() &&
editInForkAllowed(workspace, $userWorkspaces)
) {
if (flow && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
@@ -460,8 +456,8 @@
return buttons
}
// Build app makes a low-code app, which no builder right covers.
if (!actingUser?.operator) {
// Build app starts from the full template, not the builder one a compose-only role deploys.
if (caps.apps === 'full') {
buttons.push({
label: 'Build app',
placement: 'menu',
@@ -530,11 +526,8 @@
})
}
// The builder right opens this menu to operators; audit logs stay behind their own setting.
if (
!actingUser?.operator ||
$userWorkspaces.find((w) => w.id === workspace)?.operator_settings?.audit_logs
) {
// A flow-composing role opens this menu; audit logs stay behind their own visibility flag.
if (caps.visibility.audit_logs) {
menuItems.push({
label: 'Audit logs',
Icon: Eye,
@@ -544,7 +537,7 @@
})
}
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !actingUser?.operator) {
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && caps.flows === 'full') {
menuItems.push({
label: 'Deploy to staging/prod',
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
@@ -670,7 +663,7 @@
<DetailPageLayout
bind:this={detailLayout}
bind:selected={rightPaneSelected}
isOperator={actingUser?.operator}
isOperator={!caps.code}
forceSmallScreen={chatInputEnabled}
isChatMode={chatInputEnabled}
flow_json={{
@@ -179,7 +179,8 @@
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 caps = $derived(editRights.capabilities)
// The edit actions also need a role that authors scripts.
const canEdit = $derived(can_write && editRights.roleCanAuthor('script'))
let deploymentInProgress = $state(false)
let expandedModuleLocks: Record<string, boolean> = $state({})
@@ -545,7 +546,7 @@
) {
const buttons: any = []
if (!topHash && script && !actingUser?.operator && !script.codebase) {
if (!topHash && script && caps.code && !script.codebase) {
buttons.push({
label: 'Fork',
description: `Start a new script from a copy of this one`,
@@ -561,12 +562,7 @@
})
}
if (
script &&
!actingUser?.operator &&
!isCloudHosted() &&
editInForkAllowed(workspace, $userWorkspaces)
) {
if (script && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
@@ -616,24 +612,26 @@
})
}
if (!actingUser?.operator) {
buttons.push({
label: 'Build app',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
},
disabled: !showEditButtons,
unifiedSize: 'md',
variant: 'subtle',
startIcon: LayoutDashboard
}
})
if (caps.code) {
// It starts from the full template, not the builder one a compose-only role deploys.
if (caps.apps === 'full')
buttons.push({
label: 'Build app',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
},
disabled: !showEditButtons,
unifiedSize: 'md',
variant: 'subtle',
startIcon: LayoutDashboard
}
})
if (script?.restart_unless_cancelled ?? false) {
buttons.push({
@@ -688,7 +686,7 @@
script: Script | undefined,
deployUiSettings: WorkspaceDeployUISettings | undefined
) {
if (!script || actingUser?.operator) return []
if (!script || !caps.code) return []
const menuItems: any = []
@@ -855,7 +853,7 @@
</DrawerContent>
</Drawer>
{#key script?.hash}
<DetailPageLayout bind:selected={rightPaneSelected} isOperator={actingUser?.operator}>
<DetailPageLayout bind:selected={rightPaneSelected} isOperator={!caps.code}>
{#snippet header({ wide }: { wide: boolean })}
<DetailPageHeader
{wide}
@@ -40,14 +40,11 @@
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const roleCaps = useRoleCapabilities()
const { flowStore, selectionManager, pathStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
@@ -408,11 +405,7 @@
// contain. Hand it to a session opened on that step rather than the
// docked chat, which sessions leave unmounted. Sent on arrival: the
// user already said what they wanted in the description field.
if (
!sessionScopedManager &&
sessionOpen &&
prefersSessionHandoff(actingUser?.operator)
) {
if (!sessionScopedManager && sessionOpen && prefersSessionHandoff(!$roleCaps.code)) {
void openSourceInSession(sessionOpen, {
previewParams: { selected: detail.moduleId },
seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions),
@@ -25,8 +25,8 @@ export function agentMenuItems(agent: {
/** Never deployed: no resource yet to hold permissions or to deploy onward. */
draftOnly?: boolean
wsSpecific?: boolean
/** Hides what an operator has no page for, as on a flow's menu. */
operator?: boolean
/** Hides what a role that cannot code has no page for, as on a flow's menu. */
noCode?: boolean
/** Undefined until loaded, when nothing is offered for deploy. */
deployUiSettings: WorkspaceDeployUISettings | undefined
onPermissions: () => void
@@ -50,7 +50,7 @@ export function agentMenuItems(agent: {
icon: List,
action: () => goto(`/runs/${agent.path}?job_kinds=previews`)
},
...(agent.operator
...(agent.noCode
? []
: [
{
@@ -70,7 +70,7 @@ export function agentMenuItems(agent: {
...(deployable
? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }]
: []),
...(agent.draftOnly || agent.operator
...(agent.draftOnly || agent.noCode
? []
: [{ displayName: 'Version history', icon: History, action: agent.onHistory }]),
{
@@ -4,6 +4,7 @@ import { deepEqual } from 'fast-equals'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { canEditItem, roleCanAuthor } from '$lib/editRights'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { userStore, userWorkspaces } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
@@ -359,12 +360,16 @@ 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.
const writer = user ?? get(userStore) ?? undefined
canWriteResource = canEditItem(
'agent',
r.path,
r.extra_perms,
user ?? get(userStore) ?? undefined,
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
writer,
capabilitiesIn(
user,
get(userWorkspaces).find((w) => w.id === ws)
)
)
// 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
@@ -406,8 +411,11 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
deployed = undefined
canWriteResource = roleCanAuthor(
'agent',
get(userStore) ?? undefined,
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
// Only superadmin carries over: the nav user's admin flag belongs to another workspace.
capabilitiesIn(
{ is_super_admin: get(userStore)?.is_super_admin },
get(userWorkspaces).find((w) => w.id === ws)
)
)
state = {
path: name,
@@ -20,7 +20,7 @@
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { getLatestHashForScript } from '$lib/scripts'
import { sendUserToast, type Item } from '$lib/utils'
@@ -31,7 +31,8 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
interface Props {
flowModuleValue?: FlowModuleValue | undefined
@@ -104,7 +105,7 @@
const scriptItems: Item[] = $derived.by(() => {
if (flowModuleValue?.type !== 'script') return []
const items: Item[] = []
if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderFlows) {
if (!isHub && customUi?.scriptEdit != false && !composeOnlyFlows) {
items.push({
displayName: "Edit the script's code",
icon: Pen,
@@ -148,7 +149,7 @@
})
}
}
if (customUi?.scriptFork != false && !$operatorBuilderFlows) {
if (customUi?.scriptFork != false && !composeOnlyFlows) {
items.push({
displayName: 'Fork into an inline script',
icon: GitFork,
@@ -8,8 +8,9 @@
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { userStore, userWorkspaces } from '$lib/stores'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { canEditItem } from '$lib/editRights'
import { capabilitiesIn } from '$lib/roleCapabilities'
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
import {
AGENT_BRAIN_KEYS,
@@ -244,7 +245,10 @@
agent,
linkedInfo?.extraPerms,
$userStore ?? undefined,
$userWorkspaces.find((w) => w.id === ws)?.operator_settings
capabilitiesIn(
ws === $workspaceStore ? $userStore : undefined,
$userWorkspaces.find((w) => w.id === ws)
)
)
)
// The hint flips on the first keystroke in the agent editor, so the badge does not wait for the
@@ -8,7 +8,7 @@
import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte'
import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts'
import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import type { SupportedLanguage } from '$lib/common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
@@ -35,7 +35,8 @@
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
@@ -158,7 +159,7 @@
selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
if (
!$operatorBuilderFlows &&
!composeOnlyFlows &&
['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)
) {
if (!selected && preFilter == 'all') {
@@ -258,7 +259,7 @@
let showAiRows = $derived(
!disableAi &&
!$copilotInfo.workspaceDisabled &&
!$operatorBuilderFlows &&
!composeOnlyFlows &&
funcDesc?.length > 0 &&
kind != 'failure' &&
kind != 'preprocessor' &&
@@ -291,14 +292,14 @@
preFilter === 'all' &&
!selected &&
customUi?.aiSandbox != false &&
!$operatorBuilderFlows
!composeOnlyFlows
? matchingAiSandboxes
: []
)
// Hub runnables carry code the workspace never reviewed, and the backend refuses them in a
// flow a builder authors, so the hub browser and its integration filters are not offered.
let showHub = $derived(!$operatorBuilderFlows)
let showHub = $derived(!composeOnlyFlows)
// Every result row lives in one keyboard index space, and hovering a row moves that index, so
// mouse and keyboard can never highlight two different rows. Offsets follow the render order.
@@ -40,7 +40,7 @@
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import { loadSchemaFromModule } from '../flowInfers'
import { type Job } from '$lib/gen'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { checkIfParentLoop } from '../utils.svelte'
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
@@ -76,7 +76,8 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
const {
selectionManager,
@@ -244,11 +245,11 @@
!flowModule.value.path?.startsWith('hub/') &&
flowModule.value.hash == undefined &&
customUi?.scriptEdit != false &&
!$operatorBuilderFlows &&
!composeOnlyFlows &&
$workspaceScriptSettingsDrawer != undefined
)
// Same rule as the graph node's Run button (FlowModuleSchemaItem).
let builderCannotTestStep = $derived($operatorBuilderFlows && flowModule.value.type === 'script')
let builderCannotTestStep = $derived(composeOnlyFlows && flowModule.value.type === 'script')
let workspaceScriptNoEditReason = $derived(
flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings
? undefined
@@ -8,7 +8,7 @@
import SchemaForm from '$lib/components/SchemaForm.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, DynamicInput } from '$lib/utils'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import { saveFlowChatInputs } from './flowChatProps'
@@ -80,14 +80,14 @@
extraSettings = undefined
}: Props = $props()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
// Derive helperScript for dynamic inputs from schema
const dynamicInputHelperScript = $derived(
DynamicInput.flowHelperScript(
additionalInputsSchema?.['x-windmill-dyn-select-code'],
additionalInputsSchema?.['x-windmill-dyn-select-lang'],
path,
operatingUser.current?.operator
!$roleCaps.code
)
)
@@ -3,7 +3,7 @@ import {
type FlowModule,
type FlowValue,
type InputTransform,
type OperatorSettings,
type RoleCapabilities,
type Resource
} from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
@@ -123,15 +123,15 @@ export interface LinkedAgentDraft {
extraPerms: Record<string, boolean>
}
/** Whether `user` may write this agent's resource, in a workspace with these `operator_settings`.
/** Whether `user`, holding a role with `caps`, 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,
operatorSettings: OperatorSettings | undefined
caps: RoleCapabilities
): boolean {
return canEditItem('agent', draft.path, draft.extraPerms, user, operatorSettings)
return canEditItem('agent', draft.path, draft.extraPerms, user, caps)
}
/** A link that cannot resolve for the user rather than because something went wrong: the agent was
@@ -35,9 +35,9 @@
import DiffActionBar from './DiffActionBar.svelte'
import { getGraphContext } from '$lib/components/graph/graphContext'
import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
interface Props {
selected?: boolean
@@ -136,7 +136,9 @@
)
// A script step is tested as a script preview, which operators are refused: running the
// deployed runnable is left to "Test flow".
let builderCannotTestStep = $derived($operatorBuilderFlows && mod?.value.type === 'script')
let builderCannotTestStep = $derived(
$roleCaps.flows === 'compose' && mod?.value.type === 'script'
)
let moduleTest: ModuleTest | undefined = $state(undefined)
let testIsLoading = $state(false)
let hover = $state(false)
@@ -12,14 +12,15 @@
import RefreshButton from '$lib/components/common/button/RefreshButton.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { ResourceService } from '$lib/gen'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import type { FlowEditorContext } from '../types'
import { logReusableAgentUsage } from '../agentTelemetry'
import { BotIcon, Loader2, Plus } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const roleCaps = useRoleCapabilities()
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
const dispatch = createEventDispatcher()
interface Props {
@@ -61,7 +62,7 @@
// Builders compose what the workspace already deployed: hub scripts bring in code nobody here
// reviewed, and the backend refuses them, so never open on the hub for them.
let preFilter: 'all' | 'workspace' | 'hub' = $state(
untrack(() => $operatorBuilderFlows) ? 'workspace' : 'all'
untrack(() => composeOnlyFlows) ? 'workspace' : 'all'
)
let loading = $state(false)
let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure'))
@@ -84,7 +85,7 @@
let savedAgentsWs: string | undefined = undefined
async function loadSavedAgents() {
// The backend refuses a linked agent in a builder's flow.
if (!ws || savedAgentsWs === ws || $operatorBuilderFlows) {
if (!ws || savedAgentsWs === ws || composeOnlyFlows) {
return
}
savedAgentsLoading = true
@@ -200,13 +201,13 @@
<StepGenQuick
bind:this={stepGen}
on:escape={() => dispatch('close')}
disableAi={disableAi || $operatorBuilderFlows}
disableAi={disableAi || composeOnlyFlows}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !$operatorBuilderFlows}
{#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !composeOnlyFlows}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
<RefreshButton
@@ -349,7 +350,7 @@
}}
/>
{/if}
{#if customUi?.aiSandbox != false && !$operatorBuilderFlows}
{#if customUi?.aiSandbox != false && !composeOnlyFlows}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
@@ -385,7 +386,7 @@
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</Button>
{#if $operatorBuilderFlows}
{#if composeOnlyFlows}
<!-- Linked agents are refused to builders, see `loadSavedAgents`. -->
{:else if savedAgentsLoading}
<div class="flex items-center gap-2 p-2 text-xs text-tertiary">
@@ -210,10 +210,9 @@
import { deepEqual } from 'fast-equals'
import { slide } from 'svelte/transition'
import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const roleCaps = useRoleCapabilities()
interface Props {
data: AssetN['data']
@@ -282,7 +281,7 @@
Could not find resource
{/snippet}
</Tooltip>
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !actingUser?.operator}
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && $roleCaps.code}
<div transition:slide={{ axis: 'x', duration: 100 }}>
<ExploreAssetButton
btnClasses="rounded-none"
@@ -25,6 +25,7 @@
type BulkOutcome
} from './bulkActions'
import type { BulkItem, HomeSelection } from './homeSelection.svelte'
import { roleCapabilities } from '$lib/stores'
interface Props {
selection: HomeSelection
@@ -38,7 +39,7 @@
let { selection, workspace, isAdmin, moveTargets, onDone }: Props = $props()
let ctx: BulkContext = $derived({ workspace, isAdmin })
let ctx: BulkContext = $derived({ workspace, isAdmin, caps: $roleCapabilities })
// The bar is still on screen while it flies out, and unticking the last row is
// what starts that — by then the live selection is empty. Rendering the last
// non-empty one keeps the count and the action states from blinking on the way
@@ -27,6 +27,7 @@
import { importStore } from '$lib/components/apps/store'
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { useEditRights } from '$lib/operatorWriteRights'
import type { EditKind } from '$lib/editRights'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import YAML from 'yaml'
import type { Snippet } from 'svelte'
@@ -313,11 +314,15 @@
}
}
// Only the kinds this user's role authors: a builder operator gets the kinds its rights grant.
// The edit kind of an option whose key is not one: a low-code app needs `full` apps, a
// full-code one `compose`. Every other key is itself a kind (`wac` falls to `code`).
const EDIT_KIND: Record<string, EditKind> = { 'app-fullcode': 'raw_app', 'app-lowcode': 'app' }
// Only the kinds this user's role authors.
// 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(
allOptions.filter((o) => editRights.roleCanAuthor(o.key === 'app-fullcode' ? 'raw_app' : o.key))
allOptions.filter((o) => editRights.roleCanAuthor(EDIT_KIND[o.key] ?? o.key))
)
// the doc panel only shows while an option is hovered or focused, so the menu opens compact
@@ -17,9 +17,9 @@
}
]
/** 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. */
/** A session for a role that cannot code can run what is deployed, read runs and call MCP
* servers, but cannot author scripts. It composes flows or apps only where its role grants
* that, so none of these asks it to build anything. */
export const homeAIOperatorExamples: { label: string; prompt: string }[] = [
{
label: 'Run a flow',
@@ -54,7 +54,7 @@
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { aiUserDisabled, hubBaseUrlStore, roleCapabilities, workspaceStore } from '$lib/stores'
import { HOME_SHOW_HUB } from '$lib/consts'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
@@ -76,7 +76,7 @@
* labelled buttons without the trail starting to truncate. Below it they fold back. */
const BAR_FITS_SIDE_TRIPS = 1024
const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS)
const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB)
const hubOffered = $derived($roleCapabilities.code && HOME_SHOW_HUB)
let value = $state('')
// The stock examples for the reader's role, unless the invite that brought this person here wrote prompts for
@@ -89,7 +89,7 @@
promptIndex = 0
})
let examples = $derived(
invitePrompts ?? ($userStore?.operator ? homeAIOperatorExamples : homeAIExamples)
invitePrompts ?? ($roleCapabilities.code ? homeAIExamples : homeAIOperatorExamples)
)
let placeholderVisible = $state(true)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
@@ -158,11 +158,11 @@
// unlocked, so the far commoner unlocked workspace never pops the composer in mid-load.
let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment'))
// The composer hands off to /sessions, which refuses operators and users opted out of the
// The composer hands off to /sessions, which refuses roles that cannot code and users opted out of the
// sessions beta — so hide it from them (the prompt would be silently dropped) while the
// AI-independent CLI/MCP row below stays.
let showComposer = $derived(
prefersSessionHandoff($userStore?.operator) &&
prefersSessionHandoff(!$roleCapabilities.code) &&
!runOnlyWorkspace &&
!$copilotInfo.workspaceDisabled
)
@@ -247,7 +247,7 @@
{/if}
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">
{$userStore?.operator ? 'Run with AI' : 'Build with AI'}
{$roleCapabilities.code ? 'Build with AI' : 'Run with AI'}
</p>
<Badge color="blue" small>Beta</Badge>
</div>
@@ -263,9 +263,9 @@
underlyingInputEl="textarea"
inputProps={{
rows: 4,
'aria-label': $userStore?.operator
? 'Describe what you want to do'
: 'Describe what you want to build',
'aria-label': $roleCapabilities.code
? 'Describe what you want to build'
: 'Describe what you want to do',
onkeydown: onKeydown
}}
/>
@@ -17,7 +17,7 @@
} from '$lib/gen'
import { resource } from 'runed'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
import { disableHubStore, userStore, workspaceStore } from '$lib/stores'
import { disableHubStore, roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
@@ -119,7 +119,7 @@
label: 'Include library scripts',
// On by default, so selecting it means "turn it off" — keep the picker.
default: true,
hidden: !($userStore && !$userStore.operator)
hidden: !($userStore && $roleCapabilities.code)
},
only_user_folders: {
type: 'boolean' as const,
@@ -1745,7 +1745,7 @@
const homeSelection = new HomeSelection()
setHomeSelection(homeSelection)
$effect(() => {
homeSelection.available = showEditButtons && !!$userStore && !$userStore.operator
homeSelection.available = showEditButtons && !!$userStore && $roleCapabilities.code
})
// A selected path means nothing in another workspace. Narrowing the view
// within one (kind, owner, label, search) keeps the selection instead, so
@@ -4,11 +4,11 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import { disableHubStore } from '$lib/stores'
import { useOperatorBuilderRights } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import HubTemplatePicker from './HubTemplatePicker.svelte'
const operatorBuilderRights = useOperatorBuilderRights()
const roleCaps = useRoleCapabilities()
interface Props {
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
@@ -38,8 +38,8 @@
// `border-light`; only the container outline steps up, so nothing outweighs its frame.
const rowOpacities = [1, 0.7, 0.4]
// A builder gets no hub template: a hub project brings scripts and apps along.
const showHub = $derived(!$disableHubStore && !$operatorBuilderRights)
// A hub project brings scripts and apps along, which only a role with code may deploy.
const showHub = $derived(!$disableHubStore && $roleCaps.code)
// The inline "create a new one" link is the anchor for the very same New menu the
// toolbar button opens, so the menu pops next to the words that promised it.
@@ -1,3 +1,4 @@
import { FULL_CAPABILITIES } from '$lib/roleCapabilities'
import { describe, it, expect } from 'vitest'
import { blockedReason, movedPath, type BulkContext } from './bulkActions'
import type { BulkItem } from './homeSelection.svelte'
@@ -21,8 +22,8 @@ function item(over: Partial<BulkItem> = {}): BulkItem {
}
}
const admin: BulkContext = { workspace: 'w', isAdmin: true }
const member: BulkContext = { workspace: 'w', isAdmin: false }
const admin: BulkContext = { workspace: 'w', isAdmin: true, caps: FULL_CAPABILITIES }
const member: BulkContext = { workspace: 'w', isAdmin: false, caps: FULL_CAPABILITIES }
// Each action's gate has to keep matching what the equivalent per-row menu entry
// allows (common/table/{Script,Flow,App}Row.svelte, MoveDrawer.svelte) — a bulk
@@ -45,6 +46,17 @@ describe('blockedReason', () => {
expect(blockedReason('delete', item({ kind: 'app', canWrite: false }), member)).toBeDefined()
})
// The selection opens for any role with `code`, which may still lack apps.
it("refuses the kinds the member's role cannot author", () => {
const scripter = { ...member, caps: { ...FULL_CAPABILITIES, apps: 'none' as const } }
expect(blockedReason('delete', item({ kind: 'app' }), scripter)).toBeDefined()
expect(blockedReason('move', item({ kind: 'app', rawApp: true }), scripter)).toBeDefined()
expect(blockedReason('move', item({ kind: 'flow' }), scripter)).toBeUndefined()
const ownDraft = item({ kind: 'app', draftOnly: true })
expect(blockedReason('discard', ownDraft, scripter)).toBeUndefined()
expect(blockedReason('discard', { ...ownDraft, legacyDraft: true }, scripter)).toBeDefined()
})
it('requires ownership to move, and refuses archived rows', () => {
expect(blockedReason('move', item({ owner: false }), admin)).toBeDefined()
expect(blockedReason('move', item({ canWrite: false }), admin)).toBeDefined()
@@ -9,7 +9,8 @@
* there is nothing deployed at its path.
*/
import { AppService, DraftService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import type { UserDraftItemKind } from '$lib/gen'
import type { RoleCapabilities, UserDraftItemKind } from '$lib/gen'
import { roleCanAuthor, roleCanDraft } from '$lib/editRights'
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
import { discardDraft } from '$lib/utils_draft_deploy'
import type { BulkItem } from './homeSelection.svelte'
@@ -30,6 +31,8 @@ export type BulkContext = {
workspace: string
/** Deleting a script is admin-only, mirroring the row menu. */
isAdmin: boolean
/** The role's capabilities: a role that codes may still lack the flows or apps it selects. */
caps: RoleCapabilities
}
/** Why `action` cannot be applied to `item`; `undefined` means it can. The
@@ -41,6 +44,12 @@ export function blockedReason(
ctx: BulkContext
): string | undefined {
const notOwner = 'you are not an owner of this path'
const kind = draftKind(item)
// Discarding one's own draft needs no authoring right server-side, so a member who lost it
// can still clean up; a legacy (owner-less) draft does.
const roleGated = action !== 'discard' || item.legacyDraft
if (roleGated && !(action === 'discard' ? roleCanDraft : roleCanAuthor)(kind, ctx.caps))
return 'your role cannot author this kind of item'
switch (action) {
case 'move':
if (item.archived) return 'archived items cannot be moved'
@@ -6,6 +6,7 @@ import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor'
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
import { writable } from 'svelte/store'
import { OidcService } from '$lib/gen'
import { workspaceRolesErrors } from '$lib/workspaceRoles'
/**
* Bumped after instance settings are successfully saved. Settings whose display
@@ -78,6 +79,7 @@ export interface Setting {
| 'instance_banner'
| 'accent_color'
| 'agent_workload_identity_issuers'
| 'workspace_roles'
storage: SettingStorage
advancedToggle?: {
label: string
@@ -635,6 +637,16 @@ export const settings: Record<string, Setting[]> = {
(Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= 3650)
}
],
'Workspace roles': [
{
label: 'Workspace roles',
key: 'workspace_roles',
fieldType: 'workspace_roles',
storage: 'setting',
ee_only: 'Custom workspace roles are only available in the EE version',
validate: workspaceRolesErrors
}
],
'Managed Postgres': [
{
label: 'External instance',
@@ -1325,6 +1337,13 @@ export const instanceSettingsNavigationGroups = [
label: 'Jobs',
aiId: 'instance-settings-jobs',
aiDescription: 'Instance jobs settings'
},
{
id: 'workspace_roles',
label: 'Workspace roles',
aiId: 'instance-settings-workspace-roles',
aiDescription: 'Roles workspace members can hold and their capabilities',
isEE: true
}
]
},
@@ -1516,6 +1535,7 @@ export const tabToCategoryMap: Record<string, string> = {
object_storage: 'Object Storage',
managed_postgres: 'Managed Postgres',
jobs: 'Jobs',
workspace_roles: 'Workspace roles',
private_hub: 'Private Hub',
github_enterprise_app: 'GitHub App',
websocket: 'WebSocket',
@@ -1579,6 +1599,7 @@ export const categoryToTabMap: Record<string, string> = {
'Object Storage': 'object_storage',
'Managed Postgres': 'managed_postgres',
Jobs: 'jobs',
'Workspace roles': 'workspace_roles',
'Private Hub': 'private_hub',
'GitHub App': 'github_enterprise_app',
WebSocket: 'websocket',
@@ -0,0 +1,253 @@
<script lang="ts">
import type { Writable } from 'svelte/store'
import { untrack } from 'svelte'
import { Alert, Badge, Button } from '../common'
import TextInput from '../text_input/TextInput.svelte'
import RoleCapabilitiesEditor from '../settings/RoleCapabilitiesEditor.svelte'
import type { RoleCapabilities, RoleCapabilitiesPatch, RoleDefinition } from '$lib/gen'
import {
applyCapabilitiesPatch,
authorLevelLabel,
baseCapabilitiesFor,
DEFAULT_INSTANCE_ROLES,
DEVELOPER_ROLE,
isDefaultInstanceRoles,
isEmptyPatch,
workspaceRolesErrors
} from '$lib/workspaceRoles'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
import { ChevronDown, ChevronRight, GripVertical, Plus, RotateCcw, Trash2 } from 'lucide-svelte'
interface Props {
values: Writable<Record<string, any>>
loading?: boolean
/** Without a valid license the stored list keeps applying but can only be reset. */
unlicensed?: boolean
}
let { values, loading = false, unlicensed = false }: Props = $props()
const disabled = $derived(loading || unlicensed)
const KEY = 'workspace_roles'
type Row = {
id: number
name: string
label: string
capabilities: RoleCapabilitiesPatch
open: boolean
}
let nextId = 0
let rows = $state<Row[]>([])
// The JSON last read from or written to `$values[KEY]`, to tell our own writes from external
// ones (the async load, Save, Discard) and re-hydrate only on the latter.
let lastSynced: string | undefined = undefined
function toRows(v: unknown): Row[] {
const list = Array.isArray(v) && v.length > 0 ? (v as RoleDefinition[]) : DEFAULT_INSTANCE_ROLES
return list.map((r) => ({
id: nextId++,
name: r.name ?? '',
label: r.label ?? '',
capabilities: structuredClone(r.capabilities ?? {}),
open: false
}))
}
function serialize(rs: Row[]): RoleDefinition[] | undefined {
const list: RoleDefinition[] = rs.map((r) => ({
name: r.name.trim(),
...(r.label.trim() ? { label: r.label.trim() } : {}),
...(isEmptyPatch(r.capabilities) ? {} : { capabilities: r.capabilities })
}))
return isDefaultInstanceRoles(list) ? undefined : list
}
$effect(() => {
const cur = JSON.stringify($values[KEY] ?? null)
if (cur === lastSynced) return
lastSynced = cur
const open = new Set(rows.filter((r) => r.open).map((r) => r.name))
rows = toRows($values[KEY]).map((r) => ({
...r,
open: open.has(r.name)
}))
})
$effect(() => {
const next = serialize($state.snapshot(rows) as Row[])
if (lastSynced === undefined || disabled) return
const nextStr = JSON.stringify(next ?? null)
// Compared with the stored value in serialized form, so one spelled differently (the
// defaults written out, an empty `capabilities`, another key order) is not rewritten
// and the category not marked unsaved before anything is edited.
const stored = untrack(() => $values[KEY])
if (nextStr === JSON.stringify(serialize(toRows(stored)) ?? null)) return
lastSynced = nextStr
$values[KEY] = next
})
const errors = $derived(workspaceRolesErrors(serialize(rows) ?? DEFAULT_INSTANCE_ROLES))
const flipDurationMs = 200
function addRole() {
rows = [...rows, { id: nextId++, name: '', label: '', capabilities: {}, open: true }]
}
function summary(caps: RoleCapabilities): string {
return [
caps.code ? 'Code' : 'No code',
`Flows: ${authorLevelLabel(caps.flows).toLowerCase()}`,
`Apps: ${authorLevelLabel(caps.apps).toLowerCase()}`
].join(' · ')
}
</script>
<div class="flex flex-col gap-3">
{#if unlicensed}
<Alert
type="info"
size="xs"
title="Changing workspace roles requires a valid Windmill Enterprise Edition license"
>
The roles below keep applying as they are. A workspace admin can still override what they
allow in that workspace's settings.
{#if serialize(rows) !== undefined}
<div class="mt-2">
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
disabled={loading}
onclick={() => ($values[KEY] = undefined)}
>
Reset to the default roles
</Button>
</div>
{/if}
</Alert>
{/if}
<span class="text-xs text-secondary">
Roles members can hold in every workspace, in order: when several instance groups map a member
to different roles, the role listed first wins. A capability left unset uses the default:
everything for a role named <code>developer</code>, the operator rights for any other name.
Workspace admins can override a role in their workspace. A role still held by a member, invite,
auto-invite or instance group mapping cannot be removed.
</span>
<section
class="flex flex-col gap-3"
use:dragHandleZone={{
items: rows,
flipDurationMs,
dragDisabled: disabled,
// The handle zone is drag-disabled between drags, which would otherwise mark every
// input in it aria-disabled.
autoAriaDisabled: true,
dropTargetStyle: {}
}}
onconsider={(e) => (rows = e.detail.items)}
onfinalize={(e) => (rows = e.detail.items)}
>
{#each rows as row, i (row.id)}
{@const base = baseCapabilitiesFor(row.name)}
{@const caps = applyCapabilitiesPatch(base, row.capabilities)}
<div class="flex flex-col rounded-md border bg-surface-tertiary">
<div class="flex items-center gap-2 p-2">
<div
class="shrink-0 flex items-center {disabled
? 'text-disabled cursor-not-allowed'
: 'text-secondary cursor-move'}"
use:dragHandle
aria-label="Reorder role {row.name || i + 1}"
>
<GripVertical size={16} />
</div>
<div class="flex flex-col gap-0.5 w-40 shrink-0">
<TextInput
size="sm"
bind:value={row.name}
error={errors[`${i}.name`]}
inputProps={{ placeholder: 'name', disabled, 'aria-label': 'Role name' }}
/>
</div>
<span class="text-2xs text-secondary truncate grow">
{#if row.label.trim()}
<span class="text-primary">{row.label.trim()}</span> ·
{/if}
{summary(caps)}
</span>
{#if i === 0}
<Badge color="gray" verySmall>Highest</Badge>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: row.open ? ChevronDown : ChevronRight }}
onclick={() => (row.open = !row.open)}
>
Capabilities
</Button>
<Button
variant="subtle"
unifiedSize="sm"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
aria-label="Remove role"
title={rows.length === 1 ? 'Keep at least one role' : 'Remove role'}
disabled={disabled || rows.length === 1}
onclick={() => (rows = rows.filter((r) => r.id !== row.id))}
/>
</div>
{#if errors[`${i}.name`]}
<span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2 -mt-1">
{errors[`${i}.name`]}
</span>
{/if}
{#if row.open}
<div class="border-t p-3 flex flex-col gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-emphasis w-24">Label</span>
<div class="w-60">
<TextInput
size="sm"
bind:value={row.label}
inputProps={{
placeholder: row.name || 'label',
disabled,
'aria-label': 'Role label'
}}
/>
</div>
</div>
<RoleCapabilitiesEditor
{base}
patch={row.capabilities}
baseName={row.name === DEVELOPER_ROLE ? 'developer default' : 'operator default'}
setText="Set"
inheritedText="Default"
{disabled}
onchange={(p) => (row.capabilities = p)}
/>
</div>
{:else if errors[`${i}.capabilities`]}
<span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2">
{errors[`${i}.capabilities`]}
</span>
{/if}
</div>
{/each}
</section>
{#if !disabled}
<div>
<Button variant="default" unifiedSize="sm" startIcon={{ icon: Plus }} onclick={addRole}>
Add role
</Button>
</div>
{/if}
</div>
@@ -90,11 +90,11 @@
import { randomSecret, randomUUID } from '$lib/utils/uuid'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { conformBuilderAppPolicy } from './builderAppPolicy'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
interface Props {
files?: Record<string, string>
@@ -423,7 +423,7 @@
// The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user
// deployed carries, so the editor conforms the policy as soon as it opens.
$effect(() => {
if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore)
if ($roleCaps.apps === 'compose' && policy) conformBuilderAppPolicy(policy, $userStore)
})
// An app with sandbox isolation previews the way it runs once deployed: in the
// served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce
@@ -46,7 +46,7 @@
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
import FileEditorIcon from './FileEditorIcon.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatorBuilderApps } from '$lib/operatorWriteRights'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
export type RawAppTemplatePickerResult = {
@@ -68,7 +68,7 @@
} = $props()
const templates = $derived.by(() => {
const files = $operatorBuilderApps ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES
const files = composeOnlyApps ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES
return [
{ name: 'React 19', icon: 'tsx', files: files.react19, recommended: true },
{ name: 'React 18', icon: 'tsx', files: files.react18 },
@@ -87,7 +87,8 @@
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderApps = useOperatorBuilderApps()
const roleCaps = useRoleCapabilities()
const composeOnlyApps = $derived($roleCaps.apps === 'compose')
let opWs = $derived($operatingWorkspace)
const datatables = createDatatablesResource(() => opWs)
@@ -284,8 +285,7 @@
// With AI turned off for the workspace there is nothing to choose between. A builder is
// offered AI only through a session: the docked app mode writes inline code they cannot save.
const aiOffered = $derived(
!$copilotInfo.workspaceDisabled &&
(!$operatorBuilderApps || prefersSessionHandoff($userStore?.operator))
!$copilotInfo.workspaceDisabled && (!composeOnlyApps || prefersSessionHandoff(!$roleCaps.code))
)
const canStart = $derived(
@@ -301,7 +301,7 @@
execution_mode: 'publisher',
sandbox: true
}
if ($operatorBuilderApps) conformBuilderAppPolicy(policy, $userStore)
if (composeOnlyApps) conformBuilderAppPolicy(policy, $userStore)
return policy
}
@@ -42,7 +42,7 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen'
import { OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen'
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { useEditRights } from '$lib/operatorWriteRights'
import {
@@ -1027,10 +1027,10 @@
</DrawerContent>
</Drawer>
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources}
{#if $operatingWorkspace && !editRights.capabilities.visibility.resources}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
<p>Page not available for your role</p>
</div>
{:else}
<CenteredPage>
@@ -1358,7 +1358,7 @@
</Cell>
<Cell last actions>
<div class="flex justify-end items-center gap-2">
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator}
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && editRights.capabilities.code}
<ExploreAssetButton
asset={{ kind: 'resource', path }}
_resourceMetadata={{ resource_type }}
@@ -30,14 +30,11 @@
import { flowPathToHref } from '$lib/scripts'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const roleCaps = useRoleCapabilities()
interface Props {
job: Job
@@ -503,7 +500,7 @@
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1">
<JobStatus {job} />
{#if isJobResolvable(job) && !actingUser?.operator}
{#if isJobResolvable(job) && $roleCaps.code}
<!-- No startIcon on these: the row is baseline-aligned, and a Button is
itself a flex container whose baseline comes from its icon rather
than its label, which sits the text ~3px above the badges. Text
@@ -609,7 +609,7 @@
// Offer to resolve this failure once the re-run succeeds. Captured here because the
// new job carries no back-pointer to the run it supersedes. An already-resolved
// failure needs no offer, and its note must not be restated as a supersession.
if (job && isJobResolvable(job) && !job.resolved && !actingUser?.operator) {
if (job && isJobResolvable(job) && !job.resolved && editRights.capabilities.code) {
rememberRerunOrigin({ originalId: job.id, rerunId: id, workspace: workspaceId! })
}
await onNavigate('/run/' + id + '?workspace=' + workspaceId)
@@ -755,12 +755,11 @@
)
const canEdit = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
!actingUser?.operator &&
editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})
)
const canEditInFork = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
!actingUser?.operator &&
editRights.capabilities.code &&
!showEditButton &&
!isCloudHosted() &&
editInForkAllowed(workspaceId, $userWorkspaces)
@@ -1323,7 +1322,7 @@
</Button>
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
{#if !actingUser?.operator}
{#if canEdit || canEditInFork}
{#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})}
<Button
href={`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`}
@@ -1350,7 +1349,7 @@
/>
{/if}
{/if}
{#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)}
{#if canEditInFork}
<Button
href={buildForkEditUrl(
isScriptJob ? 'script' : 'flow',
@@ -21,7 +21,8 @@
import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils'
import { goto } from '$lib/navigation'
import { useHostedPage } from '../hostedPage'
import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte'
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
import { withWorkspaceParam } from '../sessions/sessionMode.svelte'
import BarsStaggered from '../icons/BarsStaggered.svelte'
@@ -205,7 +206,7 @@
const dispatch = createEventDispatcher()
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
// Under a host the link opens where it decides, in the workspace the run belongs to: the
// document's own router would leave the host for the navigation workspace.
@@ -331,8 +332,8 @@
onClick: () => onCancelJobs?.(selectedIdsPossibleActions.cancellableJobIds),
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
})
// Operators are rejected by the endpoint, so offering the action would only 403.
const canResolve = !operatingUser.current?.operator
// A role without `code` is rejected by the endpoint, so offering the action would only 403.
const canResolve = $roleCaps.code
const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0
const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0
if (resolvable)
@@ -21,7 +21,7 @@
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { userWorkspaces, enterpriseLicense } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import {
Calendar,
Circle,
@@ -714,10 +714,10 @@
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules}
{#if $operatingWorkspace && !editRights.capabilities.visibility.schedules}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
<p>Page not available for your role</p>
</div>
{:else}
<CenteredPage>
@@ -39,7 +39,7 @@
import ContentSearchInner from '../ContentSearchInner.svelte'
import { goto } from '$app/navigation'
import QuickMenuItem from '../search/QuickMenuItem.svelte'
import { devopsRole, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { devopsRole, enterpriseLicense, roleCapabilities, workspaceStore } from '$lib/stores'
import uFuzzy from '@leeoniya/ufuzzy'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import { Alert } from '../common'
@@ -106,77 +106,77 @@
label: 'Go to HTTP routes',
action: (newtab: boolean = false) => gotoPage('/routes', newtab),
icon: Route,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:web_sockets',
label: 'Go to WebSockets',
action: (newtab: boolean = false) => gotoPage('/websocket_triggers', newtab),
icon: Unplug,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:postgres_triggers',
label: 'Go to Postgres triggers',
action: (newtab: boolean = false) => gotoPage('/postgres_triggers', newtab),
icon: Database,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:kafka_triggers',
label: 'Go to Kafka triggers' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/kafka_triggers', newtab),
icon: KafkaIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:nats_triggers',
label: 'Go to NATS triggers' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/nats_triggers', newtab),
icon: NatsIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:sqs_triggers',
label: 'Go to SQS triggers' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/sqs_triggers', newtab),
icon: AwsIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:gcp_pub_sub',
label: 'Go to GCP Pub/Sub' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/gcp_triggers', newtab),
icon: GoogleCloudIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:azure_event_grid',
label: 'Go to Azure Event Grid' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/azure_triggers', newtab),
icon: AzureIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:mqtt_triggers',
label: 'Go to MQTT triggers',
action: (newtab: boolean = false) => gotoPage('/mqtt_triggers', newtab),
icon: MqttIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:amqp_triggers',
label: 'Go to AMQP triggers',
action: (newtab: boolean = false) => gotoPage('/amqp_triggers', newtab),
icon: AmqpIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
},
{
search_id: 'nav:email_triggers',
label: 'Go to Email triggers',
action: (newtab: boolean = false) => gotoPage('/email_triggers', newtab),
icon: MailIcon,
disabled: $userStore?.operator
disabled: !$roleCapabilities.visibility.triggers
}
]
@@ -43,10 +43,9 @@
import { copilotInfo } from '$lib/aiStore'
import { sendUserToast } from '$lib/toast'
import { openSourceInSession } from './sessionSwitch.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const roleCaps = useRoleCapabilities()
let {
source,
@@ -80,13 +79,13 @@
// SessionEditorTarget / the session wrapper); iframe preview tabs are not
// the top window.
const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top)
// prefersSessionHandoff carries the operator clause: the sessions page refuses
// them, so an entry point on a page they can reach (Runs, the trigger lists)
// prefersSessionHandoff carries the role clause: the sessions page refuses a role
// that cannot code, so an entry point on a page they can reach (Runs, the trigger lists)
// would only route them into that refusal.
const show = $derived(
!inSessionPanel &&
!!(source?.target || source?.page) &&
prefersSessionHandoff(actingUser?.operator)
prefersSessionHandoff(!$roleCaps.code)
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
@@ -23,7 +23,7 @@
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { operatorBuilderAppsIn } from '$lib/operatorWriteRights'
import { capabilitiesInWorkspace } from '$lib/operatorWriteRights'
import type {
RawAppRuntimeLogRequester,
RawAppRunsProvider,
@@ -100,7 +100,7 @@
files: { ...result.files },
runnables: {
...result.runnables,
...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES))
...(capabilitiesInWorkspace(workspaceId).apps === 'compose' ? {} : structuredClone(STARTER_RUNNABLES))
},
data: result.data,
policy: result.policy
@@ -11,7 +11,13 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import SessionStatusPopover from './SessionStatusPopover.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { maybePremium, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
maybePremium,
roleCapabilities,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { canCreateFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { sessionState, type Session } from './sessionState.svelte'
@@ -63,7 +69,7 @@
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. On cloud,
// forking is a premium-only feature (backend caps it per paid seat).
const forksAllowed = $derived(
(!isCloudHosted() || $maybePremium) && canCreateFork($userStore) && $workspaceStore !== 'admins'
(!isCloudHosted() || $maybePremium) && canCreateFork($userStore, $roleCapabilities) && $workspaceStore !== 'admins'
)
const runtime = $derived(getRuntime(session.id))
@@ -3,6 +3,7 @@
import {
enterpriseLicense,
maybePremium,
roleCapabilities,
superadmin,
userStore,
userWorkspaces,
@@ -178,7 +179,7 @@
const forksGateOpen = $derived(
(!isCloudHosted() || $maybePremium) &&
$workspaceStore !== 'admins' &&
(canCreateFork($userStore) || !!devOfRoot)
(canCreateFork($userStore, $roleCapabilities) || !!devOfRoot)
)
// A fork is a new workspace, so it's subject to the community-edition cap on
// the number of non-'admins' workspaces (backend _check_nb_of_workspaces,
@@ -28,8 +28,8 @@ import {
type PreviewItemRoute,
type TriggerKind
} from './previewPaths'
import type { UserExt, UserWorkspace } from '$lib/stores'
import { sidebarPageAllowed, type OperatorPageKey } from '../sidebar/operatorRoutes'
import type { UserWorkspace } from '$lib/stores'
import { capabilitiesIn, type CapabilitiesUser, type VisibilityKey } from '$lib/roleCapabilities'
// 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 {
@@ -122,20 +122,20 @@ export type PreviewPage = {
label: string
path: string
icon: DrillIcon
/** The `operator_settings` key that admits an operator, as the sidebar reads it. */
operatorKey?: OperatorPageKey
/** The role visibility flag that shows the page, as the sidebar reads it. */
visibilityKey?: VisibilityKey
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. */
* user is held to the workspace entry's role, and kept off the admin-only pages. */
export function previewPageAllowed(
page: PreviewPage,
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined,
user: CapabilitiesUser | 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)
if (page.adminOnly) return !!user && (!!user.is_admin || !!user.is_super_admin)
return !page.visibilityKey || capabilitiesIn(user, workspace).visibility[page.visibilityKey]
}
// Core workspace-level destinations the preview can route to. Intentionally
@@ -143,15 +143,15 @@ export function previewPageAllowed(
// 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, 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: 'Runs', path: RUNS_PATH, icon: Play, visibilityKey: 'runs' },
{ label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, visibilityKey: 'variables' },
{ label: 'Resources', path: RESOURCES_PATH, icon: Boxes, visibilityKey: 'resources' },
{ label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, visibilityKey: 'schedules' },
{ label: 'Assets', path: ASSETS_PATH, icon: Database, visibilityKey: 'assets' },
{ label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, visibilityKey: 'folders' },
{ label: 'Groups', path: GROUPS_PATH, icon: Users, visibilityKey: 'groups' },
{ label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true },
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, operatorKey: 'audit_logs' }
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, visibilityKey: 'audit_logs' }
]
// The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker
@@ -1,7 +1,6 @@
import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import { get } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import { roleCanAuthor } from '$lib/editRights'
import { capabilitiesInWorkspace } from '$lib/operatorWriteRights'
import {
checkDeployPermission,
checkDeployRules,
@@ -232,8 +231,8 @@ 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 and apps.rs let one with the
// workspace's matching builder right deploy that kind, under the rules alone.
// `checkDeployPermission` refuses every role that cannot code, but flows.rs and apps.rs let one
// that composes that kind deploy it, under the rules alone.
let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined)
let deployPermFetchedFor = ''
$effect(() => {
@@ -248,10 +247,8 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
void checkDeployPermission(ws).then(async (perm) => {
if (deployPermFetchedFor !== ws) return
deployPerm = perm
const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings
const kinds = (['flow', 'raw_app'] as const).filter((k) =>
roleCanAuthor(k, { operator: true }, settings)
)
const caps = capabilitiesInWorkspace(ws)
const kinds = (['flow', 'raw_app'] as const).filter((k) => roleCanAuthor(k, caps))
if (perm.refusedBy === 'operator' && kinds.length) {
const rules = await checkDeployRules(ws)
if (deployPermFetchedFor === ws) builder = { perm: rules, kinds }
@@ -261,8 +258,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
})
function permissionFor(kind: Kind): DeployPermission {
if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind)
return deployPermissionForKind(deployPerm, kind)
const caps = capabilitiesInWorkspace(getArgs().workspaceId)
if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind, caps)
return deployPermissionForKind(deployPerm, kind, caps)
}
// ── Deploy execution ─────────────────────────────────────────────────────
@@ -36,7 +36,7 @@ import {
react19Template,
STARTER_RUNNABLES
} from '$lib/components/raw_apps/templates'
import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights'
import { capabilitiesInWorkspace, workspaceListLoaded } from '$lib/operatorWriteRights'
import { random_adj } from '$lib/components/random_positive_adjetive'
import { copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
@@ -1152,7 +1152,7 @@ function createRuntime(session: Session): SessionRuntime {
const user = get(userStore)
// A builder can save no inline runnable, so the regular starter could never be saved.
await workspaceListLoaded()
const builder = operatorBuilderAppsIn(workspace)
const builder = capabilitiesInWorkspace(workspace).apps === 'compose'
saved.val = undefined
store.val = {
files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) },
@@ -7,7 +7,8 @@
import Cell from '$lib/components/table/Cell.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '$lib/components/select/Select.svelte'
import type { User } from '$lib/gen'
import type { EffectiveRole, User } from '$lib/gen'
import { loadWorkspaceRoles, pickerValue, roleLabel } from '$lib/workspaceRoles'
import { UserService, WorkspaceService } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -32,10 +33,16 @@
.map((c) => ({ value: c.email, label: `${c.username} (${c.email})` }))
)
let roles: EffectiveRole[] | undefined = $state(undefined)
async function loadMembers(workspace: string): Promise<void> {
const loaded = await UserService.listUsers({ workspace })
const [loaded, workspaceRoles] = await Promise.all([
UserService.listUsers({ workspace }),
loadWorkspaceRoles(workspace).catch(() => undefined)
])
if (workspace !== $workspaceStore) return
members = loaded
roles = workspaceRoles?.roles
}
async function loadCandidates(workspace: string, parent: string): Promise<void> {
@@ -167,7 +174,7 @@
<tbody>
{#if members}
{#each members as member (member.email)}
{@const { email, username, is_admin, operator } = member}
{@const { email, username, is_admin } = member}
<tr class="bg-surface">
<Cell first>
{#if member.is_service_account}
@@ -184,7 +191,7 @@
</Cell>
<Cell>
<Badge color="blue">
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
{roleLabel(roles, pickerValue(member))}
</Badge>
</Cell>
<Cell last actions>
@@ -0,0 +1,226 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import type {
AuthorLevel,
RoleCapabilities,
RoleCapabilitiesPatch,
RoleVisibility
} from '$lib/gen'
import {
applyCapabilitiesPatch,
capabilitiesError,
isEmptyPatch,
VISIBILITY_KEYS
} from '$lib/workspaceRoles'
import { RotateCcw } from 'lucide-svelte'
interface Props {
/** The layer an unset field falls through to. */
base: RoleCapabilities
/** Fields whose `base` value is not known here, as `code` or `visibility.runs`. */
unknownBase?: string[]
patch: RoleCapabilitiesPatch
/** What the layer below is called: "default" on the instance, "instance" in a workspace. */
baseName: string
/** Marks a field this layer sets, and one it leaves to `base`. */
setText: string
inheritedText: string
disabled?: boolean
onchange?: (patch: RoleCapabilitiesPatch) => void
}
let {
base,
unknownBase = [],
patch,
baseName,
setText,
inheritedText,
disabled = false,
onchange
}: Props = $props()
const effective = $derived(applyCapabilitiesPatch(base, patch))
const error = $derived(capabilitiesError(effective))
type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers'
type LevelKey = 'flows' | 'apps'
function boolValue(path: string): boolean {
if (path.startsWith('visibility.')) {
return effective.visibility[path.slice('visibility.'.length) as keyof RoleVisibility]
}
return effective[path as BoolKey]
}
function isSet(path: string): boolean {
if (path.startsWith('visibility.')) {
return patch.visibility?.[path.slice('visibility.'.length)] != null
}
return patch[path as keyof RoleCapabilitiesPatch] != null
}
function update(path: string, value: boolean | AuthorLevel | undefined) {
const next: RoleCapabilitiesPatch = structuredClone($state.snapshot(patch))
if (path.startsWith('visibility.')) {
const vis = { ...(next.visibility ?? {}) }
const k = path.slice('visibility.'.length)
if (value === undefined) delete vis[k]
else vis[k] = value as boolean
next.visibility = Object.keys(vis).length > 0 ? vis : undefined
if (next.visibility === undefined) delete next.visibility
} else if (value === undefined) {
delete next[path as keyof RoleCapabilitiesPatch]
} else {
;(next as Record<string, unknown>)[path] = value
}
onchange?.(next)
}
function resetAll() {
onchange?.({})
}
const authoring: { key: BoolKey | LevelKey; label: string; description: string }[] = [
{
key: 'code',
label: 'Write code',
description:
'Author scripts and run code of their own: previews, dependency jobs, dynamic selects. Required for full flow or app authoring.'
},
{
key: 'flows',
label: 'Flows',
description: 'Compose assembles deployed scripts and flows without code of their own.'
},
{
key: 'apps',
label: 'Apps',
description:
'Compose builds full-code apps out of deployed runnables, always sandboxed. Low-code apps need full.'
}
]
const management: { key: BoolKey; label: string; description: string }[] = [
{
key: 'manage_schedules',
label: 'Manage schedules',
description: 'Create, edit and delete schedules where folder permissions allow.'
},
{
key: 'manage_triggers',
label: 'Manage triggers',
description: 'Create, edit and delete triggers where folder permissions allow.'
}
]
</script>
{#snippet status(path: string)}
<div class="flex items-center justify-end gap-1 w-32 shrink-0">
{#if isSet(path)}
<Badge color="blue" verySmall>{setText}</Badge>
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: RotateCcw }}
title="Reset to the {baseName} value"
aria-label="Reset to the {baseName} value"
{disabled}
onclick={() => update(path, undefined)}
/>
{:else if unknownBase.includes(path)}
<span class="text-2xs text-hint text-right">{inheritedText}, shown after saving</span>
{:else}
<span class="text-2xs text-hint">{inheritedText}</span>
{/if}
</div>
{/snippet}
{#snippet row(label: string, description: string | undefined, path: string)}
<div class="flex items-center gap-4 py-1.5">
<div class="flex flex-col grow min-w-0">
<span class="text-xs font-semibold text-emphasis">{label}</span>
{#if description}
<span class="text-2xs text-secondary">{description}</span>
{/if}
</div>
<div class="flex justify-end w-60 shrink-0">
{#if path === 'flows' || path === 'apps'}
{@const key = path as LevelKey}
<ToggleButtonGroup
{disabled}
selected={effective[key]}
onSelected={(v) => update(key, v as AuthorLevel)}
>
{#snippet children({ item })}
<ToggleButton value="none" label="None" small {item} />
<ToggleButton value="compose" label="Compose" small {item} />
<ToggleButton
value="full"
label="Full"
small
disabled={!effective.code}
tooltip={effective.code ? undefined : 'Requires the code capability'}
{item}
/>
{/snippet}
</ToggleButtonGroup>
{:else}
<Toggle
size="xs"
{disabled}
checked={boolValue(path)}
on:change={(e) => update(path, e.detail)}
/>
{/if}
</div>
{@render status(path)}
</div>
{/snippet}
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y">
<span class="text-xs font-semibold text-secondary pb-1">Authoring</span>
{#each authoring as f (f.key)}
{@render row(f.label, f.description, f.key)}
{/each}
</div>
{#if error}
<span class="text-xs text-red-600 dark:text-red-400">{error}</span>
{/if}
<div class="flex flex-col divide-y">
<span class="text-xs font-semibold text-secondary pb-1">Management</span>
{#each management as f (f.key)}
{@render row(f.label, f.description, f.key)}
{/each}
</div>
<div class="flex flex-col divide-y">
<span class="text-xs font-semibold text-secondary pb-1">
Pages in their menu
<span class="font-normal">(hides a page, does not block the API)</span>
</span>
{#each VISIBILITY_KEYS as v (v.key)}
{@render row(v.label, undefined, `visibility.${v.key}`)}
{/each}
</div>
{#if !isEmptyPatch(patch)}
<div>
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
{disabled}
onclick={resetAll}
>
Reset all to the {baseName} values
</Button>
</div>
{/if}
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Select from '$lib/components/select/Select.svelte'
import type { EffectiveRole } from '$lib/gen'
import { roleItems } from '$lib/workspaceRoles'
interface Props {
/** The workspace's roles; undefined while loading. */
roles: EffectiveRole[] | undefined
/** A role name, or `admin`. */
value: string | undefined
/** May return a promise; the picker shows `value` again once it settles. */
onchange: (value: string) => unknown
/** Whether Admin is offered next to the roles. */
admin?: boolean
disabled?: boolean
disablePortal?: boolean
}
let {
roles,
value,
onchange,
admin = true,
disabled = false,
disablePortal = false
}: Props = $props()
const known = $derived(roleItems(roles, { admin }))
// A role the workspace no longer defines grants nothing; it stays visible rather than showing
// nothing selected.
const missing = $derived(value && !known.some((i) => i.value === value) ? value : undefined)
const undefinedValue = $derived(roles ? missing : undefined)
const items = $derived(
missing
? [...known, { label: undefinedValue ? `${missing} (inactive)` : missing, value: missing }]
: known
)
let picks = $state(0)
async function pick(v: string) {
if (v === value) return
try {
await onchange(v)
} finally {
picks++
}
}
const TOOLTIPS: Record<string, string> = {
admin:
'Full control over the workspace: members, settings, every item. Holds everything whatever the role.'
}
</script>
{#if items.length <= 4 && !undefinedValue}
<!-- The group keeps its own selection, so it is remounted after each pick: a pick the parent
refuses then shows `value` again instead of the refused one. -->
{#key picks}
<ToggleButtonGroup selected={value} {disabled} onSelected={pick}>
{#snippet children({ item })}
{#each items as i (i.value)}
<ToggleButton value={i.value} label={i.label} tooltip={TOOLTIPS[i.value]} small {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
{/key}
{:else}
<Select
{items}
{disabled}
{disablePortal}
size="sm"
class="min-w-36"
bind:value={() => value, (v) => v && pick(v)}
/>
{/if}
@@ -1,295 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Section from '$lib/components/Section.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { SaveIcon, EyeIcon, EyeOffIcon } from 'lucide-svelte'
import { untrack } from 'svelte'
const defaultVisibility = {
runs: true,
schedules: true,
resources: true,
variables: true,
assets: true,
triggers: true,
audit_logs: true,
groups: true,
folders: true,
workers: true
}
let operatorWorkspaceSettings = $state({ ...defaultVisibility })
// Kept out of `operatorWorkspaceSettings` so the visibility table's "Enable all" never flips a
// write right, and so these rows stay out of that table.
let builderFlows = $state(false)
let builderApps = $state(false)
// Withdrawable rather than granted: operators hold these until an admin turns them off.
let manageSchedules = $state(true)
let manageTriggers = $state(true)
let originalSettings = $state({
...untrack(() => operatorWorkspaceSettings),
builder_flows: false,
builder_apps: false,
manage_schedules: true,
manage_triggers: true
})
let isChanged = $state(false)
let currentWorkspace: string | null = $state(null)
let confirmBuilderOpen = $state(false)
// Saving sends every key, so saving before the load, or over a late response for another
// workspace, would write defaults over the rights stored there.
let loadedWorkspace: string | null = $state(null)
const settingsPayload = $derived({
...operatorWorkspaceSettings,
builder_flows: builderFlows,
builder_apps: builderApps,
manage_schedules: manageSchedules,
manage_triggers: manageTriggers
})
// The seat cost lands on the first right granted, so confirm only when going from neither to
// either. Adding the second one later is already paid for.
const grantsFirstBuilderRight = $derived(
(builderFlows || builderApps) &&
!(originalSettings.builder_flows || originalSettings.builder_apps)
)
const grantedBuilderKinds = $derived(
[builderFlows && 'flows', builderApps && 'full-code apps'].filter(Boolean).join(' and ')
)
function onSaveClicked() {
if (grantsFirstBuilderRight) {
confirmBuilderOpen = true
} else {
saveSettings()
}
}
async function saveSettings() {
try {
await WorkspaceService.updateOperatorSettings({
workspace: $workspaceStore!,
requestBody: settingsPayload
})
originalSettings = { ...settingsPayload }
isChanged = false
sendUserToast('Operator settings saved successfully!', false)
} catch (error) {
console.error('Error updating operator settings:', error)
sendUserToast(`Failed to save operator settings: ${error?.body ?? error}`, true)
}
}
const descriptions = {
runs: { title: 'Runs', description: 'View runs' },
schedules: { title: 'Schedules', description: 'View schedules' },
resources: { title: 'Resources', description: 'View resources' },
variables: { title: 'Variables', description: 'View variables' },
assets: { title: 'Assets', description: 'View assets' },
triggers: { title: 'Triggers', description: 'View all triggers (HTTP, Websocket, Kafka)' },
audit_logs: { title: 'Audit Logs', description: 'View audit logs' },
groups: { title: 'Groups', description: 'View groups and group members' },
folders: { title: 'Folders', description: 'View folders' },
workers: { title: 'Workers', description: 'View workers and worker groups' }
}
$effect(() => {
if ($workspaceStore && $workspaceStore !== currentWorkspace) {
;(async () => {
const ws = $workspaceStore
currentWorkspace = ws
const settings = await WorkspaceService.getSettings({ workspace: ws })
if (ws !== currentWorkspace) return
// Every value is reset, null settings included: one kept from the previous workspace
// would be saved here with the next unrelated change, builder rights among them.
const {
builder_flows: remoteFlows,
builder_apps: remoteApps,
manage_schedules: remoteSchedules,
manage_triggers: remoteTriggers,
...remoteVisibility
} = settings.operator_settings ?? {}
operatorWorkspaceSettings = { ...defaultVisibility, ...remoteVisibility }
builderFlows = remoteFlows ?? false
builderApps = remoteApps ?? false
manageSchedules = remoteSchedules ?? true
manageTriggers = remoteTriggers ?? true
originalSettings = {
...operatorWorkspaceSettings,
builder_flows: builderFlows,
builder_apps: builderApps,
manage_schedules: manageSchedules,
manage_triggers: manageTriggers
}
loadedWorkspace = ws
})()
}
})
$effect(() => {
isChanged = JSON.stringify(settingsPayload) !== JSON.stringify(originalSettings)
})
const allDisabled = $derived(
Object.values(operatorWorkspaceSettings).every((value) => value === false)
)
const allEnabled = $derived(
Object.values(operatorWorkspaceSettings).every((value) => value === true)
)
</script>
<Section
label="Operator settings"
collapsable={true}
tooltip="Operators run what is shared with them. Choose what else they can change and see in this workspace."
description="Operators run what is shared with them. Choose what else they can change and see in this workspace."
>
{#snippet action()}
<Button
on:click={onSaveClicked}
startIcon={{ icon: SaveIcon }}
disabled={!isChanged || loadedWorkspace !== $workspaceStore}
variant="accent"
>
Save operator settings
</Button>
{/snippet}
<Section
small
label="Build flows and apps"
description="Let operators build flows out of deployed scripts and flows, and full-code apps whose frontend code they write. Apps always run sandboxed, and operators still cannot write backend scripts. Granting either right makes each operator consume a full seat instead of half a seat."
wrapperClass="mb-6"
class="flex flex-col gap-y-1"
>
<Toggle
bind:checked={builderFlows}
options={{ right: 'Operators can build flows' }}
size="xs"
/>
<Toggle
bind:checked={builderApps}
options={{ right: 'Operators can build full-code apps' }}
size="xs"
/>
</Section>
<Section
small
label="Change schedules and triggers"
description="Operators can create, edit and delete schedules and triggers wherever their folder permissions let them write. When turned off, the server refuses these changes, including through the API and the CLI."
wrapperClass="mb-6"
class="flex flex-col gap-y-1"
>
<Toggle
bind:checked={manageSchedules}
options={{ right: 'Operators can manage schedules' }}
size="xs"
/>
<Toggle
bind:checked={manageTriggers}
options={{ right: 'Operators can manage triggers' }}
size="xs"
/>
</Section>
<Section
small
label="Pages in their menu"
description="Hides pages from an operator's menu. It does not block access through the API: use folder and item permissions to restrict what they can read."
>
<DataTable tableFixed={true} size="xs">
<Head>
<tr>
<Cell head first>Section</Cell>
<Cell head>Description</Cell>
<Cell head last>
<ToggleButtonGroup
bind:selected={
() => (allDisabled ? 'false' : allEnabled ? 'true' : ''),
(v) => {
Object.keys(operatorWorkspaceSettings).forEach((key) => {
if (v === 'true') operatorWorkspaceSettings[key] = true
if (v === 'false') operatorWorkspaceSettings[key] = false
})
}
}
>
{#snippet children({ item })}
<ToggleButton
icon={EyeIcon}
small={true}
value={'true'}
label="Enable All"
{item}
/>
<ToggleButton
icon={EyeOffIcon}
small={true}
value={'false'}
label="Disable All"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each Object.entries(descriptions) as [key, { title, description }]}
<tr>
<Cell first>{title}</Cell>
<Cell>{description}</Cell>
<Cell last class="pl-8">
<ToggleButtonGroup
selected={operatorWorkspaceSettings[key] ? 'on' : 'off'}
on:selected={({ detail }) => (operatorWorkspaceSettings[key] = detail === 'on')}
>
{#snippet children({ item })}
<ToggleButton icon={EyeIcon} small={true} value={'on'} label="On" {item} />
<ToggleButton icon={EyeOffIcon} small={true} value={'off'} label="Off" {item} />
{/snippet}
</ToggleButtonGroup>
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</Section>
</Section>
<ConfirmationModal
open={confirmBuilderOpen}
title="Give operators builder rights"
type="info"
confirmationText="Enable builder rights"
onCanceled={() => (confirmBuilderOpen = false)}
onConfirmed={async () => {
confirmBuilderOpen = false
await saveSettings()
}}
>
<div class="flex flex-col gap-2 text-sm">
<span>This applies to every operator of this workspace, not to a chosen few.</span>
<span>
Each of them then consumes a full seat instead of half a seat, which changes what this
instance is billed.
</span>
<span>
They can create, edit and delete {grantedBuilderKinds} wherever their folder permissions already
let them write. Review those permissions before enabling.
</span>
</div>
</ConfirmationModal>
@@ -0,0 +1,393 @@
<script lang="ts">
import { Alert, Badge, Button, Section } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RoleCapabilitiesEditor from './RoleCapabilitiesEditor.svelte'
import {
WorkspaceService,
type EffectiveRole,
type RoleCapabilities,
type RoleOverride,
type RoleOverrides,
type WorkspaceRoles
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import {
applyCapabilitiesPatch,
authorLevelLabel,
baseCapabilitiesFor,
capabilitiesError,
isEmptyPatch,
roleNameError,
VISIBILITY_KEYS
} from '$lib/workspaceRoles'
import { ChevronDown, ChevronRight, Plus, RotateCcw, SaveIcon, Trash2 } from 'lucide-svelte'
import { SvelteMap } from 'svelte/reactivity'
interface Props {
workspace: string
/** The workspace's roles as last read; undefined while loading. */
data: WorkspaceRoles | undefined
onSaved: () => void
}
let { workspace, data, onSaved }: Props = $props()
// Maps, not objects: a role name is any slug, `constructor` and `__proto__` included.
const draft = new SvelteMap<string, RoleOverride>()
let loadedFrom: WorkspaceRoles | undefined = undefined
const open = new SvelteMap<string, boolean>()
let newRoleName = $state('')
let confirmSeatOpen = $state(false)
let saving = $state(false)
$effect(() => {
if (data && data !== loadedFrom) {
loadedFrom = data
draft.clear()
for (const [name, o] of Object.entries(data.overrides ?? {})) {
draft.set(name, structuredClone($state.snapshot(o)))
}
}
})
const instanceRoles = $derived((data?.roles ?? []).filter((r) => r.source === 'instance'))
const workspaceOnlyNames = $derived(
[...draft.keys()].filter((n) => !instanceRoles.some((r) => r.name === n)).sort()
)
function overriddenKeys(o: RoleOverride | undefined): string[] {
const c = o?.capabilities ?? {}
const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers'].filter(
(k) => c[k] != null
)
for (const { key } of VISIBILITY_KEYS) {
if (c.visibility?.[key] != null) keys.push(`visibility.${key}`)
}
return keys
}
/**
* The instance layer of a role. The listing gives only capabilities with the stored override
* applied, so a field that override sets has no known instance value: `unknown` lists them.
*/
function baseFor(name: string): { base: RoleCapabilities; unknown: string[] } {
const role = instanceRoles.find((r) => r.name === name)
if (!role) return { base: baseCapabilitiesFor(name), unknown: [] }
const stored = data && Object.hasOwn(data.overrides, name) ? data.overrides[name] : undefined
return { base: role.capabilities, unknown: overriddenKeys(stored) }
}
function effectiveFor(name: string): RoleCapabilities {
return applyCapabilitiesPatch(baseFor(name).base, draft.get(name)?.capabilities)
}
const AUTHORING_KEYS = ['code', 'flows', 'apps']
/** Whether saving makes an authoring field of `name` fall back to an unknown instance value. */
function authoringUnknown(name: string): boolean {
const set = overriddenKeys(draft.get(name))
return baseFor(name).unknown.some((k) => AUTHORING_KEYS.includes(k) && !set.includes(k))
}
function isAuthor(c: RoleCapabilities): boolean {
return c.code || c.flows !== 'none' || c.apps !== 'none'
}
function summary(c: RoleCapabilities): string {
return [
c.code ? 'Code' : 'No code',
`Flows: ${authorLevelLabel(c.flows).toLowerCase()}`,
`Apps: ${authorLevelLabel(c.apps).toLowerCase()}`
].join(' · ')
}
/** The overrides to store: instance roles left as the instance has them are dropped. */
function cleaned(entries: Iterable<[string, RoleOverride]>): RoleOverrides {
const out: [string, RoleOverride][] = []
for (const [name, v] of entries) {
const label = v.label?.trim() || undefined
const isInstance = instanceRoles.some((r) => r.name === name)
if (isInstance && !label && isEmptyPatch(v.capabilities)) continue
out.push([
name,
{
...(label ? { label } : {}),
capabilities: isEmptyPatch(v.capabilities) ? {} : v.capabilities
}
])
}
return Object.fromEntries(out)
}
const cleanedDraft = $derived(cleaned(draft))
const dirty = $derived(
data != undefined &&
JSON.stringify(cleanedDraft) !== JSON.stringify(cleaned(Object.entries(data.overrides)))
)
const errors = $derived.by(() => {
const e: Record<string, string> = Object.create(null)
for (const name of [...instanceRoles.map((r) => r.name), ...workspaceOnlyNames]) {
// Left to the server, which knows the instance value.
if (authoringUnknown(name)) continue
const err = capabilitiesError(effectiveFor(name))
if (err) e[name] = err
}
return e
})
// A role that starts authoring moves each member from half a seat to a full one. One that
// falls back to an unknown instance value may, so it is confirmed too.
const newAuthorRoles = $derived(
[
...instanceRoles.map((r) => r.name),
...(data?.custom_roles_enabled ? workspaceOnlyNames : [])
].filter((name) => {
const before = data?.roles.find((r) => r.name === name)?.capabilities
if (before && isAuthor(before)) return false
return isAuthor(effectiveFor(name)) || authoringUnknown(name)
})
)
function setPatch(name: string, capabilities: RoleOverride['capabilities']) {
draft.set(name, { ...(draft.get(name) ?? {}), capabilities })
}
function setLabel(name: string, label: string) {
draft.set(name, { ...(draft.get(name) ?? {}), label })
}
function resetRole(name: string) {
draft.delete(name)
}
const newRoleError = $derived.by(() => {
const name = newRoleName.trim()
if (!name) return undefined
if (instanceRoles.some((r) => r.name === name) || draft.has(name)) {
return 'This role already exists'
}
return roleNameError(name)
})
function addRole() {
const name = newRoleName.trim()
if (!name || newRoleError) return
draft.set(name, { capabilities: {} })
open.set(name, true)
newRoleName = ''
}
function onSaveClicked() {
if (newAuthorRoles.length > 0) confirmSeatOpen = true
else save()
}
async function save() {
saving = true
try {
await WorkspaceService.updateWorkspaceRoleOverrides({
workspace,
requestBody: cleanedDraft
})
sendUserToast('Workspace roles saved')
onSaved()
} catch (e) {
sendUserToast(`Failed to save workspace roles: ${e?.body ?? e}`, true)
} finally {
saving = false
}
}
</script>
{#snippet roleCard(name: string, role: EffectiveRole | undefined)}
{@const { base, unknown } = baseFor(name)}
{@const caps = applyCapabilitiesPatch(base, draft.get(name)?.capabilities)}
{@const overridden = Object.hasOwn(cleanedDraft, name)}
{@const frozen = !role && !data?.custom_roles_enabled}
<div class="flex flex-col rounded-md border bg-surface-tertiary">
<div class="flex items-center gap-2 p-2">
<div class="flex flex-col w-40 shrink-0 min-w-0">
<span class="text-xs font-semibold text-emphasis truncate">
{draft.get(name)?.label?.trim() || role?.label || name}
</span>
<span class="text-2xs text-secondary font-mono truncate">{name}</span>
</div>
{#if role}
<Badge color="gray" verySmall>Instance</Badge>
{#if overridden}
<Badge color="blue" verySmall>Overridden here</Badge>
{/if}
{:else if frozen}
<Badge color="orange" verySmall>Read-only without a license</Badge>
{:else}
<Badge color="indigo" verySmall>This workspace only</Badge>
{/if}
<span class="text-2xs text-secondary truncate grow">
{summary(caps)}
</span>
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: open.get(name) ? ChevronDown : ChevronRight }}
onclick={() => open.set(name, !open.get(name))}
>
Capabilities
</Button>
{#if role}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
disabled={!overridden}
title="Use the instance definition of this role"
onclick={() => resetRole(name)}
>
Reset role
</Button>
{:else}
<Button
variant="subtle"
unifiedSize="sm"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
aria-label="Remove role"
title="Remove role"
onclick={() => resetRole(name)}
/>
{/if}
</div>
{#if errors[name] && !open.get(name)}
<span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2">{errors[name]}</span>
{/if}
{#if open.get(name)}
<div class="border-t p-3 flex flex-col gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-emphasis w-24">Label</span>
<div class="w-60">
<TextInput
size="sm"
value={draft.get(name)?.label ?? ''}
inputProps={{
placeholder: role?.label ?? name,
disabled: frozen,
'aria-label': 'Label in this workspace',
oninput: (e) => setLabel(name, e.currentTarget.value)
}}
/>
</div>
</div>
<RoleCapabilitiesEditor
{base}
unknownBase={unknown}
patch={draft.get(name)?.capabilities ?? {}}
baseName={role ? 'instance' : 'default'}
setText={role ? 'Overridden' : 'Set'}
inheritedText={role ? 'From instance' : 'Default'}
disabled={frozen}
onchange={(p) => setPatch(name, p)}
/>
</div>
{/if}
</div>
{/snippet}
<Section
label="Workspace roles"
collapsable={true}
description="The roles members of this workspace can hold, as the instance defines them. Override what a role allows in this workspace; a field you do not override keeps following the instance. Workspace admins hold everything regardless of their role."
>
{#snippet action()}
<Button
onclick={onSaveClicked}
startIcon={{ icon: SaveIcon }}
disabled={!dirty || saving || Object.keys(errors).length > 0}
variant="accent"
>
Save workspace roles
</Button>
{/snippet}
{#if !data}
<span class="text-xs text-secondary">Loading roles...</span>
{:else}
<div class="flex flex-col gap-3">
{#each instanceRoles as role (role.name)}
{@render roleCard(role.name, role)}
{/each}
{#each workspaceOnlyNames as name (name)}
{@render roleCard(name, undefined)}
{/each}
{#if data.custom_roles_enabled}
<div class="flex items-start gap-2">
<div class="flex flex-col gap-0.5 w-60">
<TextInput
size="sm"
bind:value={newRoleName}
error={newRoleError}
inputProps={{
placeholder: 'new role name',
'aria-label': 'New role name',
onkeydown: (e) => e.key === 'Enter' && addRole()
}}
/>
{#if newRoleError}
<span class="text-2xs text-red-600 dark:text-red-400">{newRoleError}</span>
{/if}
</div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
disabled={!newRoleName.trim() || !!newRoleError}
onclick={addRole}
>
Add workspace role
</Button>
</div>
<span class="text-2xs text-secondary">
A workspace role exists only here. Its unset capabilities default to the operator rights.
One still held by a member, invite, auto-invite or instance group mapping cannot be
removed.
</span>
{:else}
<Alert
type="info"
size="xs"
title="Adding roles requires a valid Windmill Enterprise Edition license"
>
This workspace can still override what the instance roles allow. Roles added here earlier
keep applying but are read-only; they can still be removed.
</Alert>
{/if}
</div>
{/if}
</Section>
<ConfirmationModal
open={confirmSeatOpen}
title="Roles that now author consume a full seat"
type="info"
confirmationText="Save"
onCanceled={() => (confirmSeatOpen = false)}
onConfirmed={async () => {
confirmSeatOpen = false
await save()
}}
>
<div class="flex flex-col gap-2 text-sm">
<span>
With this change, members holding
<b>{newAuthorRoles.join(', ')}</b> can write code or build flows or apps in this workspace, or
may again if the instance definition allows it.
</span>
<span>
Each of them then consumes a full seat instead of half a seat, which changes what this
instance is billed. They can author wherever their folder permissions already let them write.
</span>
</div>
</ConfirmationModal>
@@ -4,7 +4,17 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte'
import WorkspaceRoleOverrides from '$lib/components/settings/WorkspaceRoleOverrides.svelte'
import RolePicker from '$lib/components/settings/RolePicker.svelte'
import {
defaultRoleName,
loadWorkspaceRoles,
OPERATOR_ROLE,
pickerValue,
roleLabel,
roleRequestFields,
validRoleOr
} from '$lib/workspaceRoles'
import InviteUser from '$lib/components/InviteUser.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
@@ -12,7 +22,7 @@
import Head from '$lib/components/table/Head.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { CancelablePromise, User, UserUsage } from '$lib/gen'
import type { CancelablePromise, User, UserUsage, WorkspaceRoles } from '$lib/gen'
import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen'
import {
userStore,
@@ -39,12 +49,28 @@
let offboardingUser: string | undefined = $state(undefined)
let offboardingReassignOnly = $state(false)
let users: User[] | undefined = $state(undefined)
// `role` is the member's workspace role, which `listUsers` returns but the spec's `User` omits.
type Member = User & { role?: string }
let users: Member[] | undefined = $state(undefined)
let invites: WorkspaceInvite[] = $state([])
let filteredUsers: User[] | undefined = $state(undefined)
let filteredUsers: Member[] | undefined = $state(undefined)
let userFilter = $state('')
let auto_invite_domain: string | undefined = $state()
let operatorOnly: boolean | undefined = $state(undefined)
// Unset until picked or stored; the pickers and payloads fall back to the default role.
let autoInviteRole: string | undefined = $state(undefined)
let workspaceRoles = $state<WorkspaceRoles | undefined>(undefined)
const roles = $derived(workspaceRoles?.roles)
async function loadRoles(): Promise<void> {
const workspace = $workspaceStore!
try {
const r = await loadWorkspaceRoles(workspace)
if (workspace === $workspaceStore) workspaceRoles = r
} catch (e) {
sendUserToast(`Failed to load the workspace roles: ${e?.body ?? e}`, true)
}
}
let autoAdd: boolean | undefined = $state(false)
let addAdminsAndDevelopersToForks = $state(false)
let nbDisplayed = $state(30)
@@ -56,7 +82,7 @@
// Add new instance group form state
let selectedNewInstanceGroup: string | undefined = $state(undefined)
let selectedNewRole: string | undefined = $state('developer')
let selectedNewRole: string | undefined = $state(undefined)
// Service account creation
@@ -113,13 +139,14 @@
enabled?: boolean
domain?: string
operator?: boolean
role?: string
mode?: string
instance_groups?: string[]
instance_groups_roles?: Record<string, string>
}
| undefined
auto_invite_domain = autoInvite?.enabled ? (autoInvite?.domain ?? '*') : undefined
operatorOnly = autoInvite?.operator ?? false
autoInviteRole = autoInvite?.role ?? (autoInvite?.operator ? OPERATOR_ROLE : undefined)
autoAdd = autoInvite?.mode === 'add'
autoAddInstanceGroups = autoInvite?.instance_groups || []
autoAddInstanceGroupsRoles = autoInvite?.instance_groups_roles || {}
@@ -190,11 +217,11 @@
let lastSeen: { workspace: string; signature: string; seq: number } | undefined = undefined
let usersIssued = 0
// Every field a paid seat count depends on — the developer/operator split, and the
// two categories billing excludes.
function membershipSignature(list: User[] | undefined): string {
// Every field a paid seat count depends on — the role, and the two categories billing
// excludes.
function membershipSignature(list: Member[] | undefined): string {
return (list ?? [])
.map((u) => `${u.email}:${u.operator}:${u.disabled}:${u.is_service_account}`)
.map((u) => `${u.email}:${u.operator}:${u.role}:${u.disabled}:${u.is_service_account}`)
.sort()
.join(',')
}
@@ -218,29 +245,25 @@
}
}
// Throws, so each caller rolls back its optimistic change.
async function saveInstanceGroupSettings(): Promise<void> {
try {
await WorkspaceService.editInstanceGroups({
workspace: $workspaceStore ?? '',
requestBody: {
groups: autoAddInstanceGroups,
roles: autoAddInstanceGroupsRoles
}
})
sendUserToast('Instance group settings saved')
// Refresh user list to show newly auto-added users
listUsers()
} catch (e) {
console.error('Failed to save instance group settings:', e)
sendUserToast('Failed to save settings', true)
}
await WorkspaceService.editInstanceGroups({
workspace: $workspaceStore ?? '',
requestBody: {
groups: autoAddInstanceGroups,
roles: autoAddInstanceGroupsRoles
}
})
sendUserToast('Instance group settings saved')
// Refresh user list to show newly auto-added users
listUsers()
}
async function addInstanceGroup(): Promise<void> {
if (!selectedNewInstanceGroup || !selectedNewRole) return
if (!selectedNewInstanceGroup) return
const groupToAdd = selectedNewInstanceGroup
const roleToAdd = selectedNewRole
const roleToAdd = validRoleOr(roles, selectedNewRole)
try {
autoAddInstanceGroups = [...autoAddInstanceGroups, groupToAdd]
@@ -248,14 +271,14 @@
// Reset form
selectedNewInstanceGroup = undefined
selectedNewRole = 'developer'
selectedNewRole = undefined
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroups = autoAddInstanceGroups.filter((g) => g !== groupToAdd)
delete autoAddInstanceGroupsRoles[groupToAdd]
sendUserToast('Failed to add instance group', true)
sendUserToast(`Failed to add instance group: ${e?.body ?? e}`, true)
}
}
@@ -273,7 +296,7 @@
if (previousRole) {
autoAddInstanceGroupsRoles[groupName] = previousRole
}
sendUserToast('Failed to remove instance group', true)
sendUserToast(`Failed to remove instance group: ${e?.body ?? e}`, true)
}
}
@@ -286,7 +309,7 @@
} catch (e) {
// Rollback on error
autoAddInstanceGroupsRoles[groupName] = previousRole
sendUserToast('Failed to update role', true)
sendUserToast(`Failed to update role: ${e?.body ?? e}`, true)
}
}
@@ -315,6 +338,8 @@
listInvites()
loadSettings()
loadInstanceGroups()
workspaceRoles = undefined
loadRoles()
})
}
})
@@ -356,20 +381,16 @@
let nbInviteDisplayed = $state(50)
async function inviteUser(email: string, selected: 'operator' | 'developer' | 'admin') {
async function inviteUser(email: string, selected: string) {
try {
await WorkspaceService.inviteUser({
workspace: $workspaceStore!,
requestBody: {
email,
is_admin: selected == 'admin',
operator: selected == 'operator'
}
requestBody: { email, ...roleRequestFields(selected) }
})
sendUserToast(`Invited ${email}`)
} catch (e) {
console.error('Failed to invite user:', e)
sendUserToast('Failed to invite user', true)
sendUserToast(`Failed to invite user: ${e?.body ?? e}`, true)
}
if (!(await UserService.existsEmail({ email }))) {
@@ -412,7 +433,7 @@
workspace: $workspaceStore ?? '',
requestBody: enable
? {
operator: operatorOnly ?? false,
...roleRequestFields(validRoleOr(roles, autoInviteRole)),
invite_all: !isCloudHosted(),
auto_add: autoAdd
}
@@ -430,7 +451,7 @@
sendUserToast(message)
} catch (e) {
console.error('Failed to update auto invite:', e)
sendUserToast('Failed to update auto invite', true)
sendUserToast(`Failed to update auto invite: ${e?.body ?? e}`, true)
}
loadSettings()
listInvites()
@@ -548,32 +569,18 @@
{/if}
<span class="text-xs mb-1">Role <Tooltip>Role of the auto-added users</Tooltip></span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={async (e) => {
operatorOnly = e.detail === 'operator'
<RolePicker
{roles}
admin={false}
disablePortal
value={validRoleOr(roles, autoInviteRole)}
onchange={async (v) => {
autoInviteRole = v
if (auto_invite_domain != undefined) {
await updateAutoInvite(true)
}
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
{/snippet}
</ToggleButtonGroup>
/>
<div class="mt-6">
<Toggle
@@ -660,36 +667,12 @@
<div class="flex flex-col gap-1">
<span class="text-xs text-primary">Role</span>
<ToggleButtonGroup
selected={selectedNewRole}
on:selected={(e) => {
selectedNewRole = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
<RolePicker
{roles}
disablePortal
value={validRoleOr(roles, selectedNewRole)}
onchange={(v) => (selectedNewRole = v)}
/>
</div>
</div>
@@ -697,7 +680,7 @@
color="blue"
size="xs"
startIcon={{ icon: Plus }}
disabled={!selectedNewInstanceGroup || !selectedNewRole}
disabled={!selectedNewInstanceGroup}
onclick={addInstanceGroup}
>
Add
@@ -730,38 +713,15 @@
</td>
<td class="py-2">
<div>
<ToggleButtonGroup
selected={autoAddInstanceGroupsRoles[groupName] ||
'developer'}
on:selected={async (e) => {
autoAddInstanceGroupsRoles[groupName] = e.detail
await updateGroupRole(groupName, e.detail)
<RolePicker
{roles}
disablePortal
value={autoAddInstanceGroupsRoles[groupName] ||
defaultRoleName(roles)}
onchange={async (v) => {
await updateGroupRole(groupName, v)
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
/>
</div>
</td>
<td class="py-2">
@@ -800,10 +760,11 @@
{/if}
{#if showAutoInviteToggle}
<InviteUser {inviteUser} />
<InviteUser {inviteUser} {roles} />
{/if}
<AddUser
{roles}
on:new={() => {
listUsers()
listInvites()
@@ -851,7 +812,7 @@
<tbody>
{#if filteredUsers}
{#each sortedUsers().slice(0, nbDisplayed) as user, index (user.email)}
{@const { email, username, is_admin, operator, disabled, added_via } = user}
{@const { email, username, is_admin, disabled, added_via } = user}
<!-- Add separator between manual users and instance group users -->
{#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'}
<tr class="bg-surface-secondary">
@@ -901,63 +862,35 @@
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
{roleLabel(roles, pickerValue(user))}
</span>
<Tooltip>Role is managed through instance group configuration above.</Tooltip>
</div>
{:else}
<ToggleButtonGroup
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
on:selected={async (e) => {
if (is_admin && email == $userStore?.email && e.detail != 'admin') {
<RolePicker
{roles}
value={pickerValue(user)}
onchange={async (v) => {
if (is_admin && email == $userStore?.email && v != 'admin') {
sendUserToast(
'Admins cannot be demoted by themselves, ask another admin to demote you',
true
)
e.preventDefault()
listUsers()
return
}
const body =
e.detail == 'admin'
? { is_admin: true, operator: false }
: e.detail == 'operator'
? { is_admin: false, operator: true }
: { is_admin: false, operator: false }
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: body
})
listUsers()
try {
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: roleRequestFields(v)
})
} catch (e) {
sendUserToast(`Failed to change role: ${e?.body ?? e}`, true)
}
await listUsers()
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
/>
{/if}
</div>
</Cell>
@@ -1083,7 +1016,9 @@
<div class="pt-12"></div>
<WorkspaceOperatorSettings />
{#if $workspaceStore}
<WorkspaceRoleOverrides workspace={$workspaceStore} data={workspaceRoles} onSaved={loadRoles} />
{/if}
<div class="pt-12"></div>
@@ -1118,7 +1053,7 @@
{#snippet action()}
{#if showAutoInviteToggle && !isAdminsWorkspaceWithoutEE}
<div class="flex gap-2 items-center">
<InviteUser {inviteUser} />
<InviteUser {inviteUser} {roles} />
</div>
{/if}
{/snippet}
@@ -1132,56 +1067,27 @@
</Head>
<tbody class="divide-y bg-surface">
{#if invites?.length > 0}
{#each invites.slice(0, nbInviteDisplayed) as { email, is_admin, operator }}
{#each invites.slice(0, nbInviteDisplayed) as invite}
{@const { email, is_admin, operator } = invite}
<Row>
<Cell first>{email}</Cell>
<Cell>
<div>
<ToggleButtonGroup
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
on:selected={async (e) => {
const body =
e.detail == 'admin'
? { is_admin: true, operator: false }
: e.detail == 'operator'
? { is_admin: false, operator: true }
: { is_admin: false, operator: false }
await WorkspaceService.inviteUser({
workspace: $workspaceStore ?? '',
requestBody: {
email,
...body
}
})
listUsers()
<RolePicker
{roles}
value={pickerValue(invite)}
onchange={async (v) => {
try {
await WorkspaceService.inviteUser({
workspace: $workspaceStore ?? '',
requestBody: { email, ...roleRequestFields(v) }
})
} catch (e) {
sendUserToast(`Failed to change role: ${e?.body ?? e}`, true)
}
await listInvites()
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
/>
</div>
</Cell>
<Cell last actions>
@@ -38,7 +38,6 @@
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
import { accountSetup } from './accountSetup.svelte'
import { EXECUTIONS_HINT } from './executionsHint'
import { sidebarPageAllowed } from './operatorRoutes'
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
import {
userWorkspaces,
@@ -49,7 +48,8 @@
enterpriseLicense,
isCriticalAlertsUIOpen,
usageStore,
isPremiumStore
isPremiumStore,
roleCapabilities
} from '$lib/stores'
let {
@@ -250,7 +250,7 @@
icon: Settings,
action: () => goto(USER_SETTINGS_HASH)
},
...($userStore?.operator
...(!$roleCapabilities.code
? [
{
displayName: 'Take the tour',
@@ -318,7 +318,7 @@
label="Workers"
href="/workers"
icon={ServerCog}
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')}
disabled={!$roleCapabilities.visibility.workers}
{isCollapsed}
aiId="sidebar-menu-link-workers"
aiDescription="Button to navigate to workers"
@@ -364,7 +364,7 @@
label="Audit logs"
href="/audit_logs"
icon={Eye}
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')}
disabled={!$roleCapabilities.visibility.audit_logs}
{isCollapsed}
aiId="sidebar-menu-link-audit-logs"
aiDescription="Button to navigate to audit logs"
@@ -8,7 +8,8 @@
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
devopsRole
devopsRole,
roleCapabilities
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
@@ -65,7 +66,7 @@
import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte'
import AzureIcon from '../icons/triggers/AzureIcon.svelte'
import { leaveCurrentWorkspace } from './leaveWorkspace'
import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes'
import type { VisibilityKey } from '$lib/roleCapabilities'
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
@@ -183,8 +184,8 @@
}
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
function allows(key: OperatorPageKey) {
return sidebarPageAllowed($userStore?.operator, currentWs, key)
function allows(key: VisibilityKey) {
return $roleCapabilities.visibility[key]
}
const itemClass = twMerge(
@@ -228,7 +229,7 @@
label: p.label,
href: p.path === '/' ? `/` : `${p.path}`,
icon: p.icon,
disabled: p.operatorKey ? !allows(p.operatorKey) : false,
disabled: p.visibilityKey ? !allows(p.visibilityKey) : false,
...MAIN_MENU_EXTRAS[p.label]
}))
.filter((l) => !excludeMainLabels.includes(l.label))
@@ -436,7 +437,7 @@
]
: [])
],
disabled: $userStore?.operator
disabled: !$roleCapabilities.code
},
{
label: 'Workers',
@@ -6,8 +6,11 @@
usageStore,
workspaceUsageStore,
isPremiumStore,
workspaceStore
workspaceStore,
userWorkspaces,
roleCapabilities
} from '$lib/stores'
import { isRestricted } from '$lib/roleCapabilities'
import { USER_SETTINGS_HASH } from './settings'
import { isCloudHosted } from '$lib/cloud'
import { twMerge } from 'tailwind-merge'
@@ -32,6 +35,12 @@
// An account entered through an invite link that still has no credentials of its own.
let pendingSetup = $derived(accountSetup.pending)
// A server that predates roles sends no role name, and its only restricted role is operator.
const roleName = $derived(
$userWorkspaces.find((w) => w.id === $workspaceStore)?.role ?? 'operator'
)
const roleLabel = $derived(roleName.charAt(0).toUpperCase() + roleName.slice(1))
const itemClass = twMerge(
'text-secondary text-left font-normal text-xs ',
'flex flex-row items-center gap-2 px-4 py-3 w-full',
@@ -62,8 +71,8 @@
Superadmin, not a member of this workspace <Crown size={14} />
{:else if $userStore?.is_admin}
Admin of this workspace <Crown size={14} />
{:else if $userStore?.operator}
Operator in this workspace <ServerCog size={14} />
{:else if isRestricted($roleCapabilities)}
{roleLabel} in this workspace <ServerCog size={14} />
{/if}
</span>
{#if $userStore?.non_member}
@@ -2,8 +2,10 @@
import { tick, untrack } from 'svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import { workspaceMenuHref } from './workspaceMenuHref'
import { isOperatorInWorkspace } from './operatorRoutes'
import { capabilitiesIn, hidesPages, isRestricted } from '$lib/roleCapabilities'
import type { RoleCapabilities } from '$lib/gen'
import {
roleCapabilities,
isPremiumStore,
maybePremium,
superadmin,
@@ -85,16 +87,15 @@
await fixupUrlAfterWorkspaceSwitch(id, { landOnHome })
}
// Operator page access is granted per workspace, so a switch lands on home rather than
// on a page either side may refuse. Both signals are positive: `userStore.operator` is
// an explicit flag for the workspace being left, and `isOperatorInWorkspace` only ever
// confirms the target (its NULL is ambiguous, so a `false` from it is never taken as
// proof of a developer on its own). Leaving one developer workspace for another keeps
// the page, as it always has.
// What a role may see and do is per workspace, so a switch that leaves or enters a restricted
// role lands on home rather than on a page either side may refuse. Leaving one unrestricted
// workspace for another keeps the page.
function landsOnHome(id: string): boolean {
if (keepPageOnSwitch) return false
if ($userStore?.operator) return true
return isOperatorInWorkspace(($userWorkspaces ?? []).find((w) => w.id === id))
const gated = (caps: RoleCapabilities) => isRestricted(caps) || hidesPages(caps)
if (gated($roleCapabilities)) return true
const target = ($userWorkspaces ?? []).find((w) => w.id === id)
return gated(capabilitiesIn({ is_super_admin: $userStore?.is_super_admin }, target))
}
// Href for the item's navigation (including modifier/middle clicks opening a
@@ -160,7 +161,7 @@
// modal carries its own base-workspace picker). Hidden on non-premium cloud,
// in the admins workspace, or when forking is disabled.
const canForkHere = $derived(
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore, $roleCapabilities)
)
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
@@ -1,5 +1,5 @@
import { fromStore } from 'svelte/store'
import { userStore } from '$lib/stores'
import { roleCapabilities } from '$lib/stores'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
// One preference per role rather than one per account. The same person is both — an operator in
@@ -8,21 +8,21 @@ import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
// Empty until that role presses detach or attach once.
const operatorPref = useLocalStorageValue<string>('nav_detached_operator', '', 'string')
const devPref = useLocalStorageValue<string>('nav_detached_dev', '', 'string')
const user = fromStore(userStore)
const caps = fromStore(roleCapabilities)
/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */
export const navDetached = {
get val(): boolean {
// Unasked, operators start detached: many of them navigate through a workspace's own app,
// and a docked sidebar would take its width.
const operator = !!user.current?.operator
const operator = !caps.current.code
const pref = operator ? operatorPref : devPref
if (pref.val === 'true') return true
if (pref.val === 'false') return false
return operator
},
set val(detached: boolean) {
const pref = user.current?.operator ? operatorPref : devPref
const pref = caps.current.code ? devPref : operatorPref
pref.val = String(detached)
}
}
+14 -14
View File
@@ -11,7 +11,7 @@ import {
Unplug,
Users
} from 'lucide-svelte'
import type { OperatorPageKey } from './operatorRoutes'
import type { VisibilityKey } from '$lib/roleCapabilities'
/**
* The names the page header's breadcrumb gives the workspace's own pages, and the rows the
@@ -20,7 +20,7 @@ import type { OperatorPageKey } from './operatorRoutes'
* Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries
* `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules,
* HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so
* for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by
* for those four the `visibilityKey` below goes unread. Keep the labels in step with those rows by
* hand.
*/
export type NavPage = {
@@ -28,23 +28,23 @@ export type NavPage = {
/** Path under `base`, matched against the current route by longest prefix. */
path: string
icon: any
/** The `operator_settings` key that admits an operator, when the page has one. */
operatorKey?: OperatorPageKey
/** The role visibility flag that shows the page, when the page has one. */
visibilityKey?: VisibilityKey
}
export const NAV_PAGES: NavPage[] = [
{ label: 'Home', path: '/', icon: Home },
{ label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' },
{ label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' },
{ label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' },
{ label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' },
{ label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' },
{ label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' },
{ label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' },
{ label: 'Runs', path: '/runs', icon: Play, visibilityKey: 'runs' },
{ label: 'Variables', path: '/variables', icon: DollarSign, visibilityKey: 'variables' },
{ label: 'Resources', path: '/resources', icon: Boxes, visibilityKey: 'resources' },
{ label: 'Assets', path: '/assets', icon: Pyramid, visibilityKey: 'assets' },
{ label: 'Folders', path: '/folders', icon: FolderOpen, visibilityKey: 'folders' },
{ label: 'Groups', path: '/groups', icon: Users, visibilityKey: 'groups' },
{ label: 'Schedules', path: '/schedules', icon: Calendar, visibilityKey: 'schedules' },
// "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP.
{ label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
{ label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' },
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' }
{ label: 'HTTP routes', path: '/routes', icon: Unplug, visibilityKey: 'triggers' },
{ label: 'Workers', path: '/workers', icon: HardHat, visibilityKey: 'workers' },
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, visibilityKey: 'audit_logs' }
]
/**
@@ -1,26 +0,0 @@
import { describe, it, expect } from 'vitest'
import { sidebarPageAllowed } from './operatorRoutes'
import type { UserWorkspace } from '$lib/stores'
const ws = (operator_settings: any): UserWorkspace =>
({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace
describe('sidebarPageAllowed', () => {
it('shows every page to anyone who is not an operator', () => {
expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true)
expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true)
})
it('admits an operator only where the workspace turned the page on', () => {
expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true)
expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false)
})
it('withholds a page the settings do not mention', () => {
// `=== true`, not truthiness: a right absent from the jsonb, or settings that were never
// written, must not read as granted.
expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false)
expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false)
expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false)
})
})
@@ -1,48 +0,0 @@
import type { OperatorSettings } from '$lib/gen'
import type { UserWorkspace } from '$lib/stores'
/**
* An `operator_settings` key that admits an operator to one page (`triggers` to all of them).
*
* Only the visibility flags: the same object also carries the write rights `builder_flows`,
* `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A
* write right is granted unless withdrawn, so its absence means "never configured" rather than
* "no" — see `docs/operator-write-rights.md`.
*/
export type OperatorPageKey = Extract<
keyof NonNullable<OperatorSettings>,
| 'runs'
| 'schedules'
| 'resources'
| 'variables'
| 'assets'
| 'audit_logs'
| 'triggers'
| 'groups'
| 'folders'
| 'workers'
>
/**
* Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page
* (other gates, such as admin-only entries, still apply); an operator sees only the pages the
* workspace's `operator_settings` turn on.
*/
export function sidebarPageAllowed(
isOperator: boolean | undefined,
workspace: UserWorkspace | undefined,
key: OperatorPageKey
): boolean {
return !isOperator || workspace?.operator_settings?.[key] === true
}
/**
* Whether the user is an operator in `workspace`. The server nulls
* `operator_settings` for a non-operator, so a non-null value narrows an operator
* correctly; the converse does not hold — the column is itself nullable, so a
* genuine operator whose workspace never had settings written also reads as NULL.
* Only rely on `true`, never on `false` meaning "not an operator".
*/
export function isOperatorInWorkspace(workspace: UserWorkspace | undefined): boolean {
return workspace?.operator_settings != null
}
@@ -22,7 +22,7 @@
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { enterpriseLicense, usedTriggerKinds, userWorkspaces, workspaceStore } from '$lib/stores'
import { enterpriseLicense, usedTriggerKinds, workspaceStore } from '$lib/stores'
import { goto, setQuery } from '$lib/navigation'
import { isCloudHosted } from '$lib/cloud'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
@@ -519,10 +519,7 @@
}
const operatorBlocked = $derived(
config.operatorGate &&
!!operatingUser.current?.operator &&
!!$operatingWorkspace &&
!$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.triggers
config.operatorGate && !!$operatingWorkspace && !editRights.capabilities.visibility.triggers
)
const canCreate = $derived(!config.adminOnly || isAdmin)
</script>
@@ -883,7 +880,7 @@
{#if operatorBlocked}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
<p>Page not available for your role</p>
</div>
{:else}
<CenteredPage>
@@ -15,7 +15,8 @@
scriptPath: string
initialScriptPath: string
canWrite: boolean
isOperator: boolean
/** Whether to link to the picked runnable's editor: the role must author its kind. */
allowEdit: boolean
promptText?: string
promptClass?: string
workspace?: string
@@ -30,7 +31,7 @@
scriptPath = $bindable(),
initialScriptPath,
canWrite,
isOperator,
allowEdit,
promptText = 'Pick a script or flow to be triggered',
promptClass = 'text-xs mb-1 text-primary',
workspace = undefined,
@@ -53,7 +54,7 @@
bind:itemKind
bind:scriptPath
allowRefresh={canWrite}
allowEdit={!isOperator}
{allowEdit}
{workspace}
clearable
/>
@@ -91,7 +91,6 @@
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
let amqp_resource_path: string = $state('')
@@ -586,7 +585,7 @@
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!actingUser?.operator}
allowEdit={editRights.roleCanAuthor(itemKind)}
/>
</Section>
{/if}
@@ -54,7 +54,6 @@
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
@@ -526,7 +525,7 @@
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!actingUser?.operator}
allowEdit={editRights.roleCanAuthor(itemKind)}
clearable
/>
{#if emptyString(script_path)}

Some files were not shown because too many files have changed in this diff Show More