diff --git a/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json b/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json similarity index 75% rename from backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json rename to backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json index ae055e1b5f..fd205d6750 100644 --- a/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json +++ b/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "scopes", "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -47,8 +52,9 @@ true, false, false, + true, true ] }, - "hash": "1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb" + "hash": "2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d" } diff --git a/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json b/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json similarity index 72% rename from backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json rename to backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json index 657c660228..a977f306ab 100644 --- a/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json +++ b/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "scopes", "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" } ], "parameters": { @@ -47,8 +52,9 @@ true, false, false, + true, true ] }, - "hash": "ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633" + "hash": "40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8" } diff --git a/backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json b/backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json new file mode 100644 index 0000000000..d507609d8e --- /dev/null +++ b/backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n RETURNING token_prefix", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_prefix", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726" +} diff --git a/backend/migrations/20260428122300_update_token_scopes_invalidation.down.sql b/backend/migrations/20260428122300_update_token_scopes_invalidation.down.sql new file mode 100644 index 0000000000..4dce7423b6 --- /dev/null +++ b/backend/migrations/20260428122300_update_token_scopes_invalidation.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS token_scopes_update_trigger ON token; +DROP FUNCTION IF EXISTS notify_token_scopes_change(); diff --git a/backend/migrations/20260428122300_update_token_scopes_invalidation.up.sql b/backend/migrations/20260428122300_update_token_scopes_invalidation.up.sql new file mode 100644 index 0000000000..c06bf16a09 --- /dev/null +++ b/backend/migrations/20260428122300_update_token_scopes_invalidation.up.sql @@ -0,0 +1,18 @@ +-- Invalidate auth cache (across instances) when token scopes change. +-- Reuses the existing notify_token_invalidation channel handled in main.rs. + +CREATE OR REPLACE FUNCTION notify_token_scopes_change() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.scopes IS DISTINCT FROM NEW.scopes THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', NEW.token_prefix); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER token_scopes_update_trigger +AFTER UPDATE OF scopes ON token +FOR EACH ROW +EXECUTE FUNCTION notify_token_scopes_change(); diff --git a/backend/windmill-api-integration-tests/tests/users.rs b/backend/windmill-api-integration-tests/tests/users.rs index 61e174a002..db711c24e0 100644 --- a/backend/windmill-api-integration-tests/tests/users.rs +++ b/backend/windmill-api-integration-tests/tests/users.rs @@ -44,21 +44,17 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(email, "test@windmill.dev"); // --- exists_email --- - let resp = authed(client().get(format!( - "{global_base}/exists/test@windmill.dev" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/exists/test@windmill.dev"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, true); - let resp = authed(client().get(format!( - "{global_base}/exists/nonexistent@windmill.dev" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/exists/nonexistent@windmill.dev"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); assert_eq!(resp.json::().await?, false); @@ -89,20 +85,51 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { let new_token = resp.text().await?; assert!(!new_token.is_empty()); - // --- tokens/delete --- let token_prefix = &new_token[..std::cmp::min(new_token.len(), 10)]; - let resp = authed(client().delete(format!( - "{global_base}/tokens/delete/{token_prefix}" - ))) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - 200, - "delete token: {}", - resp.text().await? - ); + + // --- tokens/update_scopes (set explicit scopes) --- + let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}"))) + .json(&json!({"scopes": ["jobs:run:scripts"]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "update_scopes: {}", resp.text().await?); + + // Verify via tokens/list that scopes were applied. + let resp = authed(client().get(format!("{global_base}/tokens/list"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let tokens = resp.json::>().await?; + let updated = tokens + .iter() + .find(|t| t["token_prefix"] == *token_prefix) + .expect("token in list"); + assert_eq!(updated["scopes"], json!(["jobs:run:scripts"])); + + // --- tokens/update_scopes (clear scopes via null = full access) --- + let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}"))) + .json(&json!({"scopes": null})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + // --- tokens/update_scopes on nonexistent prefix returns 404 --- + let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/zzznotreal"))) + .json(&json!({"scopes": ["jobs:run:scripts"]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + + // --- tokens/delete --- + let resp = authed(client().delete(format!("{global_base}/tokens/delete/{token_prefix}"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?); // --- list_invites --- let resp = authed(client().get(format!("{global_base}/list_invites"))) @@ -113,12 +140,10 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { resp.json::>().await?; // --- username_info --- - let resp = authed(client().get(format!( - "{global_base}/username_info/test@windmill.dev" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/username_info/test@windmill.dev"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let body = resp.json::().await?; assert_eq!(body["username"], "test-user"); @@ -158,13 +183,11 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(body["progress"], 42); // --- global update user --- - let resp = authed(client().post(format!( - "{global_base}/update/test2@windmill.dev" - ))) - .json(&json!({"name": "Updated Test User 2"})) - .send() - .await - .unwrap(); + let resp = authed(client().post(format!("{global_base}/update/test2@windmill.dev"))) + .json(&json!({"name": "Updated Test User 2"})) + .send() + .await + .unwrap(); assert_eq!( resp.status(), 200, @@ -218,7 +241,9 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { // --- auth: is_first_time_setup (unauthed) --- let resp = client() - .get(format!("http://localhost:{port}/api/auth/is_first_time_setup")) + .get(format!( + "http://localhost:{port}/api/auth/is_first_time_setup" + )) .send() .await .unwrap(); @@ -228,7 +253,9 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { // --- auth: is_smtp_configured (unauthed) --- let resp = client() - .get(format!("http://localhost:{port}/api/auth/is_smtp_configured")) + .get(format!( + "http://localhost:{port}/api/auth/is_smtp_configured" + )) .send() .await .unwrap(); @@ -255,27 +282,20 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { if create_status == 201 { // --- rename user (only if create succeeded / EE) --- - let resp = authed(client().post(format!( - "{global_base}/rename/newglobaluser@windmill.dev" - ))) - .json(&json!({"new_username": "renamed_user"})) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - 200, - "rename user: {}", - resp.text().await? - ); + let resp = + authed(client().post(format!("{global_base}/rename/newglobaluser@windmill.dev"))) + .json(&json!({"new_username": "renamed_user"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "rename user: {}", resp.text().await?); // --- global delete user --- - let resp = authed(client().delete(format!( - "{global_base}/delete/newglobaluser@windmill.dev" - ))) - .send() - .await - .unwrap(); + let resp = + authed(client().delete(format!("{global_base}/delete/newglobaluser@windmill.dev"))) + .send() + .await + .unwrap(); assert_eq!( resp.status(), 200, @@ -462,12 +482,7 @@ async fn test_user_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "delete user: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "delete user: {}", resp.text().await?); // verify deleted let resp = authed(client().get(format!("{base}/list_usernames"))) diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 2ee32bd96c..951060b531 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -135,6 +135,10 @@ pub fn global_service() -> Router { .route("/username_info/{user}", get(get_instance_username_info)) .route("/tokens/create", post(create_token)) .route("/tokens/delete/{token_prefix}", delete(delete_token)) + .route( + "/tokens/update_scopes/{token_prefix}", + post(update_token_scopes), + ) .route("/tokens/list", get(list_tokens)) .route("/tokens/impersonate", post(impersonate)) .route("/usage", get(get_usage)) @@ -292,6 +296,7 @@ pub struct TruncatedToken { pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, pub scopes: Option>, + pub workspace_id: Option, } // NewToken is re-exported from windmill-api-auth above @@ -2243,7 +2248,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) + last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, @@ -2255,7 +2260,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes FROM token WHERE email = $1 + last_used_at, scopes, workspace_id FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, @@ -2305,6 +2310,55 @@ async fn delete_token( )) } +#[derive(Deserialize)] +struct UpdateTokenScopesRequest { + scopes: Option>, +} + +async fn update_token_scopes( + Extension(db): Extension, + authed: ApiAuthed, + Path(token_prefix): Path, + Json(req): Json, +) -> Result { + let mut tx = db.begin().await?; + + let updated: Option = sqlx::query_scalar!( + "UPDATE token SET scopes = $1 + WHERE email = $2 AND token_prefix = $3 + RETURNING token_prefix", + req.scopes.as_deref(), + &authed.email, + &token_prefix, + ) + .fetch_optional(&mut *tx) + .await?; + + let prefix = updated.ok_or_else(|| { + Error::NotFound(format!( + "token {token_prefix} not found or not owned by user" + )) + })?; + + let scopes_json = serde_json::to_string(&req.scopes).unwrap_or_default(); + audit_log( + &mut *tx, + &authed, + "users.token.update_scopes", + ActionKind::Update, + &"global", + Some(&prefix), + Some([("scopes", scopes_json.as_str())].into()), + ) + .await?; + + tx.commit().await?; + + windmill_api_auth::invalidate_token_from_cache(&prefix); + + Ok(format!("updated scopes for token {prefix}")) +} + async fn leave_workspace( Extension(db): Extension, Path(w_id): Path, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 48ff99984f..517d54dfdf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4834,6 +4834,39 @@ paths: schema: type: string + /users/tokens/update_scopes/{token_prefix}: + post: + summary: update scopes of an existing token (owner only) + operationId: updateTokenScopes + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new scopes (null or omitted = full access) + required: true + content: + application/json: + schema: + type: object + properties: + scopes: + type: array + items: + type: string + nullable: true + responses: + "200": + description: scopes updated + content: + text/plain: + schema: + type: string + /users/tokens/list: get: summary: list token @@ -22363,6 +22396,8 @@ components: type: string email: type: string + workspace_id: + type: string required: - token_prefix - created_at diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 3d019ff539..61ae086515 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -14,21 +14,24 @@ interface Props { workspaceId: string scope: string + initialScope?: string } - let { workspaceId, scope = $bindable() }: Props = $props() + let { workspaceId, scope = $bindable(), initialScope }: Props = $props() - let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>('favorites') - let selectedFolders = $state([]) + const parsedInitial = parseInitialScope(initialScope) + + let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>(parsedInitial.mode) + let selectedFolders = $state(parsedInitial.folders) let allFolders = $state([]) let loadingFolders = $state(false) let folderNamesCache = new Map() - let selectedScripts = $state([]) - let selectedFlows = $state([]) - let selectedEndpoints = $state([]) - let customScriptPatterns = $state('') - let customFlowPatterns = $state('') - let newMcpApps = $state([]) + let selectedScripts = $state(parsedInitial.scripts) + let selectedFlows = $state(parsedInitial.flows) + let selectedEndpoints = $state(parsedInitial.endpoints) + let customScriptPatterns = $state(parsedInitial.scriptPatterns) + let customFlowPatterns = $state(parsedInitial.flowPatterns) + let newMcpApps = $state(parsedInitial.hubApps) let allScripts = $state([]) let allFlows = $state([]) @@ -47,6 +50,101 @@ .filter((p) => p.length > 0) } + type ParsedScope = { + mode: 'favorites' | 'all' | 'folder' | 'custom' + folders: string[] + scripts: string[] + flows: string[] + endpoints: string[] + scriptPatterns: string + flowPatterns: string + hubApps: string[] + } + + function parseInitialScope(input: string | undefined): ParsedScope { + const empty: ParsedScope = { + mode: 'favorites', + folders: [], + scripts: [], + flows: [], + endpoints: [], + scriptPatterns: '', + flowPatterns: '', + hubApps: [] + } + if (!input) return empty + + const parts = input.split(/\s+/).filter((p) => p.length > 0) + if (parts.length === 0) return empty + + const byKind: Record = {} + let mode: ParsedScope['mode'] = 'custom' + const hubApps: string[] = [] + + for (const part of parts) { + if (part === 'mcp:favorites') { + mode = 'favorites' + } else if (part === 'mcp:all') { + mode = 'all' + } else if (part.startsWith('mcp:hub:')) { + hubApps.push(...parsePatterns(part.slice('mcp:hub:'.length))) + } else if (part.startsWith('mcp:scripts:')) { + byKind.scripts = parsePatterns(part.slice('mcp:scripts:'.length)) + } else if (part.startsWith('mcp:flows:')) { + byKind.flows = parsePatterns(part.slice('mcp:flows:'.length)) + } else if (part.startsWith('mcp:endpoints:')) { + byKind.endpoints = parsePatterns(part.slice('mcp:endpoints:'.length)) + } + } + + // Detect folder mode: scripts and flows are exclusively `f/X/*` patterns + // for the same set of folders, and endpoints is exactly `*`. + const folderRe = /^f\/([^/]+)\/\*$/ + const scriptFolders = (byKind.scripts ?? []).map((p) => p.match(folderRe)?.[1]) + const flowFolders = (byKind.flows ?? []).map((p) => p.match(folderRe)?.[1]) + const allScriptsAreFolders = scriptFolders.length > 0 && scriptFolders.every((f) => !!f) + const allFlowsAreFolders = flowFolders.length > 0 && flowFolders.every((f) => !!f) + const sameFolders = + allScriptsAreFolders && + allFlowsAreFolders && + scriptFolders.length === flowFolders.length && + scriptFolders.every((f, i) => f === flowFolders[i]) + const endpointsIsAll = byKind.endpoints?.length === 1 && byKind.endpoints[0] === '*' + + if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsIsAll) { + return { + mode: 'folder', + folders: scriptFolders.filter((f): f is string => !!f), + scripts: [], + flows: [], + endpoints: [], + scriptPatterns: '', + flowPatterns: '', + hubApps + } + } + + if (mode === 'favorites' || mode === 'all') { + return { ...empty, mode, hubApps } + } + + // Custom mode: split each list into "selectable" entries (later filtered + // against allScripts/allFlows once loaded) and free-form patterns. + // We can't know yet which are real paths vs wildcard patterns, so pass + // everything as patterns; once allScripts/allFlows load, $effect will + // move matching entries into selectedScripts/selectedFlows. + return { + mode: 'custom', + folders: [], + scripts: [], + flows: [], + endpoints: byKind.endpoints ?? [], + scriptPatterns: (byKind.scripts ?? []).join(','), + flowPatterns: (byKind.flows ?? []).join(','), + hubApps + } + } + // Compute scope string from selections $effect(() => { let scopeParts: string[] = [] @@ -112,9 +210,9 @@ try { loadingFolders = true const excludedFolders = ['app_groups', 'app_custom', 'app_themes'] - const names = ( - await FolderService.listFolderNames({ workspace }) - ).filter((x) => !excludedFolders.includes(x)) + const names = (await FolderService.listFolderNames({ workspace })).filter( + (x) => !excludedFolders.includes(x) + ) folderNamesCache.set(workspace, names) allFolders = names } catch { @@ -261,6 +359,36 @@ } }) + // One-shot: once allScripts/allFlows are loaded, split the + // initial pattern text into known paths (selectedScripts/Flows) vs + // remaining wildcards/unknowns (kept in pattern textbox). + let initialSplitDone = $state(false) + $effect(() => { + if (initialSplitDone || selectedMode !== 'custom') return + if (allScripts.length === 0 && allFlows.length === 0) return + + const scriptSet = new Set(allScripts) + const flowSet = new Set(allFlows) + + const scriptParts = parsePatterns(customScriptPatterns) + const knownScripts = scriptParts.filter((p) => scriptSet.has(p)) + const remainingScripts = scriptParts.filter((p) => !scriptSet.has(p)) + + const flowParts = parsePatterns(customFlowPatterns) + const knownFlows = flowParts.filter((p) => flowSet.has(p)) + const remainingFlows = flowParts.filter((p) => !flowSet.has(p)) + + if (knownScripts.length > 0) { + selectedScripts = [...new Set([...selectedScripts, ...knownScripts])] + customScriptPatterns = remainingScripts.join(',') + } + if (knownFlows.length > 0) { + selectedFlows = [...new Set([...selectedFlows, ...knownFlows])] + customFlowPatterns = remainingFlows.join(',') + } + initialSplitDone = true + }) + const warning = $derived( selectedMode === 'all' ? 'Create your first scripts or flows to make them available via MCP.' diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index ad54ddad82..6ba2461725 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -6,8 +6,7 @@ import Toggle from '../Toggle.svelte' import { UserService, type NewToken } from '$lib/gen' import TokenDisplay from './TokenDisplay.svelte' - import ScopeSelector from './ScopeSelector.svelte' - import McpScopeSelector from '../mcp/McpScopeSelector.svelte' + import ScopesPicker from './ScopesPicker.svelte' import TextInput from '../text_input/TextInput.svelte' import Select from '../select/Select.svelte' @@ -43,12 +42,10 @@ let newTokenExpiration = $state(undefined) let newTokenWorkspace = $state(untrack(() => defaultNewTokenWorkspace)) let mcpCreationMode = $state(false) - let mcpScope = $state('mcp:favorites') let lastRequestedMcpMode = $state(undefined) let mcpLabelAutofilled = $state(false) - let customScopes = $state([]) - let showCustomScopes = $state(false) + let pickedScopes = $state(null) function ensureCurrentWorkspaceIncluded( workspacesList: UserWorkspace[], @@ -96,12 +93,7 @@ date = new Date(new Date().getTime() + newTokenExpiration * 1000) } - let tokenScopes = scopes - if (mcpMode) { - tokenScopes = mcpScope.split(' ').filter((s) => s.length > 0) - } else if (showCustomScopes && customScopes.length > 0) { - tokenScopes = customScopes - } + const tokenScopes = scopes ?? pickedScopes ?? undefined const createdToken = await UserService.createToken({ requestBody: { @@ -195,77 +187,58 @@ {/if} - {#if !mcpCreationMode && (!scopes || scopes.length === 0)} -
- { - showCustomScopes = e.detail - }} - options={{ - right: 'Limit token permissions', - rightTooltip: - 'By default, tokens have full API access. Enable this to restrict the token to specific scopes.' - }} - size="xs" - /> - {#if showCustomScopes} - - {/if} -
+ {#if !scopes || scopes.length === 0} + {/if}
- {#if mcpCreationMode} -
- -
- - {#if !lockWorkspace} -
- Workspace - ({ label: w.name, value: w.id, subtitle: w.id }))} />
{/if} + {/if} + + {#if !mcpOnly} +
+ Label (optional) + +
+ {/if} + + {#if !mcpCreationMode} +
+ Expires In (optional) +