feat: edit scopes on existing API tokens (#8967)

* feat: edit scopes on existing API tokens

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

* fix: address PR review feedback on token scope edit

- add SECURITY DEFINER to notify_token_scopes_change so trigger fires under windmill_user/admin roles (cubic P1)
- drop banned $bindable(default) on optional props (CLAUDE.md): make ScopesPicker.value and EditTokenScopesModal.open required
- detect MCP only when *every* scope starts with mcp: so mixed/null-scope tokens fall back to standard picker without dropping non-mcp scopes
- audit log scope payload via serde_json instead of Rust {:?}

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-04-29 21:49:56 +02:00
committed by GitHub
parent de0b6b1528
commit e9e72fbbf8
13 changed files with 612 additions and 174 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS token_scopes_update_trigger ON token;
DROP FUNCTION IF EXISTS notify_token_scopes_change();
@@ -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();
@@ -44,21 +44,17 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> 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::<bool>().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::<bool>().await?, false);
@@ -89,20 +85,51 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> 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::<Vec<serde_json::Value>>().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<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<serde_json::Value>>().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::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
@@ -158,13 +183,11 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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")))
+56 -2
View File
@@ -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<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
}
// 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<Vec<String>>,
}
async fn update_token_scopes(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenScopesRequest>,
) -> Result<String> {
let mut tx = db.begin().await?;
let updated: Option<String> = 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<DB>,
Path(w_id): Path<String>,
+35
View File
@@ -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
@@ -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<string[]>([])
const parsedInitial = parseInitialScope(initialScope)
let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>(parsedInitial.mode)
let selectedFolders = $state<string[]>(parsedInitial.folders)
let allFolders = $state<string[]>([])
let loadingFolders = $state(false)
let folderNamesCache = new Map<string, string[]>()
let selectedScripts = $state<string[]>([])
let selectedFlows = $state<string[]>([])
let selectedEndpoints = $state<string[]>([])
let customScriptPatterns = $state<string>('')
let customFlowPatterns = $state<string>('')
let newMcpApps = $state<string[]>([])
let selectedScripts = $state<string[]>(parsedInitial.scripts)
let selectedFlows = $state<string[]>(parsedInitial.flows)
let selectedEndpoints = $state<string[]>(parsedInitial.endpoints)
let customScriptPatterns = $state<string>(parsedInitial.scriptPatterns)
let customFlowPatterns = $state<string>(parsedInitial.flowPatterns)
let newMcpApps = $state<string[]>(parsedInitial.hubApps)
let allScripts = $state<string[]>([])
let allFlows = $state<string[]>([])
@@ -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<string, string[]> = {}
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.'
@@ -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<number | undefined>(undefined)
let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace))
let mcpCreationMode = $state(false)
let mcpScope = $state('mcp:favorites')
let lastRequestedMcpMode = $state<boolean | undefined>(undefined)
let mcpLabelAutofilled = $state(false)
let customScopes = $state<string[]>([])
let showCustomScopes = $state(false)
let pickedScopes = $state<string[] | null>(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 @@
</div>
{/if}
{#if !mcpCreationMode && (!scopes || scopes.length === 0)}
<div class="flex flex-col gap-2">
<Toggle
checked={showCustomScopes}
on:change={(e) => {
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}
<ScopeSelector bind:selectedScopes={customScopes} />
{/if}
</div>
{#if !scopes || scopes.length === 0}
<ScopesPicker
mode={mcpCreationMode ? 'mcp' : 'standard'}
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:value={pickedScopes}
/>
{/if}
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
{#if mcpCreationMode}
<div class="col-span-2">
<McpScopeSelector
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:scope={mcpScope}
/>
</div>
{#if !lockWorkspace}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</div>
{/if}
{/if}
{#if !mcpOnly}
{#if mcpCreationMode}
{#if !lockWorkspace}
<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
>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</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 }
]}
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</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">
@@ -281,8 +254,7 @@
{/if}
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode &&
(newTokenWorkspace == undefined || !mcpScope || mcpScope.trim().length === 0)}
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
variant="accent"
>
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
@@ -0,0 +1,76 @@
<script lang="ts">
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import Modal from '../common/modal/Modal.svelte'
import ScopesPicker from './ScopesPicker.svelte'
interface Props {
open: boolean
tokenPrefix?: string
initialScopes?: string[]
tokenWorkspaceId?: string
onSaved?: () => void
}
let {
open = $bindable(),
tokenPrefix,
initialScopes,
tokenWorkspaceId,
onSaved
}: Props = $props()
// Treat as MCP only when *all* existing scopes are mcp:* — mixed-scope or
// null-scope tokens fall back to the standard picker so non-MCP scopes are
// never silently dropped.
const isMcp = $derived(
(initialScopes ?? []).length > 0 && (initialScopes ?? []).every((s) => s.startsWith('mcp:'))
)
const mcpWorkspaceId = $derived(tokenWorkspaceId ?? $workspaceStore ?? '')
let pickedScopes = $state<string[] | null>(null)
let saving = $state(false)
async function save() {
if (!tokenPrefix) return
saving = true
try {
await UserService.updateTokenScopes({
tokenPrefix,
requestBody: { scopes: pickedScopes }
})
sendUserToast('Token scopes updated')
onSaved?.()
open = false
} catch (err) {
sendUserToast(`Failed to update scopes: ${err.body ?? err.message}`, true)
} finally {
saving = false
}
}
const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && !pickedScopes))
</script>
<Modal bind:open title="Edit token scopes" class="!max-w-3xl">
<div class="flex flex-col gap-3">
<div class="text-xs text-secondary">
Token <span class="font-mono">{tokenPrefix}****</span>
</div>
{#key tokenPrefix}
<ScopesPicker
mode={isMcp ? 'mcp' : 'standard'}
workspaceId={mcpWorkspaceId}
{initialScopes}
bind:value={pickedScopes}
/>
{/key}
</div>
{#snippet actions()}
<Button size="sm" variant="accent" disabled={saveDisabled} on:click={save}>Save</Button>
{/snippet}
</Modal>
@@ -0,0 +1,59 @@
<script lang="ts">
import Toggle from '../Toggle.svelte'
import ScopeSelector from './ScopeSelector.svelte'
import McpScopeSelector from '../mcp/McpScopeSelector.svelte'
interface Props {
mode: 'standard' | 'mcp'
workspaceId?: string
/** Existing scopes (used for both initial standard selection and parsing MCP scope) */
initialScopes?: string[]
/** Final scope value: null = unrestricted/full access, array = explicit list */
value: string[] | null
}
let { mode, workspaceId = '', initialScopes, value = $bindable() }: Props = $props()
const initialMcpScope = $derived(
(initialScopes ?? []).length > 0 ? (initialScopes ?? []).join(' ') : undefined
)
// Standard-mode local state, seeded from initialScopes if any.
let limited = $state((initialScopes ?? []).length > 0)
let standardScopes = $state<string[]>([...(initialScopes ?? [])])
// MCP-mode local state. When no initial scope is provided we fall back
// to `mcp:favorites` (matches the create-token default).
let mcpScope = $state(initialMcpScope ?? 'mcp:favorites')
$effect(() => {
if (mode === 'mcp') {
const parts = mcpScope
.split(' ')
.map((s) => s.trim())
.filter((s) => s.length > 0)
value = parts.length > 0 ? parts : null
} else {
value = limited && standardScopes.length > 0 ? standardScopes : null
}
})
</script>
{#if mode === 'standard'}
<div class="flex flex-col gap-2">
<Toggle
bind:checked={limited}
options={{
right: 'Limit token permissions',
rightTooltip:
'When off, the token has full API access. Turn on to restrict it to specific scopes.'
}}
size="xs"
/>
{#if limited}
<ScopeSelector bind:selectedScopes={standardScopes} />
{/if}
</div>
{:else}
<McpScopeSelector {workspaceId} bind:scope={mcpScope} initialScope={initialMcpScope} />
{/if}
@@ -5,10 +5,11 @@
import { UserService, type TruncatedToken } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import CreateToken from './CreateToken.svelte'
import EditTokenScopesModal from './EditTokenScopesModal.svelte'
import Button from '../common/button/Button.svelte'
import Badge from '../common/badge/Badge.svelte'
import Alert from '../common/alert/Alert.svelte'
import { Trash } from 'lucide-svelte'
import { Pen, Trash } from 'lucide-svelte'
// --- Props ---
interface Props {
@@ -33,6 +34,10 @@
let tokens = $state<TruncatedToken[]>([])
let tokenPage = $state(1)
let newTokenLabel = $state<string | undefined>(untrack(() => defaultNewTokenLabel))
let editingToken = $state<
{ prefix: string; scopes: string[] | undefined; workspaceId: string | undefined } | undefined
>(undefined)
let editModalOpen = $state(false)
$effect(() => {
listTokens()
@@ -97,6 +102,19 @@
listTokens()
}
function handleEditClick(
tokenPrefix: string,
tokenScopes: string[] | undefined,
tokenWorkspaceId: string | undefined
) {
editingToken = {
prefix: tokenPrefix,
scopes: tokenScopes,
workspaceId: tokenWorkspaceId
}
editModalOpen = true
}
async function listTokens(): Promise<void> {
tokens = await UserService.listTokens({
excludeEphemeral: true,
@@ -123,7 +141,11 @@
</div>
{#if expiringSoonCount > 0}
<div class="mb-2">
<Alert type="warning" title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days" size="xs" />
<Alert
type="warning"
title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days"
size="xs"
/>
</div>
{/if}
<CreateToken
@@ -136,20 +158,19 @@
/>
<div class="overflow-auto grow min-h-64 max-h-2/3">
<TableCustom>
{#snippet headerRow()}
<tr >
<tr>
<th>Prefix</th>
<th>Label</th>
<th>Expiration</th>
<th>Scopes</th>
<th></th>
</tr>
{/snippet}
{/snippet}
{#snippet body()}
<tbody>
{#if tokens && tokens.length > 0}
{#each tokens as { token_prefix, expiration, label, scopes } (token_prefix)}
{#each tokens as { token_prefix, expiration, label, scopes, workspace_id } (token_prefix)}
{@const badge = expirationBadge(expiration, label)}
<tr>
<td class="w-32 text-xs text-primary">{token_prefix}****</td>
@@ -166,15 +187,29 @@
class="min-w-0 max-w-48 truncate text-xs text-secondary"
title={scopes?.join(', ') ?? ''}>{scopes?.join(', ') ?? ''}</td
>
<td class="w-16 text-center">
<Button
variant="subtle"
destructive
on:click={() => handleDeleteClick(token_prefix)}
size="xs"
startIcon={{ icon: Trash }}
iconOnly
/>
<td class="w-24 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="subtle"
on:click={() =>
handleEditClick(
token_prefix,
scopes ?? undefined,
workspace_id ?? undefined
)}
size="xs"
startIcon={{ icon: Pen }}
iconOnly
/>
<Button
variant="subtle"
destructive
on:click={() => handleDeleteClick(token_prefix)}
size="xs"
startIcon={{ icon: Trash }}
iconOnly
/>
</div>
</td>
</tr>
{/each}
@@ -198,3 +233,11 @@
</div>
</div>
</div>
<EditTokenScopesModal
bind:open={editModalOpen}
tokenPrefix={editingToken?.prefix}
initialScopes={editingToken?.scopes}
tokenWorkspaceId={editingToken?.workspaceId}
onSaved={listTokens}
/>