Files
windmill/frontend/src/lib/components/settings/CreateToken.svelte
T
Ruben FiszelandClaude Opus 5 ae2f584de8 fix: keep the token scope builder inside its panel when scopes get long (#10517)
* fix: keep the token scope builder inside its panel when scopes get long

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: label the scope path popover 'Add path' once paths exist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:13:36 +02:00

326 lines
9.1 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte'
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import { Button } from '../common'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import Toggle from '../Toggle.svelte'
import { UserService, type NewToken } from '$lib/gen'
import TokenDisplay from './TokenDisplay.svelte'
import ScopesPicker from './ScopesPicker.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
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)
let newTokenExpiration = $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)
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
newTokenExpiration = undefined
newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore
newToken = undefined
newMcpToken = undefined
readOnly = false
if (!newTokenLabel) {
newTokenLabel = 'MCP token'
mcpLabelAutofilled = true
} else {
mcpLabelAutofilled = false
}
}
function exitMcpMode() {
mcpCreationMode = false
newTokenExpiration = undefined
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 workspaceId = isAllWorkspaces
? undefined
: mcpMode
? newTokenWorkspace || $workspaceStore
: newTokenWorkspace
const createdToken = await UserService.createToken({
requestBody: {
label: newTokenLabel,
expiration: date?.toISOString(),
scopes: tokenScopes,
workspace_id: workspaceId,
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, $workspaceStore))
const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES)
// 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 ? $workspaceStore || '' : newTokenWorkspace || $workspaceStore || ''
)
const mcpBaseUrl = $derived(
isAllWorkspaces
? `${window.location.origin}/api/mcp/gateway?token=`
: `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?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">{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 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}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 }
]}
/>
</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>
</div>
{#if newToken && displayCreateToken}
<TokenDisplay token={newToken} />
{/if}
{#if newMcpToken && displayCreateToken}
<TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} />
{/if}
</div>