fix: judge every session-editor permission by the user acting in that workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-18 15:21:56 +02:00
co-authored by Claude Opus 5
parent dbe0b49aca
commit f4e8fe4838
37 changed files with 291 additions and 128 deletions
+6 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { superadmin, userStore, type DBSchema } from '$lib/stores'
import { superadmin, type DBSchema } from '$lib/stores'
import {
ChevronDownIcon,
EditIcon,
@@ -33,6 +33,10 @@
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset } from '$lib/gen'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -644,7 +648,7 @@
Import schema from database
</span>
</button>
{#if !!$userStore?.is_admin || !!$superadmin}
{#if !!actingUser?.is_admin || !!$superadmin}
<button
onclick={() => onImport('schema_and_data')}
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
@@ -1,10 +1,13 @@
<script lang="ts">
import { userStore } from '$lib/stores'
import { SettingsIcon } from 'lucide-svelte'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import DefaultScriptsInner from './DefaultScriptsInner.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
placement?: 'left' | 'right'
@@ -17,7 +20,7 @@
let drawer: Drawer | undefined = $state()
</script>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
{#if actingUser?.is_admin || actingUser?.is_super_admin}
<Drawer bind:this={drawer} {placement}>
<DrawerContent title="Edit Default Scripts" on:close={drawer?.closeDrawer}>
<DefaultScriptsInner />
@@ -16,9 +16,14 @@
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
import { isOwner } from '$lib/utils'
import { userStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
summary?: string
@@ -118,7 +123,7 @@
// Treat an empty path as ownable so the pen popover lets a user pick the
// path for a brand-new item. `Path.reset()` then synthesizes a default
// under their own user/folder scope.
let own = $derived(!path || isOwner(path, $userStore, $operatingWorkspace))
let own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace))
// Virtual entry for the picker: surfaces the currently-edited item at its
// live path (which may differ from `savedPath` mid-rename, so the picker
@@ -33,13 +33,18 @@
import { Button, ButtonType } from '$lib/components/common'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { VolumeService } from '$lib/gen'
import { globalDbManagerDrawer, globalS3FilePickerExplorer, userStore } from '$lib/stores'
import { globalDbManagerDrawer, globalS3FilePickerExplorer } from '$lib/stores'
import { isS3Uri } from '$lib/utils'
import { Database, File, HardDriveIcon } from 'lucide-svelte'
import DucklakeIcon from './icons/DucklakeIcon.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const {
asset,
@@ -78,7 +83,7 @@
</script>
<Button
disabled={$userStore?.operator || disabled}
disabled={actingUser?.operator || disabled}
unifiedSize={'md'}
variant={buttonVariant}
wrapperClasses={className}
@@ -105,9 +105,14 @@
import { UserDraft } from '$lib/userDraft.svelte'
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
initialPath = $bindable(''),
@@ -604,7 +609,7 @@
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
@@ -615,7 +620,7 @@
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds,
initialPath
)
@@ -1391,7 +1396,7 @@
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
{#key renderCount}
{#if !$userStore?.operator}
{#if !actingUser?.operator}
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { enterpriseLicense, userStore } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Loader2, Github, RotateCw, Plus, Minus, Download, AlertTriangle } from 'lucide-svelte'
@@ -18,9 +18,14 @@
type GitHubAppState
} from '$lib/githubApp'
import RepositorySelector from './RepositorySelector.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
resourceType: string
@@ -74,7 +79,7 @@
let showGitHubApp = $derived(
resourceType === 'git_repository' &&
$operatingWorkspace &&
($userStore?.is_admin || $userStore?.is_super_admin)
(actingUser?.is_admin || actingUser?.is_super_admin)
)
// Load GitHub installations when conditions are met
@@ -1,5 +1,5 @@
<script lang="ts">
import { userStore, enterpriseLicense } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import { GitSyncService, type GitlabProject } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Popover from './meltComponents/Popover.svelte'
@@ -8,9 +8,14 @@
import TextInput from './text_input/TextInput.svelte'
import Select from './select/Select.svelte'
import { GitBranch, Gitlab, Loader2 } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
resourceType: string
@@ -53,7 +58,7 @@
let show = $derived(
resourceType === 'git_repository' &&
!!ws &&
($userStore?.is_admin || $userStore?.is_super_admin)
(actingUser?.is_admin || actingUser?.is_super_admin)
)
// The project listing is served by an enterprise-only route, so on a build
// without it the form's first request would 404. The button still shows,
@@ -34,9 +34,14 @@
type GroupDraft,
type GroupRole
} from '$lib/groupDraft'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const ROLE_TOOLTIPS = {
member:
@@ -77,7 +82,7 @@
}: Props = $props()
const restricted = $derived(
isDemoWorkspaceRestricted($operatingWorkspace, $userStore?.is_admin, $userStore?.is_super_admin)
isDemoWorkspaceRestricted($operatingWorkspace, actingUser?.is_admin, actingUser?.is_super_admin)
)
let can_write = $state(false)
@@ -163,7 +168,7 @@
opts?.baselineOnly ? (baseline = structuredClone(value)) : setDraft(value)
try {
group = await GroupService.getGroup({ workspace: $operatingWorkspace!, name })
can_write = canWrite(name, group.extra_perms ?? {}, $userStore)
can_write = canWrite(name, group.extra_perms ?? {}, actingUser)
apply({
summary: group.summary ?? '',
members: Array.from(
@@ -12,9 +12,14 @@
import InfiniteList from './InfiniteList.svelte'
import { twMerge } from 'tailwind-merge'
import SavedInputsPickerViewer from './SavedInputsPickerViewer.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
previewArgs?: any
@@ -257,8 +262,8 @@
{#snippet children({ item, hover })}
{@const editOptions =
item.created_by == $userStore?.username ||
$userStore?.is_admin ||
$userStore?.is_super_admin}
actingUser?.is_admin ||
actingUser?.is_super_admin}
<Cell>
<div class="center-center">
<Save size={12} />
@@ -30,7 +30,10 @@
workerTags,
workspaceStore
} from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import {
emptySchema,
emptyString,
@@ -191,6 +194,8 @@
// than the navigation workspace ($workspaceStore, which stays put). indicatorPath
// is the matching draft path (URL path full-page, session target in preview).
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace)
const indicatorPath = $derived(autosavePath ?? userDraftPath)
@@ -225,8 +230,8 @@
let preserveOnBehalfOf = $state(false)
const WM_DEPLOYERS_GROUP = 'wm_deployers'
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer)
let originalOnBehalfOfEmail = $derived(savedScript?.on_behalf_of_email)
let originalOnBehalfOfPermissionedAs = $derived(savedScript?.on_behalf_of)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
@@ -742,7 +747,7 @@
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
!!actingUser?.is_admin || !!actingUser?.is_super_admin,
usedTriggerKinds,
script.path,
true
@@ -1160,7 +1165,7 @@
on:confirmed={handleDraftTriggersConfirmed}
/>
{#if !$userStore?.operator}
{#if !actingUser?.operator}
<Drawer
placement="right"
bind:open={metadataOpen}
@@ -11,9 +11,14 @@
import LabelsInput from './LabelsInput.svelte'
import InheritedLabels from './InheritedLabels.svelte'
import Badge from './common/badge/Badge.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
summary?: string
@@ -50,7 +55,7 @@
editSummary = summary ?? ''
editPath = path ?? ''
labelsDirty = false
own = isOwner(path ?? '', $userStore, $operatingWorkspace)
own = isOwner(path ?? '', actingUser, $operatingWorkspace)
onBehalfOfEmail = undefined
if (kind === 'flow' && $operatingWorkspace && path) {
checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => {
@@ -9,14 +9,19 @@
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { CheckCircle2, XCircle } from 'lucide-svelte'
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
import { enterpriseLicense, userStore } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import { Button } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
flow_status: Record<string, WorkflowStatus>
@@ -263,7 +268,7 @@
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{/if}
</div>
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
{#if canApprove && selfApprovalDisabled && actingUser?.is_admin}
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
Self-approval is disabled but allowed because you are an admin/owner
</div>
@@ -26,9 +26,14 @@
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspaceStore = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -84,20 +89,20 @@
const opWs = $derived(operatingWorkspace ?? $operatingWorkspaceStore)
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
// Admins always pass the backend check. For everyone else, fail closed
// while the workspace protection rules are still loading so the toggle
// is never briefly enabled for a user the rules will end up restricting.
let rulesetsLoaded = $derived(protectionRulesState.rulesets !== undefined)
let canSetAnonymous = $derived(
!!$userStore?.is_admin ||
!!$userStore?.is_super_admin ||
!!actingUser?.is_admin ||
!!actingUser?.is_super_admin ||
(rulesetsLoaded &&
canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined))
)
let canSetGuest = $derived(
!!$userStore?.is_admin ||
!!$userStore?.is_super_admin ||
!!actingUser?.is_admin ||
!!actingUser?.is_super_admin ||
(rulesetsLoaded &&
canUserBypassRuleKind('RestrictGuestAppDeployment', $userStore ?? undefined))
)
@@ -144,7 +149,7 @@
setPublishState()
}
}
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer)
let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email)
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
@@ -597,7 +602,7 @@
{/if}
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
{#if !(actingUser?.is_admin || actingUser?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
@@ -618,7 +623,7 @@
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)}
/>
{#if customPath !== undefined}
@@ -626,7 +631,7 @@
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
disabled={!(actingUser?.is_admin || actingUser?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
@@ -48,7 +48,10 @@
import AssetRunsPanel from './AssetRunsPanel.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { fade } from 'svelte/transition'
import { userStore } from '$lib/stores'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
// Regular selection — loads the script by path for inline editing.
@@ -662,7 +665,7 @@
// hand-rolled here because we need *two* confirm buttons, not one.
let removeOpen = $state(false)
let removing = $state(false)
let canHardDelete = $derived(!!($userStore?.is_admin || $userStore?.is_super_admin))
let canHardDelete = $derived(!!(actingUser?.is_admin || actingUser?.is_super_admin))
// React to the parent's remove-signal counter and pop the same modal
// the in-pane trash button uses. Skipped for drafts (the parent calls
@@ -9,9 +9,12 @@
import { Bot, Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import Badge from '../badge/Badge.svelte'
import type { LinkedAgentDraft } from '$lib/components/flows/linkedAgentDrafts'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
open?: boolean
@@ -80,8 +83,8 @@
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
!actingUser?.is_admin &&
!actingUser?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
@@ -10,8 +10,7 @@
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
COPILOT_SESSION_REASONING_SETTING_NAME,
userStore
COPILOT_SESSION_REASONING_SETTING_NAME
} from '$lib/stores'
import { storeLocalSetting, type Item } from '$lib/utils'
import {
@@ -32,9 +31,14 @@
REASONING_OFF,
type ReasoningProviderModel
} from '../reasoningRegistry'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
/** Whether this dropdown carries the custom-prompt entries. Off where the surface
@@ -99,7 +103,7 @@
// operations must key off this snapshot, not the reactive `mode`.
let activeMode = $state(aiChatManager.mode)
let isAdmin = $derived(Boolean($userStore?.is_admin || $userStore?.is_super_admin))
let isAdmin = $derived(Boolean(actingUser?.is_admin || actingUser?.is_super_admin))
// True when the workspace has no AI providers of its own (it uses instance defaults).
// In that case the backend never makes workspace custom_prompts effective, so a saved
// workspace prompt would be dead config — mirror the settings page and surface it read-only.
@@ -24,6 +24,10 @@ writes whichever of the two changed, including the tab that is not on screen.
import { Building2, ExternalLink, User } from 'lucide-svelte'
import { untrack } from 'svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
ws,
@@ -55,7 +59,7 @@ writes whichever of the two changed, including the tab that is not on screen.
? `Applies to everyone in ${ws}.`
: 'Stored in this browser and sent in every workspace, so they follow you rather than the workspace.'
)
// `$userStore.is_admin` is the role in the nav workspace, not necessarily in `ws`, so
// `actingUser?.is_admin` is the role in the nav workspace, not necessarily in `ws`, so
// the resolved role is keyed to the workspace it was read for, and an unresolved one
// reads as no admin: offering the field and taking it away on resolve would discard
// whatever was typed in between. Superadmin holds everywhere.
@@ -69,8 +73,8 @@ writes whichever of the two changed, including the tab that is not on screen.
let roleUnknown = $derived(roleRead !== undefined && !roleRead.user && !navIsTarget)
let isAdmin = $derived(
Boolean(
$userStore?.is_super_admin ||
(roleForTarget ? roleForTarget.is_admin : navIsTarget && $userStore?.is_admin)
actingUser?.is_super_admin ||
(roleForTarget ? roleForTarget.is_admin : navIsTarget && actingUser?.is_admin)
)
)
@@ -40,10 +40,14 @@
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
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
@@ -407,7 +411,7 @@
if (
!sessionScopedManager &&
sessionOpen &&
prefersSessionHandoff($userStore?.operator)
prefersSessionHandoff(actingUser?.operator)
) {
void openSourceInSession(sessionOpen, {
previewParams: { selected: detail.moduleId },
@@ -7,7 +7,7 @@
import { sendUserToast } from '$lib/toast'
import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte'
import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts'
import { defaultScripts, enterpriseLicense, hubBaseUrlStore, userStore } from '$lib/stores'
import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores'
import type { SupportedLanguage } from '$lib/common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
@@ -28,9 +28,14 @@
canHaveApproval,
canHaveFailure
} from '$lib/script_helpers'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const dispatch = createEventDispatcher()
@@ -397,7 +402,7 @@
<div class="text-2xs font-normal text-secondary ml-2"
>New {selectedKind != 'script' ? selectedKind + ' ' : ''}script</div
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
{#if actingUser?.is_admin || actingUser?.is_super_admin}
{#if !openScriptSettings}
<Button
onClick={() => (openScriptSettings = true)}
@@ -31,9 +31,14 @@
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
noEditor: boolean
@@ -55,8 +60,8 @@
} = getContext<FlowEditorContext>('FlowEditorContext')
const WM_DEPLOYERS_GROUP = 'wm_deployers'
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
let customOnBehalfOfEmail: string = $state('')
let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
@@ -207,10 +207,13 @@
import type { Edge, Node } from '@xyflow/svelte'
import { getNodeColorClasses, NODE } from '../../util'
import { userStore } from '$lib/stores'
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'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
data: AssetN['data']
@@ -279,7 +282,7 @@
Could not find resource
{/snippet}
</Tooltip>
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !actingUser?.operator}
<div transition:slide={{ axis: 'x', duration: 100 }}>
<ExploreAssetButton
btnClasses="rounded-none"
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -59,31 +59,29 @@ function reachableComponents(): string[] {
// Module scripts have no component context to read the operating workspace from, so what
// they fetch takes its workspace explicitly; only instance code and markup are checked.
function readsNavigationStore(file: string): boolean {
const source = readFileSync(join(components, file), 'utf-8')
function instanceCode(file: string): string {
return readFileSync(join(components, file), 'utf-8')
.replace(/<script\b[^>]*\bmodule\b[^>]*>[\s\S]*?<\/script>/g, '')
.replace(/<!--[\s\S]*?-->|\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, '$1')
return /\$workspaceStore\b|\bget\(workspaceStore\)/.test(source)
}
describe('trigger editors', () => {
it('judge permissions by the acting user, not the navigation one', () => {
// `$userStore` describes the navigation workspace: a session tab editing a fork would
// enable or disable its controls by the parent's roles. `useOperatingUser()` answers for
// the workspace the editor acts on.
const editors = readdirSync(join(components, 'triggers'), {
recursive: true,
encoding: 'utf-8'
})
.filter((f) => f.endsWith('EditorInner.svelte'))
.map((f) => join('triggers', f))
expect(editors.length).toBeGreaterThan(10)
const offenders = editors.filter((f) =>
/\$userStore\b/.test(readFileSync(join(components, f), 'utf-8'))
)
expect(offenders).toEqual([])
})
})
function readsNavigationStore(file: string): boolean {
return /\$workspaceStore\b|\bget\(workspaceStore\)/.test(instanceCode(file))
}
// Roles are per workspace, so a permission judged from `$userStore` inside a fork's editor
// answers about the parent: it disables edits the user may make, or offers ones the backend
// then refuses. Identity (username, email) is the same person in either, and stays.
const PERMISSION_READ =
/canWrite\((?:[^()]|\([^()]*\))*?\$userStore|isOwner\((?:[^()]|\([^()]*\))*?\$userStore|\$userStore(?:\?|!)?\.(?:is_admin|is_super_admin|operator|folders|groups)\b/
// Components that judge by the navigation user on purpose, with why.
const NAVIGATION_JUDGES: Record<string, string> = {
'FolderPicker.svelte':
'resolves the target workspaces member itself, and asks `$userStore` only for the navigation one',
'FolderEditor.svelte':
'resolves the target workspaces member itself, and asks `$userStore` only for the navigation one'
}
describe('components under a session editor', () => {
it('read the operating workspace, not the navigation store', () => {
@@ -93,4 +91,13 @@ describe('components under a session editor', () => {
const offenders = reachable.filter((f) => !(f in NAVIGATION_READERS) && readsNavigationStore(f))
expect(offenders).toEqual([])
})
it('judge permissions by the user acting in that workspace', () => {
const reachable = reachableComponents()
expect(reachable).toContain('triggers/PermissionedAsLine.svelte')
const offenders = reachable.filter(
(f) => !(f in NAVIGATION_JUDGES) && PERMISSION_READ.test(instanceCode(f))
)
expect(offenders).toEqual([])
})
})
@@ -74,9 +74,14 @@
import type { RawAppData } from './dataTableRefUtils'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
// async function hash(message) {
// try {
@@ -445,7 +450,7 @@
// custom_path requires admin so to accept update without it, we need to send as undefined when non-admin (when undefined, it will be ignored)
// it also means that customPath needs to be set to '' instead of undefined to unset it (when admin)
custom_path:
$userStore?.is_admin || $userStore?.is_super_admin ? (customPath ?? '') : undefined,
actingUser?.is_admin || actingUser?.is_super_admin ? (customPath ?? '') : undefined,
labels
},
js,
@@ -29,9 +29,14 @@
import { userStore } from '$lib/stores'
import { isHubFlowPath } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let opWs = $derived($operatingWorkspace)
type RunnableWithInlineScript = RunnableWithFields & {
@@ -133,7 +138,7 @@
case 'email':
return $userStore?.email ?? ''
case 'groups':
return $userStore?.groups ?? []
return actingUser?.groups ?? []
case 'workspace':
return opWs ?? ''
case 'author':
@@ -14,9 +14,14 @@
import type { InputType } from '../apps/inputType'
import Select from '$lib/components/select/Select.svelte'
import { userStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let opWs = $derived($operatingWorkspace)
// Build ctx properties with current user's actual values
@@ -30,7 +35,7 @@
{
value: 'groups',
label: 'Groups',
subtitle: `string[] — ${JSON.stringify($userStore?.groups ?? [])}`
subtitle: `string[] — ${JSON.stringify(actingUser?.groups ?? [])}`
},
{
value: 'workspace',
@@ -31,9 +31,14 @@
import { flowPathToHref } from '$lib/scripts'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
job: Job
@@ -499,7 +504,7 @@
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1">
<JobStatus {job} />
{#if isJobResolvable(job) && !$userStore?.operator}
{#if isJobResolvable(job) && !actingUser?.operator}
<!-- 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
@@ -41,9 +41,12 @@
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo } from '$lib/aiStore'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { openSourceInSession } from './sessionSwitch.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
source,
@@ -83,7 +86,7 @@
const show = $derived(
!inSessionPanel &&
!!(source?.target || source?.page) &&
prefersSessionHandoff($userStore?.operator)
prefersSessionHandoff(actingUser?.operator)
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
@@ -6,7 +6,10 @@
import Label from '../Label.svelte'
import AIPromptsModal from './AIPromptsModal.svelte'
import { ExternalLink, Settings } from 'lucide-svelte'
import { userStore } from '$lib/stores'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts'
@@ -49,7 +52,7 @@
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
Customize AI behavior with system prompts. These are stored locally in your browser and
apply in addition to {#if $userStore?.is_admin || $userStore?.is_super_admin}<a
apply in addition to {#if actingUser?.is_admin || actingUser?.is_super_admin}<a
href="/workspace_settings?tab=ai"
>workspace-level prompts <ExternalLink size={12} class="inline-block" /></a
>{:else}workspace-level prompts{/if}.
@@ -6,7 +6,10 @@
import { useFolderDefaultPermissionedAs } from '$lib/components/useFolderDefaultPermissionedAs.svelte'
import { userStore } from '$lib/stores'
import { AlertTriangle } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
interface Props {
/** Current permissioned_as value from the trigger (e.g., 'u/admin') */
@@ -23,10 +26,12 @@
let { permissionedAs, onPermissionedAsChange, path = undefined }: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
const canPreserve = $derived(
$userStore?.is_admin || ($userStore?.groups ?? []).includes('wm_deployers')
actingUser?.is_admin || (actingUser?.groups ?? []).includes('wm_deployers')
)
const myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
@@ -2,7 +2,6 @@
import { Alert } from '$lib/components/common'
import Required from '$lib/components/Required.svelte'
import Section from '$lib/components/Section.svelte'
import { userStore } from '$lib/stores'
// import { page } from '$app/state'
import { getEmailAddress, getEmailDomain } from './utils'
import { isCloudHosted } from '$lib/cloud'
@@ -11,7 +10,10 @@
import { untrack } from 'svelte'
import { EmailTriggerService } from '$lib/gen'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
interface Props {
initialTriggerPath?: string | undefined
dirtyLocalPart?: boolean
@@ -36,6 +38,8 @@
showTestingBadge = false
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
let validateTimeout: number | undefined = undefined
@@ -95,7 +99,7 @@
local_part === undefined && (local_part = '')
})
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userIsAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin)
let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved
</script>
@@ -1,11 +1,15 @@
<script lang="ts">
import EmailTriggerEditorInner from './EmailTriggerEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { enterpriseLicense } from '$lib/stores'
import { Alert } from '$lib/components/common'
import { onMount, type Snippet } from 'svelte'
import { getEmailDomain } from './utils'
import type { Trigger } from '../utils'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let emailTriggerEditor = $state<EmailTriggerEditorInner | null>(null)
@@ -73,7 +77,7 @@
trigger can be configured with a specific local part.
</Description>
{#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft}
{#if !actingUser?.is_admin && !actingUser?.is_super_admin && selectedTrigger.isDraft}
<Alert title="Only workspace admins can create email triggers" type="info" size="xs" />
{/if}
@@ -15,7 +15,6 @@
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { base } from '$lib/base'
import Toggle from '$lib/components/Toggle.svelte'
import { userStore } from '$lib/stores'
import { Button, Url } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
@@ -24,11 +23,16 @@
import TestingBadge from '../testingBadge.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
// Declared before `DEFAULT_PUSH_CONFIG` / the `base_endpoint` prop default,
// which call `getBaseUrl()` (a `wsId` reader) during component init.
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
let topic_items: string[] = $state([])
@@ -152,14 +156,14 @@
// Keyed on the loaded mode, not the live one: reading the live mode would make the toggle a
// one-way door, disabling itself the moment a non-admin switched an inherited ADC trigger away.
const canUseDefaultCredentials = $derived(
$userStore?.is_admin === true || loaded_uses_default_credentials
actingUser?.is_admin === true || loaded_uses_default_credentials
)
const hasCredentials = $derived(usesDefaultCredentials || !emptyStringTrimmed(gcp_resource_path))
/** Saving re-provisions the subscription with the instance's credentials, so the backend runs
* the admin check on every write, not only when the mode is switched. A non-admin who inherits
* such a trigger can open it, so say why saving is unavailable instead of letting them hit a
* bare 403. */
const blockedByAdminGate = $derived(usesDefaultCredentials && $userStore?.is_admin !== true)
const blockedByAdminGate = $derived(usesDefaultCredentials && actingUser?.is_admin !== true)
// One-shot on mount, so read the props rather than the derived: referencing `$derived` state
// here captures its initial value anyway, and Svelte warns about it.
@@ -4,7 +4,6 @@
import Section from '$lib/components/Section.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import { userStore } from '$lib/stores'
import { HttpTriggerService, SettingService } from '$lib/gen'
// import { page } from '$app/state'
import { getHttpRoute } from './utils'
@@ -13,7 +12,10 @@
import TestingBadge from '../testingBadge.svelte'
import { untrack } from 'svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
interface Props {
initialTriggerPath?: string | undefined
@@ -43,6 +45,8 @@
showTestingBadge = false
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const wsId = $derived($operatingWorkspace)
let validateTimeout: number | undefined = undefined
@@ -107,7 +111,7 @@
route_path === undefined && (route_path = '')
})
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userIsAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin)
let globalHttpWorkspacedRoute = $state(false)
@@ -1,9 +1,12 @@
<script lang="ts">
import RouteEditorInner from './RouteEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { userStore } from '$lib/stores'
import { Alert } from '$lib/components/common'
import { onMount } from 'svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let routeEditor = $state<RouteEditorInner | null>(null)
let {
@@ -48,7 +51,7 @@
specific HTTP method and path.</Description
>
{#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft}
{#if !actingUser?.is_admin && !actingUser?.is_super_admin && selectedTrigger.isDraft}
<Alert title="Non-admin users are limited to workspaced routes" type="info" size="xs" />
{/if}
</div>
@@ -8,7 +8,7 @@
getTemplatePath,
saveNativeTriggerFromCfg
} from './utils'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import { Button } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
@@ -27,9 +27,14 @@
import { deepEqual } from 'fast-equals'
import type { Snippet } from 'svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
service: NativeServiceName
@@ -215,7 +220,7 @@
serviceConfig = (fullTrigger.service_config as Record<string, any>) || {}
scriptPath = fullTrigger.script_path
initialScriptPath = fullTrigger.script_path
can_write = canWrite(fullTrigger.script_path, {}, $userStore)
can_write = canWrite(fullTrigger.script_path, {}, actingUser)
summary = fullTrigger.summary ?? ''
externalData = fullTrigger.external_data
externalError = fullTrigger.external_error ?? undefined
@@ -518,7 +523,7 @@
bind:itemKind
kinds={['script']}
allowFlow={true}
allowEdit={!$userStore?.operator}
allowEdit={!actingUser?.operator}
clearable
/>
{#if emptyString(scriptPath)}
@@ -1,15 +1,19 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { userStore } from '$lib/stores'
import { AppService, type ListableApp } from '$lib/gen'
import { canWrite } from '$lib/utils'
import type { AppViewerContext } from '../apps/types'
import Alert from '../common/alert/Alert.svelte'
import Select from '../select/Select.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
interface Props {
value?: string
@@ -28,9 +32,9 @@
).map((app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
canWrite(app.path!, app.extra_perms!, actingUser) &&
app.workspace_id == $operatingWorkspace &&
!$userStore?.operator,
!actingUser?.operator,
...app
}
})
@@ -26,7 +26,11 @@
import Portal from '$lib/components/Portal.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
import { superadmin, userStore } from '$lib/stores'
import { superadmin } from '$lib/stores'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let {
workspace,
@@ -56,7 +60,7 @@
let generatingInitial = $state(false)
// Only workspace admins and super admins can opt a data table in or out.
const canManage = $derived(!!$userStore?.is_admin || !!$superadmin)
const canManage = $derived(!!actingUser?.is_admin || !!$superadmin)
let newMigrationModal = $state<NewDataTableMigrationModal | undefined>(undefined)
let newMigrationOpen = $state(false)