diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index d277874cce..3ef55a9d82 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -185,6 +185,84 @@ app.post("/local-session", async (c) => { } }); +/** + * My Permissions Endpoint (authenticated) + * + * Returns the current user's role and permission map for their active + * organization. Used by the frontend useCapability hook so any member — + * not just admins — can resolve their own capabilities. + * + * This bypasses Better Auth's listRoles permission gate, which restricts + * the full role list to admins/owners. Members need to read just their + * own role, not the entire org's role catalog. + * + * Route: GET /api/auth/custom/my-permissions + */ +app.get("/my-permissions", async (c) => { + const session = (await auth.api.getSession({ + headers: c.req.raw.headers, + })) as { + user?: { id: string }; + session?: { activeOrganizationId?: string }; + } | null; + + if (!session?.user) { + return c.json({ error: "Authentication required" }, 401); + } + + const orgId = session.session?.activeOrganizationId; + if (!orgId) { + return c.json({ role: null, permission: null }); + } + + const db = getDb().db; + + // Find the user's member row in the active organization + const member = await db + .selectFrom("member") + .select(["role"]) + .where("userId", "=", session.user.id) + .where("organizationId", "=", orgId) + .executeTakeFirst(); + + if (!member?.role) { + return c.json({ role: null, permission: null }); + } + + // Built-in roles (owner/admin/user) bypass custom permission lookups — + // owner & admin get full access, user gets only basic-usage tools (the + // server's AccessControl handles those rules; clients use the role name). + if (member.role === "owner" || member.role === "admin") { + return c.json({ role: member.role, permission: null }); + } + if (member.role === "user") { + return c.json({ role: member.role, permission: {} }); + } + + // Custom role: look up the permission JSON in organizationRole. + // Better Auth stores it in camelCase column "organizationRole". + const customRole = await db + .selectFrom("organizationRole" as never) + .select(["permission" as never] as never) + .where("role" as never, "=", member.role as never) + .where("organizationId" as never, "=", orgId as never) + .executeTakeFirst(); + + let permission: Record | null = null; + const raw = (customRole as { permission?: unknown })?.permission; + if (typeof raw === "string") { + try { + permission = JSON.parse(raw) as Record; + } catch { + permission = null; + } + } else if (raw && typeof raw === "object") { + permission = raw as Record; + } + + return c.json({ role: member.role, permission }); +}); + /** * Domain Lookup Endpoint (authenticated, verified email required) * diff --git a/apps/mesh/src/auth/grant-resource-access.ts b/apps/mesh/src/auth/grant-resource-access.ts new file mode 100644 index 0000000000..b74d8aa944 --- /dev/null +++ b/apps/mesh/src/auth/grant-resource-access.ts @@ -0,0 +1,82 @@ +/** + * Auto-grant new resources (connections, virtual MCPs) to all existing + * custom roles in an organization. + * + * Default-allow semantic: when an admin adds a new MCP, every member — + * regardless of their custom role — should be able to use it. To restrict + * access, an admin explicitly removes the grant from the role afterward. + * + * Owner / admin roles bypass all permission checks at runtime, so they + * don't need updates here. The built-in "user" role is hardcoded and not + * stored in `organizationRole`, so it's also untouched — by design, "user" + * stays minimal-privilege. + */ + +import type { Kysely } from "kysely"; +import type { Database } from "@/storage/types"; + +type Permission = Record; + +function parsePermission(raw: unknown): Permission { + if (!raw) return {}; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as Permission; + } catch { + return {}; + } + } + if (typeof raw === "object") { + return raw as Permission; + } + return {}; +} + +/** + * Append a grant of all tools on a given resource (connection / virtual MCP) + * to every custom role in the organization. + * + * Idempotent: if the role already has a wildcard "*" rule or already lists + * the resource, the existing grant is preserved. + */ +export async function grantResourceAccessToAllCustomRoles( + db: Kysely, + organizationId: string, + resourceId: string, +): Promise { + const roles = await db + .selectFrom("organizationRole") + .select(["id", "permission"]) + .where("organizationId", "=", organizationId) + .execute(); + + if (roles.length === 0) return; + + await Promise.all( + roles.map(async (row) => { + const permission = parsePermission(row.permission); + + // Already has full access via any of the wildcard shapes Better Auth + // accepts — { "*": ["*"] } (all-resources wildcard) or + // { "self": ["*"] } (full org-tool access). Skip to avoid appending + // redundant per-resource entries that would never be consulted. + if (permission["*"]?.includes("*")) return; + if (permission.self?.includes("*")) return; + + // Already has an explicit grant on this resource — preserve it. + if (permission[resourceId]) return; + + const next: Permission = { + ...permission, + [resourceId]: ["*"], + }; + + await db + .updateTable("organizationRole") + .set({ permission: JSON.stringify(next) }) + .where("id", "=", row.id) + .execute(); + }), + ); +} diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index d3029ae9fb..8fff635009 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -153,6 +153,18 @@ if ( } } +// Local-dev fallback: when no email provider is configured, print the +// invitation accept URL to the server console so you can paste it into a +// second browser/incognito session and finish the invite without email. +if (!sendInvitationEmail) { + sendInvitationEmail = async (data) => { + const acceptUrl = `${getBaseUrl()}/auth/accept-invitation?invitationId=${data.invitation.id}&redirectTo=/`; + console.log( + `\n[invitation] No email provider configured. Open this URL in another browser to accept:\n invitee: ${data.email}\n org: ${data.organization.name}\n url: ${acceptUrl}\n`, + ); + }; +} + // Configure password reset emails if provider is set let sendResetPassword: | NonNullable["sendResetPassword"] diff --git a/apps/mesh/src/auth/migrate.ts b/apps/mesh/src/auth/migrate.ts index a64eae9b12..3341d0e2dd 100644 --- a/apps/mesh/src/auth/migrate.ts +++ b/apps/mesh/src/auth/migrate.ts @@ -35,10 +35,14 @@ export async function migrateBetterAuth(databaseUrl?: string): Promise { // Minimal options — only needs plugins to discover which tables to create. // Does not need auth config, rate limiting, hooks, etc. + // dynamicAccessControl must be enabled here so Better Auth creates the + // organizationRole table that backs custom roles & per-role permissions. const options = { database: freshDatabase, plugins: [ - organization(), + organization({ + dynamicAccessControl: { enabled: true, enableCustomResources: true }, + }), adminPlugin(), apiKey(), jwt(), diff --git a/apps/mesh/src/core/access-control.ts b/apps/mesh/src/core/access-control.ts index ea11293fd4..569ad2f3e5 100644 --- a/apps/mesh/src/core/access-control.ts +++ b/apps/mesh/src/core/access-control.ts @@ -10,6 +10,7 @@ */ import { MCP_MESH_KEY } from "@/core/constants"; +import { BASIC_USAGE_TOOLS } from "../tools/registry-metadata"; import type { BetterAuthInstance, BoundAuthClient } from "./mesh-context"; // ============================================================================ @@ -144,9 +145,13 @@ export class AccessControl implements Disposable { } } - // No permission found + // No permission found. The error message is read by both the chat + // model and the UI error handler — keep it explicit and directive so + // the AI tells the user about the permission instead of trying to + // install / fall back to other tools. + const resourcesText = resourcesToCheck.join(", "); throw new ForbiddenError( - `Access denied to: ${resourcesToCheck.join(", ")}`, + `Access denied to: ${resourcesText}. The current user does not have permission to use this tool. Tell the user that they need to ask an organization admin to update their role permissions to grant access. Do not attempt to install or use alternative tools — they will likely fail with the same permission error.`, ); } @@ -155,6 +160,12 @@ export class AccessControl implements Disposable { * Delegates to Better Auth's Organization plugin via boundAuth */ private async checkResource(resource: string): Promise { + // Basic-usage tools are always granted to authenticated org members, + // regardless of role. The basic-usage capability lives in the registry + // and is hidden from the role editor UI. + if (BASIC_USAGE_TOOLS.has(resource)) { + return true; + } // No user or bound auth = deny if (!this.userId && !this.boundAuth) { return false; @@ -205,4 +216,20 @@ export class AccessControl implements Disposable { granted(): boolean { return this._granted; } + + /** + * Soft permission check — returns true/false without throwing. + * Useful inside handlers that want to vary behavior based on the + * caller's role (e.g. show all rows vs. only the user's own). + */ + async has(resource: string): Promise { + if (!this.userId && !this.boundAuth) { + return false; + } + try { + return await this.checkResource(resource); + } catch { + return false; + } + } } diff --git a/apps/mesh/src/tools/connection/create.ts b/apps/mesh/src/tools/connection/create.ts index e180b9228f..92f89725a2 100644 --- a/apps/mesh/src/tools/connection/create.ts +++ b/apps/mesh/src/tools/connection/create.ts @@ -16,6 +16,8 @@ import { } from "../../core/mesh-context"; import { getMcpListCache } from "../../mcp-clients/mcp-list-cache"; import { fetchToolsFromMCP } from "./fetch-tools"; +import { grantResourceAccessToAllCustomRoles } from "../../auth/grant-resource-access"; +import { getDb } from "../../database"; import { buildVirtualUrl, ConnectionCreateDataSchema, @@ -126,6 +128,20 @@ export const COLLECTION_CONNECTIONS_CREATE = defineTool({ .catch(() => {}); } + // Auto-grant the new connection to every existing custom role so + // members aren't locked out of a freshly added MCP. Admins can still + // remove the grant on a per-role basis afterward. + await grantResourceAccessToAllCustomRoles( + getDb().db, + organization.id, + connection.id, + ).catch((err) => { + console.error( + "[connection.create] Failed to auto-grant new connection to roles", + err, + ); + }); + await ctx.eventBus.publish( organization.id, WellKnownOrgMCPId.SELF(organization.id), diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index c4793062c0..504934f975 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -157,47 +157,64 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({ offset: needsBindingFilter ? undefined : offset, }); - // Only fetch tools from MCP servers when we need them for binding filtering. - // This avoids expensive live listTools() calls on every page load. - if (bindingChecker) { - const cache = getMcpListCache(); - const selfId = WellKnownOrgMCPId.SELF(organization.id); - await Promise.all( - connections.map(async (connection) => { - if (connection.tools !== null) return; - // The self MCP requires session auth, so an HTTP round-trip would - // fail without forwarding cookies. Use in-process transport instead. - const fetchLive = - connection.id === selfId - ? async () => { - const { listManagementTools } = await import("../../tools"); - return listManagementTools(ctx) as Promise; - } - : async () => { - const client = await clientFromConnection( - connection, - ctx, - true, - ); - try { - const result = await client.listTools(); - return result.tools; - } finally { - await client.close().catch(() => {}); - } - }; - const tools = await fetchWithCache( - "tools", - connection.id, - fetchLive, - cache, - ); - if (tools !== null) { - connection.tools = tools as Tool[]; + // Populate tools on each connection. + // - Binding-filtered queries need tools to evaluate the binding, + // so we may have to do a live listTools() on cache miss. + // - Non-filtered queries (e.g. the role editor's tool selector) + // just want the tool catalog if it exists in cache. Skipping the + // live fallback keeps the page-load cheap while still showing + // freshly-imported MCPs as soon as their tools are cached. + const cache = getMcpListCache(); + const selfId = WellKnownOrgMCPId.SELF(organization.id); + await Promise.all( + connections.map(async (connection) => { + if (connection.tools !== null) return; + + if (cache && !bindingChecker) { + // Read-only path: cache hit only, no live fallback. + const cached = (await cache.get("tools", connection.id)) as + | Tool[] + | null; + if (cached) { + connection.tools = cached; } - }), - ); - } + return; + } + + if (!bindingChecker) return; + + // The self MCP requires session auth, so an HTTP round-trip would + // fail without forwarding cookies. Use in-process transport instead. + const fetchLive = + connection.id === selfId + ? async () => { + const { listManagementTools } = await import("../../tools"); + return listManagementTools(ctx) as Promise; + } + : async () => { + const client = await clientFromConnection( + connection, + ctx, + true, + ); + try { + const result = await client.listTools(); + return result.tools; + } finally { + await client.close().catch(() => {}); + } + }; + const tools = await fetchWithCache( + "tools", + connection.id, + fetchLive, + cache, + ); + if (tools !== null) { + connection.tools = tools as Tool[]; + } + }), + ); // In dev mode, inject the dev-assets connection for local file storage // This provides object storage functionality without requiring an external S3 bucket diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 7d81179766..c5dc0e486e 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -104,6 +104,18 @@ const ALL_TOOL_NAMES = [ "COLLECTION_THREADS_UPDATE", "COLLECTION_THREADS_DELETE", "COLLECTION_THREAD_MESSAGES_LIST", + // Synthetic permission flag — there's no THREADS_VIEW_ALL_MEMBERS + // tool; the name is used as a permission resource so the capability + // toggle in the role editor stores `self: ["THREADS_VIEW_ALL_MEMBERS"]`, + // which COLLECTION_THREADS_LIST checks via ctx.access.has() to decide + // whether to show all members' threads or only the caller's. + "THREADS_VIEW_ALL_MEMBERS", + // Synthetic permission flags for chat composer features (image + // generation and web search). Gating happens in the chat tools + // popover — the underlying model usage is independently controlled + // via the role's Models tab. + "CHAT_IMAGE_GENERATION", + "CHAT_WEB_SEARCH", // Tag tools "TAGS_LIST", "TAGS_CREATE", @@ -882,146 +894,360 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ }, ]; +// ============================================================================ +// Permission Capabilities (high-level, user-facing permissions) +// ============================================================================ + +export interface PermissionCapability { + id: string; + label: string; + description: string; + section: string; + tools: ToolName[]; + dangerous?: boolean; +} + /** - * Human-readable labels for tool names + * Capability id for tools all authenticated org members can use by default. + * AccessControl auto-grants any tool listed here; the UI hides this capability. */ -const TOOL_LABELS: Record = { - ORGANIZATION_CREATE: "Create organization", - ORGANIZATION_LIST: "List organizations", - ORGANIZATION_GET: "View organization details", - ORGANIZATION_UPDATE: "Update organization", - ORGANIZATION_DELETE: "Delete organization", - ORGANIZATION_SETTINGS_GET: "View organization settings", - ORGANIZATION_SETTINGS_UPDATE: "Update organization settings", - BRAND_CONTEXT_LIST: "List brand contexts", - BRAND_CONTEXT_GET: "View brand context", - BRAND_CONTEXT_CREATE: "Create brand context", - BRAND_CONTEXT_UPDATE: "Update brand context", - BRAND_CONTEXT_DELETE: "Delete brand context", - BRAND_CONTEXT_EXTRACT: "Extract brand from website", - BRAND_GET: "Get brand", - BRAND_LIST: "List brands", - ORGANIZATION_DOMAIN_GET: "Get domain claim", - ORGANIZATION_DOMAIN_SET: "Set domain claim", - ORGANIZATION_DOMAIN_UPDATE: "Update domain settings", - ORGANIZATION_DOMAIN_CLEAR: "Clear domain claim", - ORGANIZATION_MEMBER_LIST: "List members", - ORGANIZATION_MEMBER_ADD: "Add members", - ORGANIZATION_MEMBER_REMOVE: "Remove members", - ORGANIZATION_MEMBER_UPDATE_ROLE: "Update member roles", - COLLECTION_CONNECTIONS_LIST: "List connections", - COLLECTION_CONNECTIONS_GET: "View connection details", - COLLECTION_CONNECTIONS_CREATE: "Create connections", - COLLECTION_CONNECTIONS_UPDATE: "Update connections", - COLLECTION_CONNECTIONS_DELETE: "Delete connections", - CONNECTION_TEST: "Test connections", - DATABASES_RUN_SQL: "Run SQL queries", - COLLECTION_VIRTUAL_MCP_CREATE: "Create virtual MCPs", - COLLECTION_VIRTUAL_MCP_LIST: "List virtual MCPs", - COLLECTION_VIRTUAL_MCP_GET: "View virtual MCP details", - COLLECTION_VIRTUAL_MCP_UPDATE: "Update virtual MCPs", - COLLECTION_VIRTUAL_MCP_DELETE: "Delete virtual MCPs", - MONITORING_LOG_GET: "View monitoring log details", - MONITORING_LOGS_LIST: "List monitoring logs", - MONITORING_STATS: "View monitoring statistics", - API_KEY_CREATE: "Create API key", - API_KEY_LIST: "List API keys", - API_KEY_UPDATE: "Update API key", - API_KEY_DELETE: "Delete API key", - EVENT_PUBLISH: "Publish events", - EVENT_SUBSCRIBE: "Subscribe to events", - EVENT_UNSUBSCRIBE: "Unsubscribe from events", - EVENT_CANCEL: "Cancel recurring events", - EVENT_ACK: "Acknowledge event delivery", - EVENT_SUBSCRIPTION_LIST: "List event subscriptions", - EVENT_SYNC_SUBSCRIPTIONS: "Sync subscriptions to desired state", +const BASIC_USAGE_CAPABILITY_ID = "basic-usage"; - USER_GET: "Get user by id", - COLLECTION_THREADS_CREATE: "Create threads", - COLLECTION_THREADS_LIST: "List threads", - COLLECTION_THREADS_GET: "View thread details", - COLLECTION_THREADS_UPDATE: "Update threads", - COLLECTION_THREADS_DELETE: "Delete threads", - COLLECTION_THREAD_MESSAGES_LIST: "List thread messages", - TAGS_LIST: "List organization tags", - TAGS_CREATE: "Create organization tag", - TAGS_DELETE: "Delete organization tag", - MEMBER_TAGS_GET: "Get member tags", - MEMBER_TAGS_SET: "Set member tags", - VIRTUAL_MCP_PLUGIN_CONFIG_GET: "View plugin config", - VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE: "Update plugin config", - VIRTUAL_MCP_PINNED_VIEWS_UPDATE: "Update pinned views", - AUTOMATION_CREATE: "Create automation", - AUTOMATION_GET: "View automation details", - AUTOMATION_LIST: "List automations", - AUTOMATION_UPDATE: "Update automation", - AUTOMATION_DELETE: "Delete automation", - AUTOMATION_TRIGGER_ADD: "Add trigger", - AUTOMATION_TRIGGER_REMOVE: "Remove trigger", - AUTOMATION_RUN: "Run automation", - - AI_PROVIDERS_LIST: "List AI providers", - AI_PROVIDERS_LIST_MODELS: "List AI models", - AI_PROVIDERS_ACTIVE: "List active providers", - AI_PROVIDER_KEY_CREATE: "Create provider key", - AI_PROVIDER_KEY_LIST: "List provider keys", - AI_PROVIDER_KEY_DELETE: "Delete provider key", - AI_PROVIDER_OAUTH_URL: "Get OAuth URL", - AI_PROVIDER_OAUTH_EXCHANGE: "Connect via OAuth", - AI_PROVIDER_PROVISION_KEY: "Auto-provision key", - AI_PROVIDER_TOPUP_URL: "Get top-up checkout URL", - AI_PROVIDER_CREDITS: "Get credit balance", - AI_PROVIDER_CLI_ACTIVATE: "Activate Claude Code CLI", +export const PERMISSION_CAPABILITIES: PermissionCapability[] = [ + // Basic usage — granted to all org members, hidden from UI + { + id: BASIC_USAGE_CAPABILITY_ID, + label: "Basic Usage", + description: "Tools all org members can access by default", + section: "Basic Usage", + tools: [ + // View connections + "COLLECTION_CONNECTIONS_LIST", + "COLLECTION_CONNECTIONS_GET", + "CONNECTION_TEST", + // View agents + "COLLECTION_VIRTUAL_MCP_LIST", + "COLLECTION_VIRTUAL_MCP_GET", + "VIRTUAL_MCP_PLUGIN_CONFIG_GET", + // View automations + "AUTOMATION_GET", + "AUTOMATION_LIST", + // View AI providers (read-only — every member needs to know which + // providers are configured so chat / agents can use them) + "AI_PROVIDERS_LIST", + "AI_PROVIDERS_LIST_MODELS", + "AI_PROVIDERS_ACTIVE", + "AI_PROVIDER_KEY_LIST", + "AI_PROVIDER_CREDITS", + // Object storage access + "LIST_OBJECTS", + "GET_OBJECT_METADATA", + "GET_PRESIGNED_URL", + "PUT_PRESIGNED_URL", + // VM previews + "VM_START", + "VM_DELETE", + // Browse the registry / store (read-only — needed to populate the + // connections list and the home/discovery views for any member) + "COLLECTION_REGISTRY_APP_LIST", + "COLLECTION_REGISTRY_APP_GET", + "COLLECTION_REGISTRY_APP_VERSIONS", + "COLLECTION_REGISTRY_APP_FILTERS", + "REGISTRY_ITEM_LIST", + "REGISTRY_ITEM_SEARCH", + "REGISTRY_ITEM_GET", + "REGISTRY_ITEM_VERSIONS", + "REGISTRY_ITEM_FILTERS", + "REGISTRY_DISCOVER_TOOLS", + // Chat threads — every member needs CRUD on their own threads to use + // the product. Per-thread access is scoped at the handler level. + "COLLECTION_THREADS_CREATE", + "COLLECTION_THREADS_LIST", + "COLLECTION_THREADS_GET", + "COLLECTION_THREADS_UPDATE", + "COLLECTION_THREADS_DELETE", + "COLLECTION_THREAD_MESSAGES_LIST", + ], + }, + // Organization + { + id: "org:manage", + label: "Manage organization", + description: + "Edit organization settings, brand context, and domain configuration", + section: "Organization", + tools: [ + "ORGANIZATION_GET", + "ORGANIZATION_LIST", + "ORGANIZATION_UPDATE", + "ORGANIZATION_SETTINGS_GET", + "ORGANIZATION_SETTINGS_UPDATE", + "BRAND_CONTEXT_LIST", + "BRAND_CONTEXT_GET", + "BRAND_CONTEXT_CREATE", + "BRAND_CONTEXT_UPDATE", + "BRAND_CONTEXT_DELETE", + "BRAND_CONTEXT_EXTRACT", + "BRAND_GET", + "BRAND_LIST", + "ORGANIZATION_DOMAIN_GET", + "ORGANIZATION_DOMAIN_SET", + "ORGANIZATION_DOMAIN_UPDATE", + "ORGANIZATION_DOMAIN_CLEAR", + ], + }, + { + id: "members:manage", + label: "Manage members", + description: "Invite members, remove them, and change their roles", + section: "Organization", + tools: [ + "ORGANIZATION_MEMBER_LIST", + "ORGANIZATION_MEMBER_ADD", + "ORGANIZATION_MEMBER_REMOVE", + "ORGANIZATION_MEMBER_UPDATE_ROLE", + ], + dangerous: true, + }, + // Connections + { + id: "connections:manage", + label: "Manage connections", + description: "Create, update, and delete connections", + section: "Connections & Agents", + tools: [ + "COLLECTION_CONNECTIONS_CREATE", + "COLLECTION_CONNECTIONS_UPDATE", + "COLLECTION_CONNECTIONS_DELETE", + ], + dangerous: true, + }, + { + id: "agents:manage", + label: "Manage agents", + description: "Create, configure, and delete agents", + section: "Connections & Agents", + tools: [ + "COLLECTION_VIRTUAL_MCP_CREATE", + "COLLECTION_VIRTUAL_MCP_UPDATE", + "COLLECTION_VIRTUAL_MCP_DELETE", + "VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE", + "VIRTUAL_MCP_PINNED_VIEWS_UPDATE", + ], + dangerous: true, + }, + // Automations + { + id: "automations:manage", + label: "Manage automations", + description: "Create, update, run, and delete automations", + section: "Automations", + tools: [ + "AUTOMATION_CREATE", + "AUTOMATION_UPDATE", + "AUTOMATION_DELETE", + "AUTOMATION_TRIGGER_ADD", + "AUTOMATION_TRIGGER_REMOVE", + "AUTOMATION_RUN", + ], + dangerous: true, + }, + // Monitoring + { + id: "monitoring:view", + label: "View monitoring", + description: "Access logs and usage statistics", + section: "Monitoring", + tools: ["MONITORING_LOG_GET", "MONITORING_LOGS_LIST", "MONITORING_STATS"], + }, + { + id: "threads:view-all", + label: "View other members' threads", + description: + "See threads and automation tasks created by other members. Without this, members can only see their own.", + section: "Monitoring", + tools: ["THREADS_VIEW_ALL_MEMBERS"], + }, + // Chat features + { + id: "chat:image-generation", + label: "Generate images", + description: + "Use the Create image action in the chat composer. The underlying model still has to be allowed via the Models tab.", + section: "Chat features", + tools: ["CHAT_IMAGE_GENERATION"], + }, + { + id: "chat:web-search", + label: "Use web search", + description: + "Use the Web search action in the chat composer. The underlying search model still has to be allowed via the Models tab.", + section: "Chat features", + tools: ["CHAT_WEB_SEARCH"], + }, + // AI Providers + { + id: "ai-providers:manage", + label: "Manage AI providers", + description: + "Add or remove API keys and provision provider credentials. Read-only access (which providers are configured, credits) is available to all members.", + section: "AI Providers", + tools: [ + "AI_PROVIDER_KEY_CREATE", + "AI_PROVIDER_KEY_DELETE", + "AI_PROVIDER_OAUTH_URL", + "AI_PROVIDER_OAUTH_EXCHANGE", + "AI_PROVIDER_PROVISION_KEY", + "AI_PROVIDER_TOPUP_URL", + "AI_PROVIDER_CLI_ACTIVATE", + ], + }, + // Organization (tags moved here from Developer) + { + id: "tags:manage", + label: "Manage tags", + description: "Create, assign, and delete organization tags", + section: "Organization", + tools: [ + "TAGS_LIST", + "TAGS_CREATE", + "TAGS_DELETE", + "MEMBER_TAGS_GET", + "MEMBER_TAGS_SET", + ], + }, + // Store & Registry + { + id: "registry:manage", + label: "Manage registry", + description: + "Publish and manage items in the registry. Read-only browsing is available to all members.", + section: "Store & Registry", + tools: [ + "REGISTRY_ITEM_CREATE", + "REGISTRY_ITEM_BULK_CREATE", + "REGISTRY_ITEM_UPDATE", + "REGISTRY_ITEM_DELETE", + "REGISTRY_AI_GENERATE", + "REGISTRY_PUBLISH_REQUEST_LIST", + "REGISTRY_PUBLISH_REQUEST_REVIEW", + "REGISTRY_PUBLISH_REQUEST_COUNT", + "REGISTRY_PUBLISH_REQUEST_DELETE", + "REGISTRY_PUBLISH_API_KEY_GENERATE", + "REGISTRY_PUBLISH_API_KEY_LIST", + "REGISTRY_PUBLISH_API_KEY_REVOKE", + ], + dangerous: true, + }, + { + id: "registry:monitor", + label: "Monitor registry health", + description: "Run health checks on registry connections and view results", + section: "Store & Registry", + tools: [ + "REGISTRY_MONITOR_RUN_START", + "REGISTRY_MONITOR_RUN_LIST", + "REGISTRY_MONITOR_RUN_GET", + "REGISTRY_MONITOR_RUN_CANCEL", + "REGISTRY_MONITOR_RESULT_LIST", + "REGISTRY_MONITOR_CONNECTION_LIST", + "REGISTRY_MONITOR_CONNECTION_SYNC", + "REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH", + "REGISTRY_MONITOR_SCHEDULE_SET", + "REGISTRY_MONITOR_SCHEDULE_CANCEL", + ], + }, + // Developer + { + id: "api-keys:manage", + label: "Manage API keys", + description: "Create, update, and revoke API keys", + section: "Developer", + tools: [ + "API_KEY_CREATE", + "API_KEY_LIST", + "API_KEY_UPDATE", + "API_KEY_DELETE", + ], + }, + { + id: "event-bus:use", + label: "Use event bus", + description: "Publish events and manage subscriptions", + section: "Developer", + tools: [ + "EVENT_PUBLISH", + "EVENT_SUBSCRIBE", + "EVENT_UNSUBSCRIBE", + "EVENT_CANCEL", + "EVENT_ACK", + "EVENT_SUBSCRIPTION_LIST", + "EVENT_SYNC_SUBSCRIPTIONS", + ], + }, + { + id: "storage:delete", + label: "Delete from storage", + description: "Permanently delete files from object storage", + section: "Developer", + tools: ["DELETE_OBJECT", "DELETE_OBJECTS"], + dangerous: true, + }, + { + id: "connections:sql", + label: "Run SQL queries", + description: "Execute raw SQL against connected databases", + section: "Developer", + tools: ["DATABASES_RUN_SQL"], + dangerous: true, + }, +]; - // Object Storage - LIST_OBJECTS: "List objects", - GET_OBJECT_METADATA: "Get object metadata", - GET_PRESIGNED_URL: "Generate download URL", - PUT_PRESIGNED_URL: "Generate upload URL", - DELETE_OBJECT: "Delete object", - DELETE_OBJECTS: "Delete multiple objects", +export const BASIC_USAGE_TOOLS: ReadonlySet = new Set( + PERMISSION_CAPABILITIES.find((c) => c.id === BASIC_USAGE_CAPABILITY_ID) + ?.tools ?? [], +); - // Registry - COLLECTION_REGISTRY_APP_LIST: "List registry apps", - COLLECTION_REGISTRY_APP_GET: "Get registry app", - COLLECTION_REGISTRY_APP_VERSIONS: "List registry app versions", - COLLECTION_REGISTRY_APP_FILTERS: "Get registry filters", - REGISTRY_ITEM_LIST: "List registry items", - REGISTRY_ITEM_SEARCH: "Search registry", - REGISTRY_ITEM_GET: "Get registry item", - REGISTRY_ITEM_VERSIONS: "List item versions", - REGISTRY_ITEM_CREATE: "Create registry item", - REGISTRY_ITEM_BULK_CREATE: "Bulk create items", - REGISTRY_ITEM_UPDATE: "Update registry item", - REGISTRY_ITEM_DELETE: "Delete registry item", - REGISTRY_ITEM_FILTERS: "Get item filters", - REGISTRY_DISCOVER_TOOLS: "Discover tools", - REGISTRY_AI_GENERATE: "AI generate content", - REGISTRY_PUBLISH_REQUEST_LIST: "List publish requests", - REGISTRY_PUBLISH_REQUEST_REVIEW: "Review publish request", - REGISTRY_PUBLISH_REQUEST_COUNT: "Count publish requests", - REGISTRY_PUBLISH_REQUEST_DELETE: "Delete publish request", - REGISTRY_PUBLISH_API_KEY_GENERATE: "Generate API key", - REGISTRY_PUBLISH_API_KEY_LIST: "List API keys", - REGISTRY_PUBLISH_API_KEY_REVOKE: "Revoke API key", - REGISTRY_MONITOR_RUN_START: "Start monitor run", - REGISTRY_MONITOR_RUN_LIST: "List monitor runs", - REGISTRY_MONITOR_RUN_GET: "Get monitor run", - REGISTRY_MONITOR_RUN_CANCEL: "Cancel monitor run", - REGISTRY_MONITOR_RESULT_LIST: "List monitor results", - REGISTRY_MONITOR_CONNECTION_LIST: "List monitor connections", - REGISTRY_MONITOR_CONNECTION_SYNC: "Sync monitor connections", - REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH: "Update connection auth", - REGISTRY_MONITOR_SCHEDULE_SET: "Set monitor schedule", - REGISTRY_MONITOR_SCHEDULE_CANCEL: "Cancel monitor schedule", +export function getCapabilitySections(): Array<{ + section: string; + capabilities: PermissionCapability[]; +}> { + const map = new Map(); + for (const cap of PERMISSION_CAPABILITIES) { + if (cap.id === BASIC_USAGE_CAPABILITY_ID) continue; + const arr = map.get(cap.section) ?? []; + arr.push(cap); + map.set(cap.section, arr); + } + return Array.from(map.entries()).map(([section, capabilities]) => ({ + section, + capabilities, + })); +} - // GitHub +export function isCapabilityEnabled( + cap: PermissionCapability, + enabledTools: string[], + allowAll: boolean, +): boolean { + if (allowAll) return true; + return cap.tools.every((tool) => enabledTools.includes(tool)); +} - // VM - VM_START: "Start VM preview", - VM_DELETE: "Delete VM preview", - GITHUB_LIST_USER_ORGS: "List GitHub user orgs", -}; +export function toggleCapabilityInTools( + cap: PermissionCapability, + currentTools: string[], + enable: boolean, +): string[] { + if (enable) { + const toolSet = new Set(currentTools); + for (const tool of cap.tools) toolSet.add(tool); + return Array.from(toolSet); + } + const toolSet = new Set(currentTools); + for (const tool of cap.tools) toolSet.delete(tool); + return Array.from(toolSet); +} // ============================================================================ // Exports @@ -1055,15 +1281,3 @@ export function getToolsByCategory() { return grouped; } - -/** - * Get permission options for UI components (type-safe) - * Returns flat array of all static permissions with labels - */ -export function getPermissionOptions(): PermissionOption[] { - return MANAGEMENT_TOOLS.map((tool) => ({ - value: tool.name, - label: TOOL_LABELS[tool.name], - dangerous: tool.dangerous, - })); -} diff --git a/apps/mesh/src/tools/thread/list.ts b/apps/mesh/src/tools/thread/list.ts index 4ed9d016c6..44449689bc 100644 --- a/apps/mesh/src/tools/thread/list.ts +++ b/apps/mesh/src/tools/thread/list.ts @@ -9,6 +9,7 @@ import { CollectionListInputSchema, createCollectionListOutputSchema, } from "@decocms/bindings/collections"; +import { ForbiddenError } from "../../core/access-control"; import { defineTool } from "../../core/define-tool"; import { requireOrganization } from "../../core/mesh-context"; import { normalizeThreadForResponse } from "./helpers"; @@ -48,7 +49,9 @@ const ThreadListInputSchema = CollectionListInputSchema.extend({ userId: z .string() .optional() - .describe("Filter by the user who created the thread"), + .describe( + "Filter by the user who created the thread. Members without the `THREADS_VIEW_ALL_MEMBERS` capability can only filter by their own user id; passing a different id will raise a permission error.", + ), agentId: z .string() .optional() @@ -87,10 +90,27 @@ export const COLLECTION_THREADS_LIST = defineTool({ const triggerIds = input.where?.trigger_ids; const virtualMcpId = input.where?.virtual_mcp_id; // "me" is a reserved value meaning "filter by the authenticated user" - const createdBy = + const requestedCreatedBy = input.userId ?? (input.where?.created_by === "me" ? userId : input.where?.created_by); + // Members without `threads:view-all` may only filter by their own user + // id (or omit the filter, which we then default to "self"). Asking for + // someone else's threads is a permission error rather than a silent + // override — silent overrides made it look like the API was returning + // empty results when the caller mistyped a userId. + const canViewAll = await ctx.access.has("THREADS_VIEW_ALL_MEMBERS"); + if ( + !canViewAll && + requestedCreatedBy !== undefined && + requestedCreatedBy !== userId + ) { + throw new ForbiddenError( + "You don't have permission to list other members' threads. Ask an organization admin to grant the 'View other members' threads' capability if you need it.", + ); + } + const createdBy = canViewAll ? requestedCreatedBy : userId; + const { threads, total } = triggerIds?.length ? await ctx.storage.threads.listByTriggerIds(triggerIds, { limit, diff --git a/apps/mesh/src/tools/virtual/create.ts b/apps/mesh/src/tools/virtual/create.ts index 5fb12ffd54..9d5fed6f01 100644 --- a/apps/mesh/src/tools/virtual/create.ts +++ b/apps/mesh/src/tools/virtual/create.ts @@ -14,6 +14,8 @@ import { requireOrganization, } from "../../core/mesh-context"; import { VirtualMCPCreateDataSchema, VirtualMCPEntitySchema } from "./schema"; +import { grantResourceAccessToAllCustomRoles } from "../../auth/grant-resource-access"; +import { getDb } from "../../database"; /** * Random icon+color for new agents (server-side, no React deps). * Uses the same icon:// format as the client-side agent-icon module. @@ -123,6 +125,18 @@ export const COLLECTION_VIRTUAL_MCP_CREATE = defineTool({ dataWithIcon, ); + // Auto-grant the new virtual MCP to every existing custom role. + await grantResourceAccessToAllCustomRoles( + getDb().db, + organization.id, + virtualMcp.id, + ).catch((err) => { + console.error( + "[virtual-mcp.create] Failed to auto-grant new agent to roles", + err, + ); + }); + // Return virtual MCP entity directly (already in correct format) return { item: virtualMcp, diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index b14a960758..db3fe6264f 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -60,6 +60,7 @@ import { getProviderLogo } from "@/web/utils/ai-providers-logos"; import { useNavigate } from "@tanstack/react-router"; import { useProjectContext } from "@decocms/mesh-sdk"; import { NoAiProviderEmptyState } from "./no-ai-provider-empty-state"; +import { useCapability } from "@/web/hooks/use-capability"; function parseModelTitle(model: { title: string; modelId: string }): { provider: string; @@ -1113,6 +1114,7 @@ function ModelSelectorInner({ const searchInputRef = useRef(null); const aiProviders = useAiProviders(); const keys = useAiProviderKeys(); + const { granted: canManageProviders } = useCapability("ai-providers:manage"); const providerMap = Object.fromEntries( (aiProviders?.providers ?? []).map((p) => [p.id, p]), @@ -1250,7 +1252,7 @@ function ModelSelectorInner({ /> - {!managing && ( + {!managing && canManageProviders && (
)} - - + {onConfigure && ( + + )} + {onDelete && ( + + )}
); @@ -93,7 +99,7 @@ function InstanceItemFallback({ onConfigure, }: { instance: ConnectionEntity; - onConfigure: (instance: ConnectionEntity) => void; + onConfigure?: (instance: ConnectionEntity) => void; }) { return (
@@ -107,15 +113,17 @@ function InstanceItemFallback({

{instance.title}

- + {onConfigure && ( + + )}
); @@ -128,6 +136,7 @@ export function ConnectionInstancesPanel({ onDelete, onAdd, isAdding, + canManage = true, }: ConnectionInstancesPanelProps) { if (instances.length === 0) return null; return ( @@ -136,20 +145,22 @@ export function ConnectionInstancesPanel({

{instances.length === 1 ? "Instance" : "Instances"}

- + {canManage && ( + + )}
{instances.map((instance) => ( diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 167b9aca46..7bad42984f 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -58,6 +58,16 @@ import { toast } from "sonner"; import { DeleteConnectionDialogs } from "@/web/components/delete-connection-dialogs"; import { useDeleteConnection } from "@/web/hooks/use-delete-connection"; +import { + NO_PERMISSION_TOOLTIP, + useCapability, +} from "@/web/hooks/use-capability"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; import { ViewLayout } from "../layout"; import { ConnectionActivity } from "./connection-activity.tsx"; import { ConnectionAgentsPanel } from "./connection-agents-panel.tsx"; @@ -241,6 +251,7 @@ function ConnectionInspectorViewWithConnection({ const navigate = useNavigate({ from: "/$org/settings/connections/$appSlug" }); const queryClient = useQueryClient(); const connectionActions = useConnectionActions(); + const { granted: canManageConnections } = useCapability("connections:manage"); const deleteConnection = useDeleteConnection({ onSuccess: () => { if (siblings.length <= 1) { @@ -496,30 +507,47 @@ function ConnectionInspectorViewWithConnection({ />
- + {canManageConnections ? ( + + ) : ( + + + + + + + + {NO_PERMISSION_TOOLTIP} + + + )} {hasAnyChanges && ( )} - + {canManageConnections && ( + + )}
@@ -545,9 +573,18 @@ function ConnectionInspectorViewWithConnection({
setConfigureInstance(inst)} + onConfigure={ + canManageConnections + ? (inst) => setConfigureInstance(inst) + : undefined + } onAuthenticate={(inst) => handleAuthenticateForId(inst.id)} - onDelete={(inst) => deleteConnection.requestDelete(inst)} + onDelete={ + canManageConnections + ? (inst) => deleteConnection.requestDelete(inst) + : undefined + } + canManage={canManageConnections} isAdding={isAddingInstance} onAdd={async () => { setIsAddingInstance(true); diff --git a/apps/mesh/src/web/components/error-boundary.tsx b/apps/mesh/src/web/components/error-boundary.tsx index 2025248cdf..514e09fe09 100644 --- a/apps/mesh/src/web/components/error-boundary.tsx +++ b/apps/mesh/src/web/components/error-boundary.tsx @@ -1,7 +1,8 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { Button } from "@deco/ui/components/button.tsx"; -import { AlertTriangle, RefreshCw01 } from "@untitledui/icons"; +import { AlertTriangle, Lock01, RefreshCw01 } from "@untitledui/icons"; import { captureException } from "@/web/lib/posthog-client"; +import { detectAccessDenied } from "@/web/lib/access-denied"; const CHUNK_RELOAD_KEY = "__mesh_chunk_reload_ts"; @@ -87,6 +88,33 @@ export class ErrorBoundary extends Component { return fallback; } + // Friendlier fallback for access-denied errors — no scary + // "Something went wrong" + raw MCP -32603 message. + const denied = detectAccessDenied(this.state.error); + if (denied) { + return ( +
+
+ +
+
+

+ {denied.resource + ? `No access to ${denied.resource}` + : "No access"} +

+

+ Your role doesn't include permission for this. Ask an + organization admin to update your role if you need it. +

+
+ +
+ ); + } + // Default fallback UI return (
@@ -167,6 +195,34 @@ export class ChunkErrorBoundary extends Component< } if (this.state.hasError) { + const denied = detectAccessDenied(this.state.error); + if (denied) { + return ( +
+
+ +
+
+

+ {denied.resource + ? `No access to ${denied.resource}` + : "No access"} +

+

+ Your role doesn't include permission for this. Ask an + organization admin to update your role if you need it. +

+
+ +
+ ); + } + return (
diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx index 9e01beda36..37fba79f8a 100644 --- a/apps/mesh/src/web/components/home/agents-list.tsx +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -17,6 +17,7 @@ import { useVirtualMCPs, } from "@decocms/mesh-sdk"; import type { ProjectLocator, VirtualMCPEntity } from "@decocms/mesh-sdk"; +import { useCapability } from "@/web/hooks/use-capability"; import { useDefaultHomeAgents } from "@/web/hooks/use-organization-settings"; function readRecentAgentIds(locator: ProjectLocator): string[] { @@ -158,10 +159,13 @@ function SeeAllButton() { * Agents list content component */ function CreateAgentButton() { + const { granted: canManageAgents } = useCapability("agents:manage"); const { createVirtualMCP, isCreating } = useCreateVirtualMCP({ navigateOnCreate: true, }); + if (!canManageAgents) return null; + return ( + } + /> +
+ ); +} diff --git a/apps/mesh/src/web/components/permission-tooltip.tsx b/apps/mesh/src/web/components/permission-tooltip.tsx new file mode 100644 index 0000000000..0f4ca217d9 --- /dev/null +++ b/apps/mesh/src/web/components/permission-tooltip.tsx @@ -0,0 +1,102 @@ +/** + * PermissionTooltip + * + * Wraps an action (button, menu item, etc.) so that when the user lacks the + * required capability the trigger is disabled and explains why on hover. + * + * Usage: + * + * {({ disabled, tooltip }) => ( + * + * )} + * + * + * Or for direct wrapping: + * + * + * + */ + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { cloneElement, isValidElement, type ReactElement } from "react"; +import { + NO_PERMISSION_TOOLTIP, + useCapability, + type CapabilityId, +} from "@/web/hooks/use-capability"; + +interface PermissionTooltipProps { + capability: CapabilityId; + children: ReactElement<{ disabled?: boolean; "aria-disabled"?: boolean }>; + message?: string; + /** + * Show tooltip even when allowed (useful when wrapping in a list to keep + * markup stable). Defaults to false — no tooltip shown if user has access. + */ + alwaysWrap?: boolean; +} + +/** + * Wraps a single interactive element. When the user lacks the capability the + * child is forcibly disabled and a tooltip explains why. + */ +export function PermissionTooltip({ + capability, + children, + message = NO_PERMISSION_TOOLTIP, + alwaysWrap = false, +}: PermissionTooltipProps) { + const { granted, loading } = useCapability(capability); + + if (granted || loading) { + if (!alwaysWrap) return children; + return children; + } + + if (!isValidElement(children)) return children; + + const disabled = cloneElement(children, { + disabled: true, + "aria-disabled": true, + }); + + return ( + + + + {disabled} + + {message} + + + ); +} + +interface PermissionGateProps { + capability: CapabilityId; + children: (state: { + granted: boolean; + loading: boolean; + disabled: boolean; + tooltip: string; + }) => ReactElement | null; +} + +/** + * Render-prop variant. Use when the disabled state needs to flow into a more + * complex JSX tree than wrapping a single element. + */ +export function PermissionGate({ capability, children }: PermissionGateProps) { + const { granted, loading } = useCapability(capability); + return children({ + granted, + loading, + disabled: !granted && !loading, + tooltip: NO_PERMISSION_TOOLTIP, + }); +} diff --git a/apps/mesh/src/web/components/require-capability.tsx b/apps/mesh/src/web/components/require-capability.tsx new file mode 100644 index 0000000000..11d55f66b1 --- /dev/null +++ b/apps/mesh/src/web/components/require-capability.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import { Loading01 } from "@untitledui/icons"; +import { useCapability, type CapabilityId } from "@/web/hooks/use-capability"; +import { NoPermissionState } from "@/web/components/no-permission-state"; + +interface RequireCapabilityProps { + capability: CapabilityId; + /** Friendly label of the section, used in the no-permission heading. */ + area?: string; + children: ReactNode; +} + +/** + * Route-level guard. Renders children when the current user has the capability, + * a clean no-permission empty state otherwise. While the capability resolves + * we render a small spinner so the page doesn't flicker. + */ +export function RequireCapability({ + capability, + area, + children, +}: RequireCapabilityProps) { + const { granted, loading } = useCapability(capability); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!granted) { + return ; + } + + return <>{children}; +} diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 2ceab43c7e..4b99e4ae1d 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -69,6 +69,7 @@ import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { AgentAvatar } from "@/web/components/agent-icon"; import { GitHubIcon } from "@/web/components/icons/github-icon"; import { usePreferences } from "@/web/hooks/use-preferences.ts"; +import { useCapability } from "@/web/hooks/use-capability"; import { cn } from "@deco/ui/lib/utils.ts"; import { ImportFromDecoDialog } from "@/web/components/import-from-deco-dialog.tsx"; import { GitHubRepoPicker } from "@/web/components/github-repo-picker.tsx"; @@ -366,6 +367,7 @@ function PinAgentPopoverContent({ navigateOnCreate: true, }); const [preferences] = usePreferences(); + const { granted: canManageAgents } = useCapability("agents:manage"); const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org.slug); const navigateToAgent = useNavigateToAgent(); @@ -496,26 +498,28 @@ function PinAgentPopoverContent({
- {/* Create new button */} - + {/* Create new button — gated on agents:manage */} + {canManageAgents && ( + + )} - {preferences.experimental_vibecode && ( + {canManageAgents && preferences.experimental_vibecode && (
- } - > - - + + + +
+ } + > + + + ); } const agentTab = layoutTabs.find((t) => t.id === activeTab); if (agentTab) { return ( - - -
- } - > - - + + + + + } + > + + + ); } diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/settings-tab.tsx b/apps/mesh/src/web/layouts/main-panel-tabs/settings-tab.tsx index 5e563f8686..2f327ad91c 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/settings-tab.tsx +++ b/apps/mesh/src/web/layouts/main-panel-tabs/settings-tab.tsx @@ -1,5 +1,14 @@ import { VirtualMcpDetailView } from "@/web/views/virtual-mcp"; +import { useCapability } from "@/web/hooks/use-capability"; +import { NoPermissionState } from "@/web/components/no-permission-state"; export function SettingsTab({ virtualMcpId }: { virtualMcpId: string }) { + const { granted, loading } = useCapability("agents:manage"); + + if (loading) return null; + if (!granted) { + return ; + } + return ; } diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts index 48f7623e73..c0209e9a74 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -34,6 +34,7 @@ import { type AutomationTabParsed, } from "./tab-id"; import { resolveTabIcon, type TabIcon, type TabKind } from "./resolve-tab-icon"; +import { useCapability } from "@/web/hooks/use-capability"; export type AgentTabDef = { id: string; @@ -131,6 +132,8 @@ export function useMainPanelTabs(ctx: { const expandedTools: ThreadExpandedTool[] = metadata?.expanded_tools ?? []; const hasActiveGithubRepo = !!(entity && getActiveGithubRepo(entity)); const connections = useConnections({ includeVirtual: true }); + const { granted: canManageAgents } = useCapability("agents:manage"); + const { granted: canManageAutomations } = useCapability("automations:manage"); const { activeTab, mainOpen } = resolveActiveTabAndOpen({ mainParam: search.main, @@ -154,8 +157,15 @@ export function useMainPanelTabs(ctx: { systemTabs.push({ id: "env", title: "Terminal" }); systemTabs.push({ id: "git", title: currentBranch ?? "git" }); } - systemTabs.push({ id: "settings", title: "Settings" }); - systemTabs.push({ id: "automations", title: "Automations" }); + // Settings tab is hidden when the user can't manage agents — there's + // nothing they could change inside it. + if (canManageAgents) { + systemTabs.push({ id: "settings", title: "Settings" }); + } + // Automations tab requires automations:manage to do anything useful. + if (canManageAutomations) { + systemTabs.push({ id: "automations", title: "Automations" }); + } // Merge pinned views + per-task expanded tools into a single list keyed // by the pinned-view tab id. Pinned views win on dedupe so the diff --git a/apps/mesh/src/web/layouts/settings-layout.tsx b/apps/mesh/src/web/layouts/settings-layout.tsx index 342884b323..bc17134f24 100644 --- a/apps/mesh/src/web/layouts/settings-layout.tsx +++ b/apps/mesh/src/web/layouts/settings-layout.tsx @@ -46,12 +46,18 @@ import { pluginSettingsSidebarItems } from "@/web/index"; import { useStatusSounds } from "../hooks/use-status-sounds"; import { authClient } from "@/web/lib/auth-client"; import { track } from "@/web/lib/posthog-client"; +import { useCapabilities, type CapabilityId } from "@/web/hooks/use-capability"; interface SettingsNavItem { key: string; label: string; icon: React.ReactNode; to: string; + /** + * If set, the item is only shown when the current user has the capability. + * Items with no requirement are visible to everyone. + */ + requires?: CapabilityId; } interface SettingsNavGroup { @@ -62,12 +68,13 @@ interface SettingsNavGroup { function useSettingsSidebarGroups(): SettingsNavGroup[] { const currentProject = useProjectContext().project; const enabledPlugins = currentProject.enabledPlugins ?? []; + const { capabilities, loading } = useCapabilities(); const enabledSettingsItems = pluginSettingsSidebarItems .filter((item) => enabledPlugins.includes(item.pluginId)) .map(({ key, label, icon, to }) => ({ key, label, icon, to })); - const groups: SettingsNavGroup[] = [ + const allGroups: SettingsNavGroup[] = [ { label: "Organization", items: [ @@ -76,18 +83,21 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { label: "General", icon: , to: "/$org/settings/general", + requires: "org:manage", }, { key: "brand-context", label: "Brand Context", icon: , to: "/$org/settings/brand-context", + requires: "org:manage", }, { key: "ai-providers", label: "AI Providers", icon: , to: "/$org/settings/ai-providers", + requires: "ai-providers:manage", }, ], }, @@ -111,12 +121,14 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { label: "Automations", icon: , to: "/$org/settings/automations", + requires: "automations:manage", }, { key: "store", label: "Store", icon: , to: "/$org/settings/store", + requires: "registry:manage", }, ], }, @@ -128,24 +140,28 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { label: "Monitor", icon: , to: "/$org/settings/monitor", + requires: "monitoring:view", }, { key: "members", label: "Members", icon: , to: "/$org/settings/members", + requires: "members:manage", }, { key: "roles", label: "Roles", icon: , to: "/$org/settings/roles", + requires: "members:manage", }, { key: "sso", label: "Security", icon: , to: "/$org/settings/sso", + requires: "org:manage", }, ], }, @@ -157,6 +173,7 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { label: "Plugins", icon: , to: "/$org/settings/features", + requires: "org:manage", }, ...enabledSettingsItems, ], @@ -174,7 +191,18 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { }, ]; - return groups; + // While capabilities are loading, show everything so we don't flash empty + // groups; permission checks still gate actual actions inside each page. + if (loading) return allGroups; + + return allGroups + .map((group) => ({ + ...group, + items: group.items.filter( + (item) => !item.requires || capabilities[item.requires], + ), + })) + .filter((group) => group.items.length > 0); } export function SettingsSidebar() { diff --git a/apps/mesh/src/web/layouts/tasks-panel/tasks-section.tsx b/apps/mesh/src/web/layouts/tasks-panel/tasks-section.tsx index bd8256320e..c473ca831b 100644 --- a/apps/mesh/src/web/layouts/tasks-panel/tasks-section.tsx +++ b/apps/mesh/src/web/layouts/tasks-panel/tasks-section.tsx @@ -11,6 +11,7 @@ import { cn } from "@deco/ui/lib/utils.js"; import type { Task } from "@/web/components/chat/task/types"; import { TaskRow } from "./task-row"; import { track } from "@/web/lib/posthog-client"; +import { useCapability } from "@/web/hooks/use-capability"; type FilterOption = "all" | "manual" | "automation"; type MemberFilter = "all" | "mine"; @@ -51,9 +52,16 @@ export function TasksSection({ }) { const [filter, setFilter] = useState("all"); const [memberFilter, setMemberFilter] = useState("mine"); + const { granted: canViewAllThreads } = useCapability("threads:view-all"); + + // Members without threads:view-all are pinned to "mine" — server enforces + // the same scope, so the All members option is redundant for them. + const effectiveMemberFilter: MemberFilter = canViewAllThreads + ? memberFilter + : "mine"; const memberFiltered = - memberFilter === "mine" && currentUserId + effectiveMemberFilter === "mine" && currentUserId ? tasks.filter((t) => t.created_by === currentUserId) : tasks; @@ -69,43 +77,45 @@ export function TasksSection({
{title}
- - - - - - { - const next = v as MemberFilter; - if (next !== memberFilter) { - track("tasks_panel_member_filter_changed", { - to_value: next, - }); - } - setMemberFilter(next); - }} - > - {(Object.keys(MEMBER_FILTER_LABELS) as MemberFilter[]).map( - (opt) => ( - - {MEMBER_FILTER_LABELS[opt]} - - ), - )} - - - + {canViewAllThreads && ( + + + + + + { + const next = v as MemberFilter; + if (next !== memberFilter) { + track("tasks_panel_member_filter_changed", { + to_value: next, + }); + } + setMemberFilter(next); + }} + > + {(Object.keys(MEMBER_FILTER_LABELS) as MemberFilter[]).map( + (opt) => ( + + {MEMBER_FILTER_LABELS[opt]} + + ), + )} + + + + )} - - { - track("agent_create_clicked", { - source: "agents_list", - method: "scratch", - }); - createVirtualMCP(); - }} - onImportGitHub={() => { - track("agent_create_clicked", { - source: "agents_list", - method: "github", - }); - setGithubPickerOpen(true); - }} - onImportDeco={() => { - track("agent_create_clicked", { - source: "agents_list", - method: "deco", - }); - setImportDecoOpen(true); - }} - isCreating={isCreating} - align="end" - /> - + {canManageAgents ? ( + + + + + { + track("agent_create_clicked", { + source: "agents_list", + method: "scratch", + }); + createVirtualMCP(); + }} + onImportGitHub={() => { + track("agent_create_clicked", { + source: "agents_list", + method: "github", + }); + setGithubPickerOpen(true); + }} + onImportDeco={() => { + track("agent_create_clicked", { + source: "agents_list", + method: "deco", + }); + setImportDecoOpen(true); + }} + isCreating={isCreating} + align="end" + /> + + ) : ( + + + + + + + + {NO_PERMISSION_TOOLTIP} + + + )}
@@ -194,7 +221,8 @@ export default function AgentsListPage() { : "Create an agent to get started." } actions={ - !search && ( + !search && + canManageAgents && ( )} - - - - + {canManageAgents && ( + + )} + {canManage && ( + <> + + + + + )} + + + {NO_PERMISSION_TOOLTIP} + + )} } @@ -674,6 +717,8 @@ function ConnectionResults({ const queryClient = useQueryClient(); const navigate = useNavigate(); const { data: session } = authClient.useSession(); + const { granted: canManageConnections } = useCapability("connections:manage"); + const { granted: canManageAgents } = useCapability("agents:manage"); const actions = useConnectionActions(); const connections = useConnections(listState); @@ -1159,16 +1204,18 @@ function ConnectionResults({ Select - { - e.stopPropagation(); - deleteConnection.requestDelete(connection); - }} - > - - Delete - + {canManageConnections && ( + { + e.stopPropagation(); + deleteConnection.requestDelete(connection); + }} + > + + Delete + + )} @@ -1185,6 +1232,7 @@ function ConnectionResults({ allConnections={connections} connectedAppNames={connectedAppNames} connectingItemId={connectingItemId} + canManage={canManageConnections} onNavigateConnected={(conn) => navigate({ to: "/$org/settings/connections/$appSlug", @@ -1228,6 +1276,8 @@ function ConnectionResults({ onAddToAgent={() => setAddToAgentOpen(true)} onToggleStatus={handleBulkToggleStatus} onCancel={exitSelectionMode} + canManage={canManageConnections} + canManageAgents={canManageAgents} /> )} @@ -1244,6 +1294,7 @@ function OrgMcpsContent() { const { data: session } = authClient.useSession(); const { stdioEnabled } = useAuthConfig(); const isMobile = useIsMobile(); + const { granted: canManageConnections } = useCapability("connections:manage"); // Consolidated list UI state (search, filters, sorting, view mode) const listState = useListState({ @@ -1513,10 +1564,26 @@ function OrgMcpsContent() { const ctaButton = (
- + {canManageConnections ? ( + + ) : ( + + + + + + + + {NO_PERMISSION_TOOLTIP} + + + )}
); diff --git a/apps/mesh/src/web/routes/orgs/monitoring/index.tsx b/apps/mesh/src/web/routes/orgs/monitoring/index.tsx index 2c25a42b8c..43c265d208 100644 --- a/apps/mesh/src/web/routes/orgs/monitoring/index.tsx +++ b/apps/mesh/src/web/routes/orgs/monitoring/index.tsx @@ -8,6 +8,7 @@ import { SearchInput } from "@deco/ui/components/search-input.tsx"; import { Page } from "@/web/components/page"; import { EmptyState } from "@/web/components/empty-state.tsx"; import { ErrorBoundary } from "@/web/components/error-boundary"; +import { RequireCapability } from "@/web/components/require-capability"; import { MONITORING_CONFIG } from "@/web/components/monitoring/config.ts"; import type { DateRange } from "@/web/components/monitoring/monitoring-stats-row.tsx"; import { @@ -816,7 +817,15 @@ function MonitoringDashboardContent({ // Route Entry Point // ============================================================================ -export default function MonitoringDashboard() { +export default function MonitoringDashboardRoute() { + return ( + + + + ); +} + +function MonitoringDashboard() { const { org } = useProjectContext(); const navigate = useNavigate(); const search = useSearch({ diff --git a/apps/mesh/src/web/routes/orgs/settings/ai-providers.tsx b/apps/mesh/src/web/routes/orgs/settings/ai-providers.tsx index 2b3cf265d2..6073d14020 100644 --- a/apps/mesh/src/web/routes/orgs/settings/ai-providers.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/ai-providers.tsx @@ -1,5 +1,10 @@ import { OrgAiProvidersPage } from "@/web/views/settings/org-ai-providers"; +import { RequireCapability } from "@/web/components/require-capability"; export default function AiProvidersRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/automations.tsx b/apps/mesh/src/web/routes/orgs/settings/automations.tsx index 1cbbfc8899..9850d81ad8 100644 --- a/apps/mesh/src/web/routes/orgs/settings/automations.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/automations.tsx @@ -10,8 +10,17 @@ import { AutomationListRow } from "@/web/views/automations/automation-list-row"; import { useVirtualMCPs, useProjectContext } from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; import { track } from "@/web/lib/posthog-client"; +import { RequireCapability } from "@/web/components/require-capability"; export default function SettingsAutomationsPage() { + return ( + + + + ); +} + +function SettingsAutomationsPageInner() { const { org } = useProjectContext(); const { data: automations = [] } = useAutomations(undefined); const agents = useVirtualMCPs(); diff --git a/apps/mesh/src/web/routes/orgs/settings/brand-context.tsx b/apps/mesh/src/web/routes/orgs/settings/brand-context.tsx index ccced18bec..7faf841693 100644 --- a/apps/mesh/src/web/routes/orgs/settings/brand-context.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/brand-context.tsx @@ -1,5 +1,10 @@ import { OrgBrandContextPage } from "@/web/views/settings/org-brand-context"; +import { RequireCapability } from "@/web/components/require-capability"; export default function BrandContextRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/features.tsx b/apps/mesh/src/web/routes/orgs/settings/features.tsx index 0e4c7d6a05..196dffa0a7 100644 --- a/apps/mesh/src/web/routes/orgs/settings/features.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/features.tsx @@ -1,5 +1,10 @@ import { ProjectPluginsPage } from "@/web/views/settings/project-plugins"; +import { RequireCapability } from "@/web/components/require-capability"; export default function FeaturesRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/general.tsx b/apps/mesh/src/web/routes/orgs/settings/general.tsx index 7178fb4ff3..0684ca6fd4 100644 --- a/apps/mesh/src/web/routes/orgs/settings/general.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/general.tsx @@ -1,5 +1,10 @@ import { OrgGeneralPage } from "@/web/views/settings/org-general"; +import { RequireCapability } from "@/web/components/require-capability"; export default function GeneralRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/index-redirect.tsx b/apps/mesh/src/web/routes/orgs/settings/index-redirect.tsx new file mode 100644 index 0000000000..db9eee5c82 --- /dev/null +++ b/apps/mesh/src/web/routes/orgs/settings/index-redirect.tsx @@ -0,0 +1,33 @@ +import { Navigate, useParams } from "@tanstack/react-router"; +import { Loading01 } from "@untitledui/icons"; +import { useCapabilities } from "@/web/hooks/use-capability"; + +/** + * Settings index — picks the first settings page the current user can access. + * + * Priority order: + * 1. General (requires org:manage) + * 2. Connections (basic-usage, everyone) + * 3. Profile (always available, ultimate fallback) + * + * This avoids dumping users without org:manage onto a "No access" screen + * just because they clicked the Settings shortcut. + */ +export default function SettingsIndexRedirect() { + const { org } = useParams({ from: "/shell/$org" }); + const { capabilities, loading } = useCapabilities(); + + if (loading) { + return ( +
+ +
+ ); + } + + const target = capabilities["org:manage"] + ? "/$org/settings/general" + : "/$org/settings/connections"; + + return ; +} diff --git a/apps/mesh/src/web/routes/orgs/settings/members.tsx b/apps/mesh/src/web/routes/orgs/settings/members.tsx index 148b80075f..1756bf6477 100644 --- a/apps/mesh/src/web/routes/orgs/settings/members.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/members.tsx @@ -1 +1,10 @@ -export { default } from "@/web/routes/orgs/members"; +import OrgMembers from "@/web/routes/orgs/members"; +import { RequireCapability } from "@/web/components/require-capability"; + +export default function MembersRoute() { + return ( + + + + ); +} diff --git a/apps/mesh/src/web/routes/orgs/settings/registry.tsx b/apps/mesh/src/web/routes/orgs/settings/registry.tsx index 06a6c83e95..29b74ca571 100644 --- a/apps/mesh/src/web/routes/orgs/settings/registry.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/registry.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from "react"; import { Loading01 } from "@untitledui/icons"; +import { RequireCapability } from "@/web/components/require-capability"; const RegistryLayout = lazy( () => import("@/web/views/registry/registry-layout"), @@ -7,14 +8,19 @@ const RegistryLayout = lazy( export default function SettingsRegistryPage() { return ( - - - - } - > - - + + + + + } + > + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/roles.tsx b/apps/mesh/src/web/routes/orgs/settings/roles.tsx index 4717e7f3ff..1e2e6a50b1 100644 --- a/apps/mesh/src/web/routes/orgs/settings/roles.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/roles.tsx @@ -41,6 +41,7 @@ import type { TableColumn } from "@/web/components/collections/collection-table. import { Page } from "@/web/components/page"; import { ErrorBoundary } from "@/web/components/error-boundary"; import { EmptyState } from "@/web/components/empty-state.tsx"; +import { RequireCapability } from "@/web/components/require-capability"; import { SearchInput } from "@deco/ui/components/search-input.tsx"; import { RoleDetailPage, @@ -126,7 +127,11 @@ function RolesPageContent() { const { locator } = useProjectContext(); const queryClient = useQueryClient(); - const { customRoles, refetch: refetchRoles } = useOrganizationRoles(); + const { + customRoles, + builtinOverrides, + refetch: refetchRoles, + } = useOrganizationRoles(); const setActiveRole = (value: string | undefined) => navigate({ @@ -139,7 +144,13 @@ function RolesPageContent() { if (roleParam === "new") return { kind: "new" }; if (roleParam.startsWith("builtin-")) { const slug = roleParam.slice(8) as "owner" | "admin" | "user"; - return { kind: "builtin", role: slug }; + const override = builtinOverrides[slug]; + return { + kind: "builtin", + role: slug, + storedId: override?.id, + storedPermission: override?.permission, + }; } const custom = customRoles.find((r) => r.id === roleParam); return custom ? { kind: "custom", role: custom } : null; @@ -427,31 +438,33 @@ function RolesPageContent() { export default function RolesPage() { return ( - -
-
- Failed to load roles -
-
- - } - > - +
- +
+ Failed to load roles +
} > - -
-
+ +
+ +
+ + } + > + +
+ + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/sso.tsx b/apps/mesh/src/web/routes/orgs/settings/sso.tsx index e74c356664..22131915e2 100644 --- a/apps/mesh/src/web/routes/orgs/settings/sso.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/sso.tsx @@ -1,5 +1,10 @@ import { OrgSsoPage } from "@/web/views/settings/org-sso"; +import { RequireCapability } from "@/web/components/require-capability"; export default function SsoRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/store-registry.tsx b/apps/mesh/src/web/routes/orgs/settings/store-registry.tsx index d187027266..e83e634b8c 100644 --- a/apps/mesh/src/web/routes/orgs/settings/store-registry.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/store-registry.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; import { useNavigate, useParams } from "@tanstack/react-router"; import { Loading01 } from "@untitledui/icons"; +import { RequireCapability } from "@/web/components/require-capability"; const RegistryLayout = lazy( () => import("@/web/views/registry/registry-layout"), @@ -11,16 +12,23 @@ export default function StoreRegistryPage() { const { org } = useParams({ from: "/shell/$org" }); return ( - - - - } - > - navigate({ to: "/$org/settings/store", params: { org } })} - /> - + + + + + } + > + + navigate({ to: "/$org/settings/store", params: { org } }) + } + /> + + ); } diff --git a/apps/mesh/src/web/routes/orgs/settings/store.tsx b/apps/mesh/src/web/routes/orgs/settings/store.tsx index 604928496c..cbe7245fe9 100644 --- a/apps/mesh/src/web/routes/orgs/settings/store.tsx +++ b/apps/mesh/src/web/routes/orgs/settings/store.tsx @@ -1,5 +1,10 @@ import { OrgStorePage } from "@/web/views/settings/org-store"; +import { RequireCapability } from "@/web/components/require-capability"; export default function StoreRoute() { - return ; + return ( + + + + ); } diff --git a/apps/mesh/src/web/views/automations/automation-list-row.tsx b/apps/mesh/src/web/views/automations/automation-list-row.tsx index 13e9779b52..35e7cfaf23 100644 --- a/apps/mesh/src/web/views/automations/automation-list-row.tsx +++ b/apps/mesh/src/web/views/automations/automation-list-row.tsx @@ -24,6 +24,7 @@ import { useAutomationActions, type AutomationListItem, } from "@/web/hooks/use-automations"; +import { useCapability } from "@/web/hooks/use-capability"; export function AutomationListRow({ automation, @@ -36,6 +37,7 @@ export function AutomationListRow({ }) { const { remove } = useAutomationActions(); const [confirmOpen, setConfirmOpen] = useState(false); + const { granted: canManageAutomations } = useCapability("automations:manage"); const agent = useVirtualMCP( showAgent ? (automation.agent?.id ?? undefined) : undefined, ); @@ -104,36 +106,38 @@ export function AutomationListRow({ /> -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - className="shrink-0" - > - - - - - - { - e.stopPropagation(); - setConfirmOpen(true); - }} - > - - Delete - - - -
+ {canManageAutomations && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + className="shrink-0" + > + + + + + + { + e.stopPropagation(); + setConfirmOpen(true); + }} + > + + Delete + + + +
+ )} diff --git a/apps/mesh/src/web/views/automations/automations-list.tsx b/apps/mesh/src/web/views/automations/automations-list.tsx index 4c580fce63..525da93416 100644 --- a/apps/mesh/src/web/views/automations/automations-list.tsx +++ b/apps/mesh/src/web/views/automations/automations-list.tsx @@ -12,12 +12,23 @@ import { } from "@/web/hooks/use-automations"; import { AutomationListRow } from "./automation-list-row"; import { track } from "@/web/lib/posthog-client"; +import { + NO_PERMISSION_TOOLTIP, + useCapability, +} from "@/web/hooks/use-capability"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; export function AutomationsList({ virtualMcpId }: { virtualMcpId: string }) { const navigate = useNavigate(); const { data: automations = [] } = useAutomations(virtualMcpId); const { create } = useAutomationActions(); const [search, setSearch] = useState(""); + const { granted: canManageAutomations } = useCapability("automations:manage"); const lowerSearch = search.toLowerCase(); const filtered = automations.filter((a) => @@ -61,10 +72,30 @@ export function AutomationsList({ virtualMcpId }: { virtualMcpId: string }) { className="w-full md:w-[375px]" /> )} - + {canManageAutomations ? ( + + ) : ( + + + + + + + + {NO_PERMISSION_TOOLTIP} + + + )} @@ -75,14 +106,16 @@ export function AutomationsList({ virtualMcpId }: { virtualMcpId: string }) { title="No automations yet" description="Create your first automation to run this agent on a schedule or in response to events." actions={ - + canManageAutomations && ( + + ) } /> diff --git a/apps/mesh/src/web/views/settings/org-ai-providers.tsx b/apps/mesh/src/web/views/settings/org-ai-providers.tsx index c1a11c8adc..413df58704 100644 --- a/apps/mesh/src/web/views/settings/org-ai-providers.tsx +++ b/apps/mesh/src/web/views/settings/org-ai-providers.tsx @@ -72,6 +72,13 @@ import { } from "@/web/hooks/use-organization-settings"; import { SimpleModeConfigSchema } from "@/tools/organization/schema"; import { ModelSelector } from "@/web/components/chat/select-model"; +import { useCapability } from "@/web/hooks/use-capability"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; function ErrorFallback({ error }: { error: Error }) { return ( @@ -88,10 +95,12 @@ function KeyList({ keys, onDelete, isDeleting, + canManage, }: { keys: AiProviderKey[]; onDelete: (keyId: string) => void; isDeleting: boolean; + canManage: boolean; }) { const [deleteTarget, setDeleteTarget] = useState(null); const targetKey = keys.find((k) => k.id === deleteTarget); @@ -110,18 +119,19 @@ function KeyList({ added {formatDistanceToNow(new Date(key.createdAt))} ago - {/* Stop propagation so trash click doesn't trigger card's onClick */} -
e.stopPropagation()}> - -
+ {canManage && ( +
e.stopPropagation()}> + +
+ )} ))} @@ -430,6 +440,7 @@ function ProviderCard({ keys: AiProviderKey[]; }) { const { org } = useProjectContext(); + const { granted: canManageProviders } = useCapability("ai-providers:manage"); const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, orgId: org.id, @@ -733,23 +744,47 @@ function ProviderCard({ } description={statusText} onClick={ - !isOAuthPending && !isActivating && !isProvisioning + canManageProviders && + !isOAuthPending && + !isActivating && + !isProvisioning ? handleCardClick : undefined } className={cn( (isOAuthPending || isActivating || isProvisioning) && "cursor-wait", + !canManageProviders && "cursor-not-allowed opacity-90", )} action={
{isActive && (
)} + {!canManageProviders && !isActive && ( + + + + + Not configured + + + + Ask an organization admin to add an API key for this + provider. + + + + )}
} > {isActive && !isCliActivate && ( - + )} @@ -983,6 +1018,7 @@ function creditColorClass(dollars: number): string { function DecoCreditsHero() { const { org } = useProjectContext(); + const { granted: canManageProviders } = useCapability("ai-providers:manage"); const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, orgId: org.id, @@ -1123,12 +1159,14 @@ function DecoCreditsHero() {
{/* Quick top-up */} -
-

- Add credits -

- -
+ {canManageProviders && ( +
+

+ Add credits +

+ +
+ )} diff --git a/apps/mesh/src/web/views/settings/org-role-detail.tsx b/apps/mesh/src/web/views/settings/org-role-detail.tsx index 3acff4edce..f5eb7cc5e2 100644 --- a/apps/mesh/src/web/views/settings/org-role-detail.tsx +++ b/apps/mesh/src/web/views/settings/org-role-detail.tsx @@ -1,7 +1,8 @@ import { - getPermissionOptions, - getToolsByCategory, - type ToolName, + getCapabilitySections, + isCapabilityEnabled, + toggleCapabilityInTools, + type PermissionCapability, } from "@/tools/registry-metadata"; import { DEFAULT_LOGO, PROVIDER_LOGOS } from "@/web/utils/ai-providers-logos"; import { ToolSetSelector } from "@/web/components/tool-set-selector.tsx"; @@ -52,9 +53,14 @@ import { SearchInput } from "@deco/ui/components/search-input.tsx"; import { Page } from "@/web/components/page"; import { IntegrationIcon } from "@/web/components/integration-icon"; import { + SettingsCard, + SettingsCardItem, + SettingsSection, +} from "@/web/components/settings/settings-section"; +import { + AlertTriangle, ChevronDown, ChevronRight, - Key01, Loading01, Lock01, Plus, @@ -66,7 +72,12 @@ import { // ============================================================================ export type RoleEditorTarget = - | { kind: "builtin"; role: "owner" | "admin" | "user" } + | { + kind: "builtin"; + role: "owner" | "admin" | "user"; + storedId?: string; + storedPermission?: Record; + } | { kind: "custom"; role: OrganizationRole } | { kind: "new" }; @@ -160,6 +171,25 @@ interface OrgPermissionsTabProps { searchQuery: string; } +function makeToggle(checked: boolean, readOnly: boolean, onToggle: () => void) { + const sw = ( + + ); + if (!readOnly) return sw; + return ( + + + +
{sw}
+
+ +

Built-in role permissions cannot be changed

+
+
+
+ ); +} + function OrgPermissionsTab({ allowAllStaticPermissions, staticPermissions, @@ -168,159 +198,108 @@ function OrgPermissionsTab({ readOnly = false, searchQuery, }: OrgPermissionsTabProps) { - const deferredSearchQuery = useDeferredValue(searchQuery); - const toolsByCategory = getToolsByCategory(); - const allPermissions = getPermissionOptions(); - - const filteredPermissions = allPermissions.filter((perm) => - perm.label.toLowerCase().includes(deferredSearchQuery.toLowerCase()), - ); + const deferredQuery = useDeferredValue(searchQuery.trim().toLowerCase()); + const sections = getCapabilitySections(); - const togglePermission = (permission: ToolName) => { - if (staticPermissions.includes(permission)) { - onPermissionsChange(staticPermissions.filter((p) => p !== permission)); - } else { - const newPermissions = [...staticPermissions, permission]; - if (newPermissions.length === allPermissions.length) { - onAllowAllChange(true); - onPermissionsChange([]); - } else { - onPermissionsChange(newPermissions); - } + const toggleCapability = ( + cap: PermissionCapability, + currentEnabled: boolean, + ) => { + if (readOnly) return; + if (allowAllStaticPermissions && currentEnabled) { + const allTools = sections + .flatMap((s) => s.capabilities) + .flatMap((c) => c.tools); + onAllowAllChange(false); + onPermissionsChange(allTools.filter((t) => !cap.tools.includes(t))); + return; } + if (allowAllStaticPermissions) return; + onPermissionsChange( + toggleCapabilityInTools(cap, staticPermissions, !currentEnabled), + ); }; + const filteredSections = sections + .map(({ section, capabilities }) => ({ + section, + capabilities: capabilities.filter( + (cap) => + !deferredQuery || + cap.label.toLowerCase().includes(deferredQuery) || + cap.description.toLowerCase().includes(deferredQuery), + ), + })) + .filter((s) => s.capabilities.length > 0); + return ( -
-
-
{ - if (readOnly) return; - onAllowAllChange(!allowAllStaticPermissions); - onPermissionsChange([]); - }} - > - - All organization permissions - -
e.stopPropagation()}> - {readOnly ? ( - - - -
- {}} - /> -
-
- -

Built-in role permissions cannot be changed

-
-
-
- ) : ( - { - onAllowAllChange(checked); - onPermissionsChange([]); - }} - /> - )} -
-
-
-
- {Object.entries(toolsByCategory).map(([category, tools]) => { - const categoryPermissions = filteredPermissions.filter((p) => - tools.some((t) => t.name === p.value), - ); - if (categoryPermissions.length === 0) return null; - return ( -
-

- {category} -

-
- {categoryPermissions.map((permission) => ( -
{ - if (readOnly) return; - if (allowAllStaticPermissions) { - onAllowAllChange(false); - onPermissionsChange( - allPermissions - .map((p) => p.value) - .filter((p) => p !== permission.value), - ); - } else { - togglePermission(permission.value); +
+
+ + + { + onAllowAllChange(!allowAllStaticPermissions); + onPermissionsChange([]); + })} + onClick={ + readOnly + ? undefined + : () => { + onAllowAllChange(!allowAllStaticPermissions); + onPermissionsChange([]); + } + } + /> + + + + {filteredSections.length === 0 && deferredQuery ? ( +

+ No permissions match “{searchQuery}” +

+ ) : ( + filteredSections.map(({ section, capabilities }) => ( + + + {capabilities.map((cap) => { + const enabled = isCapabilityEnabled( + cap, + staticPermissions, + allowAllStaticPermissions, + ); + return ( + + {cap.label} + {cap.dangerous && ( + + )} + } - }} - > - - {permission.label} - -
e.stopPropagation()}> - {readOnly ? ( - - - -
- {}} - /> -
-
- -

Built-in role permissions cannot be changed

-
-
-
- ) : ( - { - if (allowAllStaticPermissions) { - onAllowAllChange(false); - onPermissionsChange( - allPermissions - .map((p) => p.value) - .filter((p) => p !== permission.value), - ); - } else { - togglePermission(permission.value); - } - }} - /> + description={cap.description} + action={makeToggle(enabled, readOnly, () => + toggleCapability(cap, enabled), )} -
-
- ))} -
-
- ); - })} + onClick={ + readOnly + ? undefined + : () => toggleCapability(cap, enabled) + } + /> + ); + })} + + + )) + )}
); @@ -1056,10 +1035,26 @@ type MemberLike = { id: string; role: string }; function loadBuiltinRoleIntoForm( role: "owner" | "admin" | "user", members: Array<{ id: string; role: string }>, + storedData?: { id?: string; permission?: Record }, + connections?: ConnectionEntity[], ): RoleFormData { + if (storedData?.permission != null && connections) { + return convertRoleToFormData( + { + id: storedData.id, + role, + label: role.charAt(0).toUpperCase() + role.slice(1), + isBuiltin: true, + permission: storedData.permission, + }, + members, + connections, + ); + } const isOwnerOrAdmin = role === "owner" || role === "admin"; return { role: { + id: storedData?.id, slug: role, label: role.charAt(0).toUpperCase() + role.slice(1), }, @@ -1169,7 +1164,14 @@ function getInitialFormValues( connections: ConnectionEntity[], ): RoleFormData { if (target.kind === "builtin") { - return loadBuiltinRoleIntoForm(target.role, members); + return loadBuiltinRoleIntoForm( + target.role, + members, + target.storedId != null + ? { id: target.storedId, permission: target.storedPermission } + : undefined, + connections, + ); } if (target.kind === "custom") { return convertRoleToFormData(target.role, members, connections); @@ -1271,12 +1273,12 @@ function RoleDetailPageInner({ const { locator } = useProjectContext(); const queryClient = useQueryClient(); - const isBuiltin = target.kind === "builtin"; + const isOwnerBuiltin = target.kind === "builtin" && target.role === "owner"; const isNew = target.kind === "new"; const [activeTab, setActiveTab] = useState< "mcp" | "org" | "models" | "members" - >(isBuiltin ? "org" : "mcp"); + >("org"); const form = useForm({ resolver: zodResolver(roleFormSchema), @@ -1292,7 +1294,11 @@ function RoleDetailPageInner({ const roleSlug = formData.role.slug || formData.role.label.toLowerCase().replace(/\s+/g, "-"); - const isBuiltinRole = formData.role.slug && !formData.role.id; + const isOwnerBuiltinSave = + formData.role.slug === "owner" && !formData.role.id; + const isEditableBuiltinFirstSave = + (formData.role.slug === "admin" || formData.role.slug === "user") && + !formData.role.id; const syncMembers = async (currentSlug: string) => { const currentIds = members @@ -1322,8 +1328,8 @@ function RoleDetailPageInner({ } }; - if (isBuiltinRole) { - await syncMembers(formData.role.slug!); + if (isOwnerBuiltinSave) { + await syncMembers("owner"); return formData; } else if (formData.role.id) { const r = await authClient.organization.updateRole({ @@ -1334,6 +1340,46 @@ function RoleDetailPageInner({ throw new Error(r.error.message ?? "Something went wrong"); await syncMembers(formData.role.slug!); return formData; + } else if (isEditableBuiltinFirstSave) { + // First save of an editable built-in (admin/user) creates an + // organizationRole row that shadows the slug. If a row already + // exists (e.g. saved earlier in another tab, or list cache was + // stale), Better Auth returns "role name already exists". Recover + // by fetching the canonical list and updating the row in place so + // the editor never gets stuck on a duplicate-name error. + const create = await authClient.organization.createRole({ + role: formData.role.slug!, + permission, + }); + let savedId = create?.data?.roleData?.id as string | undefined; + if (create?.error) { + const message = create.error.message ?? ""; + const isDuplicate = /already exists/i.test(message); + if (!isDuplicate) { + throw new Error(message || "Something went wrong"); + } + const list = await authClient.organization.listRoles(); + if (list?.error) throw new Error(list.error.message ?? message); + const existing = (list?.data ?? []).find( + (row: { role?: string; id?: string }) => + row.role === formData.role.slug, + ); + if (!existing?.id) { + throw new Error(message); + } + const upd = await authClient.organization.updateRole({ + roleId: existing.id, + data: { permission }, + }); + if (upd?.error) + throw new Error(upd.error.message ?? "Something went wrong"); + savedId = existing.id; + } + await syncMembers(formData.role.slug!); + return { + ...formData, + role: { ...formData.role, id: savedId }, + }; } else { const r = await authClient.organization.createRole({ role: roleSlug, @@ -1364,9 +1410,10 @@ function RoleDetailPageInner({ queryKey: KEYS.organizationRoles(locator), }); const wasNew = !variables.role.id && !variables.role.slug; - const wasBuiltinRole = variables.role.slug && !variables.role.id; + const wasOwnerBuiltin = + variables.role.slug === "owner" && !variables.role.id; track( - wasBuiltinRole + wasOwnerBuiltin ? "role_members_updated" : wasNew ? "role_created" @@ -1374,7 +1421,7 @@ function RoleDetailPageInner({ { role_slug: variables.role.slug ?? null }, ); toast.success( - wasBuiltinRole + wasOwnerBuiltin ? "Members updated successfully!" : wasNew ? "Role created successfully!" @@ -1399,8 +1446,7 @@ function RoleDetailPageInner({ saveMutation.mutate(data); }); - const showSaveActions = - !isBuiltin || (target.kind === "builtin" && target.role !== "owner"); + const showSaveActions = !isOwnerBuiltin; const roleName = target.kind === "builtin" @@ -1410,8 +1456,10 @@ function RoleDetailPageInner({ : ""; const tabs = [ - ...(!isBuiltin ? [{ id: "mcp" as const, label: "MCP Permissions" }] : []), { id: "org" as const, label: "Organization Permissions" }, + ...(!isOwnerBuiltin + ? [{ id: "mcp" as const, label: "MCP Permissions" }] + : []), { id: "models" as const, label: "Models" }, { id: "members" as const, label: "Members" }, ]; @@ -1433,7 +1481,12 @@ function RoleDetailPageInner({ return ( - +
- {isBuiltin ? ( + {isOwnerBuiltin && ( - ) : ( - )} {isNew ? (
-
+
- {activeTab === "mcp" && !isBuiltin && ( + {activeTab === "mcp" && !isOwnerBuiltin && ( @@ -1565,7 +1622,7 @@ function RoleDetailPageInner({ onPermissionsChange={(v) => form.setValue("staticPermissions", v, { shouldDirty: true }) } - readOnly={isBuiltin} + readOnly={isOwnerBuiltin} searchQuery={searchQuery} /> )} @@ -1579,7 +1636,7 @@ function RoleDetailPageInner({ onModelSetChange={(v) => form.setValue("modelSet", v, { shouldDirty: true }) } - readOnly={isBuiltin} + readOnly={isOwnerBuiltin} searchQuery={searchQuery} /> )} diff --git a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 7bd9ac5b69..fc2cb4c525 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -53,6 +53,7 @@ import { import { Suspense, useDeferredValue, useState } from "react"; import { toast } from "sonner"; import { track } from "@/web/lib/posthog-client"; +import { useCapability } from "@/web/hooks/use-capability"; // --------------------------------------------------------------------------- // Types @@ -101,6 +102,7 @@ function ConnectionDialogContent({ onCreateConnection, onBrowseNavigate, defaultTab = "connected", + canManageConnections, }: { mode?: ConnectionDialogMode; agentId?: string; @@ -113,6 +115,7 @@ function ConnectionDialogContent({ onCreateConnection: () => void; onBrowseNavigate?: (slug: string) => void; defaultTab?: "all" | "connected"; + canManageConnections: boolean; }) { const { org } = useProjectContext(); const deferredSearch = useDeferredValue(search); @@ -235,7 +238,10 @@ function ConnectionDialogContent({ isFetchingNextConnectionsPage, ); - const showCatalog = activeTab === "all" || !!searchLower; + // Catalog items always require creating a new connection — no point in + // populating them for users who can't manage connections. + const showCatalog = + canManageConnections && (activeTab === "all" || !!searchLower); // Catalog items, excluding apps already shown as connected cards const catalogItems = showCatalog @@ -308,6 +314,11 @@ function ConnectionDialogContent({ ); } + // When there is no available instance, the only way to attach is to + // clone — which creates a new connection. Hide the Add button when the + // user lacks connections:manage so they can't trigger a doomed call. + const showAddButton = !!availableInstance || canManageConnections; + return ( Added )} - + }} + > + Add + + )}
} /> ); }; - // Render a catalog item card — no instances yet + // Render a catalog item card — no instances yet. Catalog items always + // require creating a new connection, so hide them entirely when the user + // can't manage connections — they'd just produce 403s on click. const renderCatalogItem = (item: RegistryItem) => { + if (!canManageConnections) return null; const meshMeta = item._meta?.["mcp.mesh"] as | Record | undefined; @@ -437,18 +453,20 @@ function ConnectionDialogContent({ activeTab={activeTab} onTabChange={(id) => handleTabChange(id as ConnectionTab)} /> - + {canManageConnections && ( + + )}
)} @@ -591,6 +609,7 @@ export function AddConnectionDialog({ const connectionActions = useConnectionActions(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const { granted: canManageConnections } = useCapability("connections:manage"); const handleBrowseNavigate = (slug: string) => { onOpenChange(false); @@ -851,6 +870,7 @@ export function AddConnectionDialog({ onCreateConnection={() => setCreateOpen(true)} onBrowseNavigate={handleBrowseNavigate} defaultTab={defaultTab} + canManageConnections={canManageConnections} /> diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index c5c5262672..7b1a3b42ee 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -7,6 +7,10 @@ import { ErrorBoundary } from "@/web/components/error-boundary"; import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { usePanelActions } from "@/web/layouts/shell-layout"; import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status"; +import { + NO_PERMISSION_TOOLTIP, + useCapability, +} from "@/web/hooks/use-capability"; import { authenticateMcp, @@ -47,6 +51,7 @@ import { Textarea } from "@deco/ui/components/textarea.tsx"; import { Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; @@ -1078,6 +1083,8 @@ function VirtualMcpDetailViewWithData({ defaultValues: virtualMcp, }); + const { granted: canManageAgents } = useCapability("agents:manage"); + // Watch connections for reactive UI const connections = form.watch("connections"); @@ -1167,6 +1174,18 @@ function VirtualMcpDetailViewWithData({ saveTimerRef.current = null; } + // Auto-save is gated by agents:manage. Roll the form back to the + // persisted agent so the user's edits don't masquerade as saved values + // (and so the next keystroke doesn't keep firing this branch). + if (!canManageAgents) { + toast.error( + "You don't have permission to edit this agent. Changes won't be saved.", + { id: "agent-no-permission" }, + ); + form.reset(virtualMcp); + return; + } + const dirtyKeys = Object.keys(form.formState.dirtyFields); if (dirtyKeys.length === 0) return; const instructionsDirty = dirtyKeys.includes("metadata"); @@ -1214,6 +1233,7 @@ function VirtualMcpDetailViewWithData({ } const handleOpenAddDialog = () => { + if (!canManageAgents) return; track("connections_dialog_opened", { source: "agent_settings", mode: "add", @@ -1222,6 +1242,7 @@ function VirtualMcpDetailViewWithData({ }; const handleAddConnection = async (connectionId: string) => { + if (!canManageAgents) return; const current = form.getValues("connections"); // Don't add duplicates if (current.some((c) => c.connection_id === connectionId)) return; @@ -1605,31 +1626,62 @@ Define step-by-step how the agent should handle requests.

Connections

- {connections.length > 0 && ( - - )} + {connections.length > 0 && + (canManageAgents ? ( + + ) : ( + + + + + + + + {NO_PERMISSION_TOOLTIP} + + + ))}
{connections.length === 0 ? ( - + ) : ( +
+
+ +
+ + No connections yet. Ask an admin to add one. +
- - No connections yet. Add one to get started. - - + ) ) : ( connections.map((conn) => (