+ {:else}
Service account role
{#snippet children({ item })}
diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte
index a05511ea42..976f82c382 100644
--- a/frontend/src/lib/components/CompareDrafts.svelte
+++ b/frontend/src/lib/components/CompareDrafts.svelte
@@ -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
diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte
index 5a22ed012d..1c718287fc 100644
--- a/frontend/src/lib/components/CompareWorkspaces.svelte
+++ b/frontend/src/lib/components/CompareWorkspaces.svelte
@@ -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)
+ )
)
)
diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte
index 22f7c4e55a..4d21e81b29 100644
--- a/frontend/src/lib/components/DraftBadge.svelte
+++ b/frontend/src/lib/components/DraftBadge.svelte
@@ -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 @@
{#if actionsEnabled && isSelf}
- {#if !$userStore?.operator && itemKind && path}
+ {#if canOpenEditor && itemKind && path}
diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte
index 11779afb11..1736520eed 100644
--- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte
+++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte
@@ -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
diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte
index a3a2d19fd6..ad2ee14b99 100644
--- a/frontend/src/lib/components/InstanceSetting.svelte
+++ b/frontend/src/lib/components/InstanceSetting.svelte
@@ -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). -->
+ {:else if setting.fieldType == 'workspace_roles'}
+
{:else}
+ {:else if category == 'Workspace roles'}
+
{:else if category == 'Service logs'}
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
+ /** `selected` is a role name, or `admin`. */
+ inviteUser: (email: string, selected: string) => Promise
+ /** 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))
@@ -46,30 +50,7 @@
bind:value={email}
class="mr-4"
/>
-
- {#snippet children({ item })}
-
-
-
-
-
- {/snippet}
-
+ (picked = v)} />
-{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
+{#if ($roleCaps.code || $roleCaps.flows !== 'none' || $roleCaps.apps !== 'none') && activeDeployRulesets.length > 0}
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 @@
})
-{#if !$userStore?.operator && activeDeployRulesets.length > 0}
+{#if ($roleCapabilities.code || $roleCapabilities.flows !== 'none' || $roleCapabilities.apps !== 'none') && activeDeployRulesets.length > 0}
diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte
index 33ab00abbf..3e842e040f 100644
--- a/frontend/src/lib/components/ResourceEditor.svelte
+++ b/frontend/src/lib/components/ResourceEditor.svelte
@@ -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 | 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(
diff --git a/frontend/src/lib/components/RunFormAdvancedPopup.svelte b/frontend/src/lib/components/RunFormAdvancedPopup.svelte
index 8bf1bd1481..5513e70538 100644
--- a/frontend/src/lib/components/RunFormAdvancedPopup.svelte
+++ b/frontend/src/lib/components/RunFormAdvancedPopup.svelte
@@ -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
- }
- | 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
+ }
+ | 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 @@