mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+8
-2
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+24
@@ -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")))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user