mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
* feat: edit variables, resources and triggers in their own session tab Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take a trigger's flow kind from its config and name the written trigger Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: one operating-workspace context for editors acting on a session's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reload page item tabs only in sessions acting on the tool's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve open in workspace for every preview tab kind in one place Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: guard every component under a session editor against the navigation workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: show a page item tab whose item is gone instead of mounting its editor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow a saved page item in the tab's own workspace and restore in-frame rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: close a page item tab from its editor header Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge trigger permissions by the user acting in the session workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge every session-editor permission by the user acting in that workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep session permissions reactive to the acting role and restore tabs as last seen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: default Path's owner controls to the user acting in its workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
466 lines
15 KiB
Svelte
466 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount, untrack } from 'svelte'
|
|
import { userWorkspaces, usersWorkspaceStore, type UserWorkspace } from '$lib/stores'
|
|
import { Alert, Button, Skeleton } from '../common'
|
|
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
|
import Toggle from '../Toggle.svelte'
|
|
import { SettingService, UserService, type NewToken } from '$lib/gen'
|
|
import { mcpTokenUrlDisabled } from '$lib/mcpAuth'
|
|
import TokenDisplay from './TokenDisplay.svelte'
|
|
import ScopesPicker from './ScopesPicker.svelte'
|
|
import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte'
|
|
import { shell } from 'svelte-highlight/languages'
|
|
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
|
|
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import Select from '../select/Select.svelte'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
interface Props {
|
|
showMcpMode?: boolean
|
|
openWithMcpMode?: boolean
|
|
mcpOnly?: boolean
|
|
lockWorkspace?: boolean
|
|
title?: string
|
|
newTokenLabel?: string
|
|
defaultNewTokenWorkspace?: string
|
|
scopes?: string[]
|
|
onTokenCreated: (token: string) => void
|
|
displayCreateToken?: boolean
|
|
}
|
|
|
|
let {
|
|
showMcpMode = false,
|
|
openWithMcpMode = false,
|
|
mcpOnly = false,
|
|
lockWorkspace = false,
|
|
title = 'Add a new token',
|
|
defaultNewTokenWorkspace,
|
|
scopes,
|
|
onTokenCreated,
|
|
newTokenLabel = $bindable(undefined),
|
|
displayCreateToken = true
|
|
}: Props = $props()
|
|
|
|
// Sentinel workspace value meaning "all workspaces the user can access".
|
|
// Produces a workspace-less MCP token served through the /api/mcp/gateway
|
|
// endpoint, where tools take an explicit workspace_id argument.
|
|
const ALL_WORKSPACES = '*'
|
|
|
|
let newToken = $state<string | undefined>(undefined)
|
|
let newMcpToken = $state<string | undefined>(undefined)
|
|
// What the user picked; `newTokenExpiration` is that pick held within the ceiling.
|
|
let pickedExpiration = $state<number | undefined>(undefined)
|
|
let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace))
|
|
let mcpCreationMode = $state(false)
|
|
let lastRequestedMcpMode = $state<boolean | undefined>(undefined)
|
|
let mcpLabelAutofilled = $state(false)
|
|
|
|
let pickedScopes = $state<string[] | null>(null)
|
|
let readOnly = $state(false)
|
|
// How this instance lets an MCP client in. `oauth` means it refuses `?token=`, so a
|
|
// generated token would not get a client in and the URL is handed over bare instead.
|
|
// A failed read lands on `oauth`: the bare URL works whichever way the setting is,
|
|
// whereas guessing `token` mints a non-expiring credential the server may refuse.
|
|
type McpUrlPolicy = 'loading' | 'token' | 'oauth'
|
|
let mcpUrlPolicy = $state<McpUrlPolicy>('loading')
|
|
|
|
async function loadMcpUrlPolicy() {
|
|
mcpUrlPolicy = 'loading'
|
|
try {
|
|
mcpUrlPolicy = (await mcpTokenUrlDisabled()) ? 'oauth' : 'token'
|
|
} catch (err) {
|
|
console.error('Failed to load the MCP token setting:', err)
|
|
mcpUrlPolicy = 'oauth'
|
|
}
|
|
}
|
|
|
|
const DAY_SECS = 24 * 60 * 60
|
|
const EXPIRATION_CHOICES = [
|
|
{ label: '15 minutes', value: 15 * 60 },
|
|
{ label: '30 minutes', value: 30 * 60 },
|
|
{ label: '1 hour', value: 60 * 60 },
|
|
{ label: '1 day', value: DAY_SECS },
|
|
{ label: '7 days', value: 7 * DAY_SECS },
|
|
{ label: '30 days', value: 30 * DAY_SECS },
|
|
{ label: '90 days', value: 90 * DAY_SECS },
|
|
{ label: '180 days', value: 180 * DAY_SECS },
|
|
{ label: '365 days', value: 365 * DAY_SECS }
|
|
]
|
|
|
|
// The `max_token_expiration_days` instance setting. The server shortens any token that asks
|
|
// for longer, or for no expiration, so with it set the form only offers what would be kept.
|
|
let maxExpirationDays = $state<number | undefined>(undefined)
|
|
|
|
onMount(async () => {
|
|
let value: unknown
|
|
try {
|
|
value = await SettingService.getGlobal({ key: 'max_token_expiration_days' })
|
|
} catch {
|
|
// The server still shortens tokens itself; the form just offers every choice.
|
|
return
|
|
}
|
|
const days = parseMaxTokenExpirationDays(value)
|
|
if (days == undefined) {
|
|
return
|
|
}
|
|
maxExpirationDays = days
|
|
})
|
|
|
|
// Without a ceiling the expiration field is hidden in MCP mode, so a value picked on one side
|
|
// of the toggle must not ride along unseen. With one, the field shows in both modes and keeps
|
|
// its value.
|
|
function resetExpirationOnModeChange() {
|
|
if (maxExpirationSecs == undefined) {
|
|
pickedExpiration = undefined
|
|
}
|
|
}
|
|
|
|
function ensureCurrentWorkspaceIncluded(
|
|
workspacesList: UserWorkspace[],
|
|
currentWorkspace: string | undefined
|
|
) {
|
|
if (!currentWorkspace) {
|
|
return workspacesList
|
|
}
|
|
const hasCurrentWorkspace = workspacesList.some((w) => w.id === currentWorkspace)
|
|
if (hasCurrentWorkspace) {
|
|
return workspacesList
|
|
}
|
|
return [{ id: currentWorkspace, name: currentWorkspace }, ...workspacesList]
|
|
}
|
|
|
|
function enterMcpMode() {
|
|
mcpCreationMode = true
|
|
void loadMcpUrlPolicy()
|
|
resetExpirationOnModeChange()
|
|
newTokenWorkspace = defaultNewTokenWorkspace ?? $operatingWorkspace
|
|
newToken = undefined
|
|
newMcpToken = undefined
|
|
readOnly = false
|
|
if (!newTokenLabel) {
|
|
newTokenLabel = 'MCP token'
|
|
mcpLabelAutofilled = true
|
|
} else {
|
|
mcpLabelAutofilled = false
|
|
}
|
|
}
|
|
|
|
function exitMcpMode() {
|
|
mcpCreationMode = false
|
|
resetExpirationOnModeChange()
|
|
newTokenWorkspace = defaultNewTokenWorkspace
|
|
newMcpToken = undefined
|
|
readOnly = false
|
|
if (mcpLabelAutofilled) {
|
|
newTokenLabel = undefined
|
|
}
|
|
mcpLabelAutofilled = false
|
|
}
|
|
|
|
async function createToken(mcpMode: boolean = false): Promise<void> {
|
|
try {
|
|
let date: Date | undefined
|
|
if (newTokenExpiration) {
|
|
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
|
|
}
|
|
|
|
const tokenScopes = scopes ?? pickedScopes ?? undefined
|
|
|
|
const createdToken = await UserService.createToken({
|
|
requestBody: {
|
|
label: newTokenLabel,
|
|
expiration: date?.toISOString(),
|
|
scopes: tokenScopes,
|
|
workspace_id: tokenWorkspaceId,
|
|
read_only: readOnly
|
|
} as NewToken
|
|
})
|
|
|
|
if (mcpMode) {
|
|
newToken = undefined
|
|
newMcpToken = `${createdToken}`
|
|
} else {
|
|
newMcpToken = undefined
|
|
newToken = `${createdToken}`
|
|
}
|
|
|
|
onTokenCreated(`${createdToken}`)
|
|
if (!mcpOnly) {
|
|
mcpCreationMode = false
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create token:', err)
|
|
}
|
|
}
|
|
|
|
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $operatingWorkspace))
|
|
const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES)
|
|
const tokenWorkspaceId = $derived(
|
|
isAllWorkspaces
|
|
? undefined
|
|
: mcpCreationMode
|
|
? newTokenWorkspace || $operatingWorkspace
|
|
: newTokenWorkspace
|
|
)
|
|
|
|
// Mirrors the server's exemption for tokens owned by a service account: one in the workspace
|
|
// the token is for, or in any workspace for a workspace-less token.
|
|
const isServiceAccount = $derived.by(() => {
|
|
const memberships = $usersWorkspaceStore?.workspaces ?? []
|
|
return tokenWorkspaceId == undefined
|
|
? memberships.some((w) => w.is_service_account)
|
|
: memberships.some((w) => w.id === tokenWorkspaceId && w.is_service_account)
|
|
})
|
|
const maxExpirationSecs = $derived(
|
|
maxExpirationDays == undefined || isServiceAccount ? undefined : maxExpirationDays * DAY_SECS
|
|
)
|
|
const maxExpirationLabel = $derived(
|
|
maxExpirationDays === 1 ? '1 day' : `${maxExpirationDays} days`
|
|
)
|
|
const expirationItems = $derived(
|
|
maxExpirationSecs == undefined
|
|
? [{ label: 'No expiration', value: undefined }, ...EXPIRATION_CHOICES]
|
|
: [
|
|
...EXPIRATION_CHOICES.filter((choice) => choice.value < maxExpirationSecs),
|
|
{ label: `${maxExpirationLabel} (maximum)`, value: maxExpirationSecs }
|
|
]
|
|
)
|
|
// A ceiling can start applying after the pick: once the setting loads, or when the MCP workspace
|
|
// moves to one where the account is not a service account.
|
|
const newTokenExpiration = $derived(
|
|
maxExpirationSecs != undefined &&
|
|
(pickedExpiration == undefined || pickedExpiration > maxExpirationSecs)
|
|
? maxExpirationSecs
|
|
: pickedExpiration
|
|
)
|
|
// The workspace used to browse scripts/flows/endpoints in the scope picker.
|
|
// For an all-workspaces token there is no single workspace, so fall back to
|
|
// the current one just for populating the endpoint list.
|
|
const scopeWorkspaceId = $derived(
|
|
isAllWorkspaces ? $operatingWorkspace || '' : newTokenWorkspace || $operatingWorkspace || ''
|
|
)
|
|
// Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL
|
|
// and is copyable, so the OAuth panel withholds it rather than showing a broken one. The
|
|
// token branch guards the same case by disabling its generate button.
|
|
const mcpUrl = $derived(
|
|
isAllWorkspaces
|
|
? `${window.location.origin}/api/mcp/gateway`
|
|
: newTokenWorkspace
|
|
? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp`
|
|
: undefined
|
|
)
|
|
const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`)
|
|
|
|
$effect(() => {
|
|
const requestedMcpMode = mcpOnly || openWithMcpMode
|
|
if (requestedMcpMode === lastRequestedMcpMode) {
|
|
return
|
|
}
|
|
|
|
if (requestedMcpMode) {
|
|
enterMcpMode()
|
|
} else {
|
|
exitMcpMode()
|
|
}
|
|
|
|
lastRequestedMcpMode = requestedMcpMode
|
|
})
|
|
|
|
$effect(() => {
|
|
if (mcpLabelAutofilled && newTokenLabel !== 'MCP token') {
|
|
mcpLabelAutofilled = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div>
|
|
<!-- Stays bounded by the panel width: a content-driven width (min-w-min) would let a long
|
|
scope chip stretch this card and push the rest of the form out of view. -->
|
|
<div class="p-4 rounded-md mb-6 bg-surface-tertiary">
|
|
<h3 class="pb-2 font-semibold text-emphasis text-sm">
|
|
{mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title}
|
|
</h3>
|
|
|
|
{#if showMcpMode && !mcpOnly}
|
|
<div
|
|
class="mb-4 flex flex-row flex-shrink-0"
|
|
use:triggerableByAI={{
|
|
id: 'account-settings-create-mcp-token',
|
|
description: 'Create a new MCP token to authenticate to the Windmill API'
|
|
}}
|
|
>
|
|
<Toggle
|
|
on:change={(e) => {
|
|
if (e.detail) {
|
|
enterMcpMode()
|
|
} else {
|
|
exitMcpMode()
|
|
}
|
|
}}
|
|
checked={mcpCreationMode}
|
|
options={{
|
|
right: 'Generate MCP URL',
|
|
rightTooltip:
|
|
'Generate a new MCP URL to make your scripts, flows, and API endpoints available as tools through your LLM clients.',
|
|
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
|
|
}}
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if mcpCreationMode && mcpUrlPolicy === 'loading'}
|
|
<Skeleton layout={[[2], 0.5, [1]]} />
|
|
{:else if mcpCreationMode && mcpUrlPolicy === 'oauth'}
|
|
{#if !lockWorkspace}
|
|
<div class="mb-4 max-w-md">
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
|
<!-- No all-workspaces entry: the gateway's consent screen binds the token it issues
|
|
to the one workspace picked there, so OAuth has no multi-workspace grant to offer. -->
|
|
<Select
|
|
bind:value={newTokenWorkspace}
|
|
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if mcpUrl}
|
|
<CopyableCodeBlock code={mcpUrl} language={shell} wrap />
|
|
|
|
<div class="mt-2">
|
|
<Alert type="info" title="This instance requires MCP clients to sign in" size="xs">
|
|
Paste this URL into your client. It opens a Windmill page where you approve the access
|
|
it asks for, and no token needs to be copied around.
|
|
</Alert>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-tertiary">Pick a workspace to get its MCP URL.</p>
|
|
{/if}
|
|
|
|
{#if !mcpOnly}
|
|
<div class="mt-4 flex justify-end gap-2 flex-row">
|
|
<Button onClick={exitMcpMode} variant="default">Cancel</Button>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
{#if scopes != undefined}
|
|
<div class="mb-4">
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
|
{#each scopes as scope (scope)}
|
|
<TextInput inputProps={{ disabled: true }} value={scope} class="mb-2 w-full" />
|
|
{/each}
|
|
<div class="text-tertiary">
|
|
<Toggle
|
|
bind:checked={readOnly}
|
|
options={{
|
|
right: 'Read-only',
|
|
rightTooltip:
|
|
'Restricts this token to GET/HEAD endpoints. Any mutating request (POST/PUT/PATCH/DELETE) or job-run action will be rejected with 403, regardless of the scopes listed above.'
|
|
}}
|
|
size="2xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !scopes || scopes.length === 0}
|
|
<ScopesPicker
|
|
mode={mcpCreationMode ? 'mcp' : 'standard'}
|
|
workspaceId={scopeWorkspaceId}
|
|
bind:value={pickedScopes}
|
|
bind:readOnly
|
|
/>
|
|
{/if}
|
|
|
|
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{#if mcpCreationMode}
|
|
{#if !lockWorkspace}
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
|
<Select
|
|
bind:value={newTokenWorkspace}
|
|
items={[
|
|
{
|
|
label: 'All workspaces',
|
|
value: ALL_WORKSPACES,
|
|
subtitle: 'Multi-workspace'
|
|
},
|
|
...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))
|
|
]}
|
|
/>
|
|
{#if isAllWorkspaces}
|
|
<p class="mt-1 text-xs text-tertiary">
|
|
This token works across every workspace you can access. Tools take a
|
|
<code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !mcpOnly}
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
|
>Label <span class="text-xs text-primary">(optional)</span></span
|
|
>
|
|
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !mcpCreationMode || maxExpirationSecs != undefined}
|
|
<div>
|
|
<span class="block mb-1 text-xs text-emphasis font-semibold">
|
|
Expires In
|
|
{#if maxExpirationSecs == undefined}
|
|
<span class="text-xs text-primary">(optional)</span>
|
|
{/if}
|
|
</span>
|
|
<Select
|
|
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
|
placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'}
|
|
inputClass="w-full"
|
|
items={expirationItems}
|
|
/>
|
|
{#if maxExpirationSecs != undefined}
|
|
<p class="mt-1 text-xs text-tertiary">
|
|
This instance limits tokens to {maxExpirationLabel}.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4 flex justify-end gap-2 flex-row">
|
|
{#if !mcpOnly}
|
|
<Button
|
|
on:click={() => {
|
|
exitMcpMode()
|
|
}}
|
|
variant="default"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
on:click={() => createToken(mcpCreationMode)}
|
|
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
|
|
variant="accent"
|
|
>
|
|
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if newToken && displayCreateToken}
|
|
<TokenDisplay token={newToken} />
|
|
{/if}
|
|
|
|
{#if newMcpToken && displayCreateToken}
|
|
<TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} />
|
|
{/if}
|
|
</div>
|