diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte index 5ed0799df3..b427ad6d3e 100644 --- a/frontend/src/lib/components/flows/content/AgentSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -10,7 +10,6 @@ import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte' import AgentSlackSettings from './AgentSlackSettings.svelte' import { WorkspaceService } from '$lib/gen' - import { userStore } from '$lib/stores' import { useActingUser } from '$lib/actingUser.svelte' import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte' @@ -36,8 +35,8 @@ let nextRunAs = $derived( pickedRunAs?.preserve && pickedRunAs.permissionedAs ? pickedRunAs.permissionedAs - : $userStore?.username - ? `u/${$userStore.username}` + : acting.current?.username + ? `u/${acting.current.username}` : undefined ) // Only a workspace that deploys somewhere has anything to keep an agent out of. diff --git a/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte index 5e1e4f020d..0b4ebe01c7 100644 --- a/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte @@ -9,7 +9,7 @@ import TextInput from '$lib/components/text_input/TextInput.svelte' import Toggle from '$lib/components/Toggle.svelte' import { SlackAgentsService, type SlackAgentChannel } from '$lib/gen' - import { userStore } from '$lib/stores' + import { useActingUser } from '$lib/actingUser.svelte' import { sendUserToast } from '$lib/toast' import type { AgentDraftHandle } from '../agentDraft.svelte' @@ -26,16 +26,18 @@ let { draft, path, workspace, nextRunAs }: Props = $props() + const acting = useActingUser(() => workspace) + let readOnly = $derived(!draft.canWrite) let obo = $derived(!!draft.state?.args.run_on_behalf_of) let runsAs = $derived( readOnly ? (draft.onBehalfOf ?? 'its run-as user') - : !nextRunAs || nextRunAs === `u/${$userStore?.username}` + : !nextRunAs || nextRunAs === `u/${acting.current?.username}` ? 'you' : nextRunAs ) - let isAdmin = $derived(!!$userStore?.is_admin) + let isAdmin = $derived(!!acting.current?.is_admin) const slack = resource( () => ({ ws: workspace, path, deployed: !draft.noDeployed }), diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte index ad7d0badb2..1c3f34aca6 100644 --- a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -9,8 +9,10 @@ import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' import { Plus, RefreshCw, Unplug } from 'lucide-svelte' - import { userStore } from '$lib/stores' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' import { connectNativeAccount, getServiceConfig } from './utils' interface Props { @@ -45,6 +47,7 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service) const confirmationModal = createAsyncConfirmationModal() @@ -95,7 +98,7 @@ } async function reconnect(c: NativeTriggerConnection) { - if (c.owner && c.owner !== $userStore?.username) { + if (c.owner && c.owner !== operatingUser.current?.username) { const confirmed = await confirmationModal.ask({ title: `Reconnect ${c.path} with your account?`, confirmationText: 'Reconnect', @@ -130,13 +133,16 @@ // Members cannot write the workspace connection; the server refuses anyone else it does not own. function canDisconnect(c: NativeTriggerConnection): boolean { - if ($userStore?.is_admin) return true - return !c.workspace && !!c.owner && c.owner === $userStore?.username + if (operatingUser.current?.is_admin) return true + return !c.workspace && !!c.owner && c.owner === operatingUser.current?.username } // The server lets only the connection's owner or an admin replace it. function canReconnect(c: NativeTriggerConnection): boolean { - return !!$userStore?.is_admin || (!!c.owner && c.owner === $userStore?.username) + return ( + !!operatingUser.current?.is_admin || + (!!c.owner && c.owner === operatingUser.current?.username) + ) } const items = $derived.by(() => { diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts index 2a9bea2973..d146f74bc1 100644 --- a/frontend/src/lib/rawAppDeploy.ts +++ b/frontend/src/lib/rawAppDeploy.ts @@ -10,10 +10,9 @@ * files, then createAppRaw/updateAppRaw. The source→AppDraftValue projection is * shared via `rawAppDraftValue` so the two deploy paths can't drift. */ -import { get } from 'svelte/store' import { AppService } from '$lib/gen' import type { Policy } from '$lib/gen' -import { userStore } from '$lib/stores' +import { isAdminIn } from '$lib/user' import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge' import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' @@ -63,7 +62,7 @@ export async function deployRawAppValue({ const bundle = await bundleRawAppDraft({ workspace, files }) - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await AppService.updateAppRaw({ workspace, path, @@ -131,7 +130,7 @@ export async function deployRawAppDraft( // non-admin deploying a draft for an app that has a custom route would hit // RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue // fallback even when unchanged). - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await AppService.updateAppRaw({ workspace, path, diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 12d6f237ba..1aa4a10267 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -39,6 +39,12 @@ export function getWorkspaceRole(workspace: string): Promise { return lookup } +/** Admin of `workspace`, which may not be the navigated one; an unknown user is not. */ +export async function isAdminIn(workspace: string): Promise { + const role = await getWorkspaceRole(workspace) + return role.kind === 'resolved' && !!(role.user.is_admin || role.user.is_super_admin) +} + /** Roles are per-identity, so anything that changes who is logged in must drop this. */ export function clearWorkspaceRoleCache(): void { workspaceRoleCache.clear() diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index c4f2b3749e..4ffbfc2508 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -12,7 +12,7 @@ * draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`); * a draft on an already-deployed item just deletes the draft row. */ -import { get, writable } from 'svelte/store' +import { writable } from 'svelte/store' import { DraftService, ScriptService, @@ -43,7 +43,7 @@ import { classicAppDraftParts } from '$lib/appDiffSides' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' -import { userStore } from '$lib/stores' +import { isAdminIn } from '$lib/user' import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' @@ -449,7 +449,7 @@ async function deployDraftTriggers( ): Promise { const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig) if (triggers.length === 0) return - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await deployTriggers(triggers, workspace, isAdmin, writable([]), path, isNew) } @@ -586,7 +586,7 @@ export async function deployDraft( // (or a default). custom_path requires admin on update; non-admins send // undefined so the backend preserves the existing route. The draft has no // custom_path, so admins fall back to the deployed route (`''` when none). - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true } const requestBody = { value: appValue, @@ -720,7 +720,7 @@ export async function deployDraft( } else if (kind in TRIGGER_SAVERS) { const getter = OVERLAY_GETTERS[kind]! const { draft: d } = splitOverlay(await getter(workspace, path)) - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) const ok = await TRIGGER_SAVERS[kind]!( path, { ...d, path: d.path ?? path },