diff --git a/apps/desktop/src/main/terminal/selection.test.ts b/apps/desktop/src/main/terminal/selection.test.ts index a791350d845..1c9b4a2ce7a 100644 --- a/apps/desktop/src/main/terminal/selection.test.ts +++ b/apps/desktop/src/main/terminal/selection.test.ts @@ -1,4 +1,3 @@ -import { sleep } from '@sim/utils/helpers' import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' import { findSelectedRow } from '@/main/terminal/session' @@ -7,14 +6,14 @@ const REVERSE = '\u001b[7m' const RESET = '\u001b[0m' /** - * Writes to a real headless emulator and lets it settle, so these exercise the - * same buffer the agent reads rather than a hand-built fake. xterm parses - * asynchronously, hence the flush. + * Writes to a real headless emulator and waits for the queued writes to finish, + * so these exercise the same buffer the agent reads. The trailing empty write's + * callback fires after xterm has parsed every preceding chunk. */ async function screen(write: (term: Terminal) => void, rows = 8): Promise { const term = new Terminal({ cols: 40, rows, allowProposedApi: true }) write(term) - await sleep(30) + await new Promise((resolve) => term.write('', resolve)) return term } diff --git a/apps/docs/app/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx index 4d94308ae0e..71853679de9 100644 --- a/apps/docs/app/[[...slug]]/page.tsx +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -125,9 +125,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> // width so the lesson hero/video gets the room (chapters live in-page instead). const isAcademy = slug?.[0] === 'academy' const isCli = slug?.[0] === 'cli' + const isMcp = slug?.[0] === 'mcp' const rawNeighbours = findNeighbour(source.pageTree, page.url) - // Academy, API Reference, and CLI are self-contained sections; keep prev/next + // Academy, API Reference, CLI, and MCP are self-contained sections; keep prev/next // inside the section instead of spilling into the main documentation tree. // Match both the section's pages (`//...`) and its index (`/`). const sectionSlug = isApiReference @@ -136,7 +137,9 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> ? 'academy' : isCli ? 'cli' - : null + : isMcp + ? 'mcp' + : null const inSection = (url?: string) => url != null && (url.includes(`/${sectionSlug}/`) || url.endsWith(`/${sectionSlug}`)) const neighbours = sectionSlug diff --git a/apps/docs/components/docs-layout/docs-sidebar.tsx b/apps/docs/components/docs-layout/docs-sidebar.tsx index f2d68258adb..24e33677746 100644 --- a/apps/docs/components/docs-layout/docs-sidebar.tsx +++ b/apps/docs/components/docs-layout/docs-sidebar.tsx @@ -103,6 +103,7 @@ export function DocsSidebar() { ['Docs', '/introduction'], ['API Reference', '/api-reference/getting-started'], ['CLI', '/cli'], + ['MCP', '/mcp'], ['Academy', '/academy'], ].map(([label, href]) => ( setOpen(false)}> diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index f8b0997cc01..4610c6d392f 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -9,25 +9,20 @@ import { ThemeToggle } from '@/components/ui/theme-toggle' import { cn } from '@/lib/utils' /** - * Sections that own a tab, in reading order: the main docs, then the two + * Sections that own a tab, in reading order: the main docs, then the three * reference surfaces, then Academy. `Documentation` matches by exclusion, so * every section listed here is one it must not claim. */ -const SECTION_TABS = ['api-reference', 'academy', 'cli'] as const +const SECTION_TABS = ['api-reference', 'academy', 'cli', 'mcp'] as const /** - * Whether a pathname is inside a section, matched by whole path segment. + * Whether a pathname is inside a section, matched on its first path segment. * - * A substring test is wrong: `/integrations/clickup` and - * `/integrations/clickhouse` both contain `/cli`, which lit the CLI tab and - * unlit Documentation on two existing integration pages. + * A substring or suffix test is wrong: `/integrations/clickup` contains `/cli`, + * and `/agents/mcp` ends with `/mcp`, and both belong to Documentation. */ function isInSection(pathname: string, section: string): boolean { - return ( - pathname === `/${section}` || - pathname.endsWith(`/${section}`) || - pathname.includes(`/${section}/`) - ) + return pathname === `/${section}` || pathname.startsWith(`/${section}/`) } const NAV_TABS = [ @@ -49,6 +44,12 @@ const NAV_TABS = [ match: (p: string) => isInSection(p, 'cli'), external: false, }, + { + label: 'MCP', + href: '/mcp', + match: (p: string) => isInSection(p, 'mcp'), + external: false, + }, { label: 'Academy', href: '/academy', diff --git a/apps/docs/content/docs/mcp/authentication.mdx b/apps/docs/content/docs/mcp/authentication.mdx new file mode 100644 index 00000000000..de694a2066c --- /dev/null +++ b/apps/docs/content/docs/mcp/authentication.mdx @@ -0,0 +1,70 @@ +--- +title: Authentication +description: Sign in with OAuth, or connect with an API key +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +## OAuth + +Most apps sign in with OAuth. The first time you connect, your app opens Sim in +the browser, you sign in, and you approve its access. The app then holds a +token that renews itself; you do not copy any secret. + +The approval screen names the app and what it can do: + +| Access | Scope | Allows | +| --- | --- | --- | +| Read-only | `api:read` | Reading workspaces, workflows, runs, tables, files, knowledge bases, and logs | +| Full | `api:write` | Everything above, plus creating, changing, running, deploying, and deleting | + +Most apps request full access. To connect an app for reads only, configure it +to request the `api:read` scope; changes then fail with an insufficient-scope +error. + +Tokens are issued for the Sim MCP server itself. An app cannot take one to +another service and use it there. + +### Revoke access + +Open **Settings → General → Authorized apps** in Sim, find the app, and revoke +it. The app's next request fails, and you can reconnect at any time. Revoking +does not undo changes the app already made. + +## API keys + +Apps that cannot sign in through a browser, such as CI jobs and headless +agents, can send a Sim [API key](/api-reference/authentication) in the +`X-API-Key` header, or as `Authorization: Bearer `. + +```bash +claude mcp add --transport http sim https://mcp.sim.ai/mcp \ + --header "X-API-Key: $SIM_API_KEY" +``` + +```json title="~/.cursor/mcp.json" +{ + "mcpServers": { + "sim": { + "url": "https://mcp.sim.ai/mcp", + "headers": { "X-API-Key": "${env:SIM_API_KEY}" } + } + } +} +``` + +A personal key acts as you in every workspace you can access. A workspace key +reaches only its own workspace, and a few account-level operations refuse it; +`search_operations` marks them `personalCredentialOnly`. + + + An API key does not expire until you revoke it. Prefer OAuth for any app that + can open a browser, and store keys in your app's secret or environment + settings rather than in a shared config file. + + +## Organization policy + +The server follows your organization's access policy. If an administrator turns +off **OAuth apps** or **personal API keys** for your permission group, requests +with that credential are refused in the affected workspaces. diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx new file mode 100644 index 00000000000..8f2a705f0e8 --- /dev/null +++ b/apps/docs/content/docs/mcp/index.mdx @@ -0,0 +1,109 @@ +--- +title: Sim MCP +description: Build, run, and manage everything in your Sim workspace from Claude, Codex, Cursor, and other MCP apps +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The Sim MCP server gives an AI app the whole Sim API through the +[Model Context Protocol](https://modelcontextprotocol.io). Your agent can list +workspaces, run and deploy workflows, query and edit tables, manage files and +knowledge bases, read run logs, and more. It covers the same operations as the +[API](/api-reference/getting-started) and the [CLI](/cli). + +| Deployment | Server URL | +| --- | --- | +| Sim Cloud | `https://mcp.sim.ai/mcp` | +| Self-hosted | `https:///api/mcp`, or your [`SIM_MCP_URL`](/platform/self-hosting/environment-variables) | + +The server uses the Streamable HTTP transport. Sign in with OAuth, the default +in every app below, or send an [API key](/mcp/authentication#api-keys). + +## Connect an app + + + + ```bash + claude mcp add --transport http sim https://mcp.sim.ai/mcp + ``` + + Open `/mcp` in Claude Code, select **sim**, and sign in to Sim in the + browser. + + + Add `https://mcp.sim.ai/mcp` as a custom connector under **Settings → + Connectors**, then connect it and sign in to Sim. For Team or Enterprise, + an owner first adds it under **Organization settings → Connectors**. See + [Claude's custom connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). + + + ```bash + codex mcp add sim --url https://mcp.sim.ai/mcp + ``` + + Complete the browser sign-in. To sign in again later, run + `codex mcp login sim`. + + + Add `sim` to `mcpServers` in `~/.cursor/mcp.json`, then enable it in + Cursor and sign in to Sim: + + ```json + { + "mcpServers": { + "sim": { "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + Add `sim` to `.vscode/mcp.json`, then start it and sign in to Sim: + + ```json + { + "servers": { + "sim": { "type": "http", "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + +Any other app that supports remote MCP servers with OAuth works the same way: +give it the server URL and choose **Streamable HTTP** if asked. + + + Claude's hosted connectors call your server from Claude's infrastructure, so a + self-hosted Sim must be reachable from the internet. A `localhost` URL works + only with apps that run on your machine, such as Claude Code, Codex, Cursor, + and VS Code. + + +## Try it + +Ask your app: + +- "List my Sim workspaces and the tables in each." +- "Run the `lead-scoring` workflow with this input and show me the result." +- "Find failed runs from the last day and explain what went wrong." +- "Create a table of our open support tickets and add these rows." + +The agent finds the right operation, reads its inputs, and calls it. See +[Tools](/mcp/tools) for how that works. + +## What your agent can do + +The server acts as you. It sees the workspaces you can see, with your role in +each, and every call is authorized, rate limited, and logged exactly like the +same request to the API. Reads leave your resources unchanged, and apps can ask +you to confirm each change. See [Authentication](/mcp/authentication) to limit +an app to reads. + +## Other Sim MCP surfaces + +This server is for operating Sim. Two other MCP features do different jobs: + +- [Search MCP](/search/mcp) searches your organization's indexed sources. +- [MCP deployment](/workflows/deployment/mcp) exposes your own workflows as + tools, and [MCP tools](/agents/mcp) connect external servers to Sim agents. diff --git a/apps/docs/content/docs/mcp/meta.json b/apps/docs/content/docs/mcp/meta.json new file mode 100644 index 00000000000..f111bfd9ece --- /dev/null +++ b/apps/docs/content/docs/mcp/meta.json @@ -0,0 +1,5 @@ +{ + "title": "MCP", + "root": true, + "pages": ["---Sim MCP---", "index", "authentication", "tools"] +} diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx new file mode 100644 index 00000000000..6bf6e0fee84 --- /dev/null +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -0,0 +1,55 @@ +--- +title: Tools +description: How an agent finds, reads, and calls Sim operations through four tools +--- + +The Sim API has more than 200 operations. Instead of one tool per operation, +which would crowd your app's tool list and your agent's context, the server +exposes four tools. The agent searches for an operation, reads its inputs, and +calls it. + +| Tool | Does | +| --- | --- | +| `search_operations` | Finds operations by keyword (`"table rows"`) or by area (`tables`, `workflows`, `knowledge`, …). Returns each operation's name, method, path, summary, and the tool that runs it. | +| `describe_operation` | Returns an operation's description and the JSON Schema of its path parameters, query, body, and headers. | +| `call_read_operation` | Runs an operation that only needs read access, such as `listWorkspaces`, `queryRows`, or `getWorkflowRun`. | +| `call_write_operation` | Runs an operation that needs write access: one that creates, changes, runs, or deletes something, or reaches out to another service, such as `createTable`, `executeWorkflow`, or `listMcpServerTools`. | + +Reads and writes are separate tools so your app can approve reads once and still +ask you before each change. + +## Calling an operation + +Operation names match the [CLI](/cli/reference) and the +[API reference](/api-reference/getting-started). A call names the operation and +fills the parts of the request it needs: + +```json +{ + "operation": "listTableRows", + "params": { "tableId": "tbl_8f2c" }, + "query": { "workspaceId": "ws_91ab", "limit": 50 } +} +``` + +| Field | Holds | +| --- | --- | +| `params` | Path parameters, such as `tableId` or `workflowId` | +| `query` | Query-string parameters; most operations need `workspaceId` | +| `body` | The JSON request body (write operations only) | +| `headers` | Headers the operation declares, such as `upload-token` | + +The result is the same JSON the API returns, usually `{ "data": … }`. List +operations page with `limit` and `cursor`. A failed call returns the API's error, +such as `{ "error": { "code": "NOT_FOUND", "message": "…" } }`, so the agent can +correct its request. + +## Limits + +- **Same rules as the API.** Permissions, rate limits, and request validation + are the API's own; nothing is looser through MCP. +- **1 MiB per result.** Page through larger lists with `limit` and `cursor`. +- **No streaming.** Run a workflow without `stream: true` to wait for its + result, or with `async: true` and poll `getWorkflowRun`. +- **No file bytes.** Downloads, knowledge base exports, and multipart document + uploads are not available over MCP; use the [CLI](/cli/files) or the API. diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index d870ef0e354..23c80273d81 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -34,6 +34,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) | | `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` | | `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Optional — falls back to the public base URL. Deliberately ignored inside the Trigger.dev worker runtime, where a cluster-internal address resolves to the worker itself | +| `SIM_MCP_URL` | Public URL of the [Sim MCP server](/mcp) when you serve it on its own host, e.g. `https://mcp.example.com/mcp`. Point that host at the app; Sim serves only the MCP endpoint and its OAuth metadata there, and stops serving `/api/mcp` on the app host so clients use one URL. Optional — defaults to `/api/mcp` | | `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset | ## AI Providers diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index dde3a58689c..0b28b7feb88 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -25,7 +25,7 @@ Answer in two sentences, cite the doc you used, and never guess a price. ### Model -The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM. +The model that runs the step. Defaults to `claude-sonnet-5`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM. For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. @@ -87,6 +87,8 @@ Some settings live under advanced, or appear only for models that support them: - **Reasoning effort / Thinking level.** For models with extended reasoning, how much the model thinks before answering. Higher is more thorough but slower and costs more tokens. - **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use. - **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one. +- **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `` reports the model that answered. On hosted Sim, hosted models use your workspace's BYOK or platform credentials; local and self-hosted installations may still require a key. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. A stored row key stops applying when its key field is hidden. Providers that require family-specific credentials, such as Vertex, can only be fallbacks for a selected model of the same family. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies. +- **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. A failure that happens after the model already called a tool runs that conversation again on the next try or the next model, so keep fallbacks and retry off for agents whose tools must not repeat. OpenAI and Gemini cache automatically at no extra cost and need no setting; their discount is already reflected in what you are charged. @@ -148,4 +150,5 @@ The Agent reads the message from Start with `` and returns a result diff --git a/apps/docs/content/docs/workflows/blocks/evaluator.mdx b/apps/docs/content/docs/workflows/blocks/evaluator.mdx index db480409d4d..7b5716cd5bc 100644 --- a/apps/docs/content/docs/workflows/blocks/evaluator.mdx +++ b/apps/docs/content/docs/workflows/blocks/evaluator.mdx @@ -32,6 +32,12 @@ The content to score. Usually an earlier output like ``. Structur The model that does the scoring, defaulting to `claude-sonnet-4-6`. Stronger reasoning models give more consistent scores. Type or pick any supported model. **Temperature** and a **System Prompt** are available under advanced, and on hosted Sim the API key is supplied for you. +### Fallback models + +Under **Additional fields**, add up to five models to try in order when a model request fails. With **Retry on fail** enabled, the selected model exhausts its tries first; each fallback is then tried once. Every attempt uses the same content, metrics, and response schema. `` reports the model that answered. + +Hosted models on hosted Sim use workspace BYOK or platform credentials. On local or self-hosted installations, a fallback on another provider may need a secret selected on its row; same-provider fallbacks reuse the selected model's key. The picker shows supported tuning fields when the selected model's settings cannot be inherited. Auto cannot be a fallback. + ## Outputs The Evaluator returns a number for each metric, read by the metric's lowercase name: diff --git a/apps/docs/content/docs/workflows/blocks/router.mdx b/apps/docs/content/docs/workflows/blocks/router.mdx index 06d183f369e..1f6fead37ba 100644 --- a/apps/docs/content/docs/workflows/blocks/router.mdx +++ b/apps/docs/content/docs/workflows/blocks/router.mdx @@ -35,6 +35,12 @@ Each route is a **title** and a **description** of when to choose it ("Route her The model that makes the decision, defaulting to `claude-sonnet-4-6`. Stronger reasoning models route more accurately; a faster, cheaper model is fine when the routes are clearly distinct. Type or pick any supported model, or a local one through Ollama or VLLM. On hosted Sim the API key is supplied for you. +### Fallback models + +Under **Additional fields**, add up to five models to try in order when a model request fails. With **Retry on fail** enabled, the selected model exhausts its tries first; each fallback is then tried once. Every attempt uses the same context and route definitions. `` reports the model that answered. This also works for existing legacy Router blocks. + +Hosted models on hosted Sim use workspace BYOK or platform credentials. On local or self-hosted installations, a fallback on another provider may need a secret selected on its row; same-provider fallbacks reuse the selected model's key. Auto cannot be a fallback. A completed `NO_MATCH` decision still takes the error path; it does not trigger another model request. + ## Outputs | Output | What it is | diff --git a/apps/docs/lib/integration-navigation.test.ts b/apps/docs/lib/integration-navigation.test.ts index bbe381c7e6a..7b1d3b96e67 100644 --- a/apps/docs/lib/integration-navigation.test.ts +++ b/apps/docs/lib/integration-navigation.test.ts @@ -58,8 +58,8 @@ describe('docs section navigation', () => { } }) - it('keeps root-tab overview pages in the CLI and Academy navigation', () => { - for (const root of ['cli', 'academy']) { + it('keeps root-tab overview pages in the CLI, MCP, and Academy navigation', () => { + for (const root of ['cli', 'mcp', 'academy']) { const folder = folders(source.pageTree.fallback?.children ?? []).find( (node) => node.$ref === `${root}/meta.json` ) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f5d05f0ac23..33b321ba57d 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -20,6 +20,7 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL +# SIM_MCP_URL=https://mcp.example.com/mcp # Optional: dedicated host for the Sim MCP server; defaults to NEXT_PUBLIC_APP_URL/api/mcp # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. @@ -214,7 +215,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup -# PERMISSION_ACCESS_REQUESTS_ENABLED= # Global access-request rollout; organizations may opt out # KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts index 130b84711b6..beb515c525d 100644 --- a/apps/sim/app/(auth)/components/constants.ts +++ b/apps/sim/app/(auth)/components/constants.ts @@ -14,4 +14,4 @@ export const AUTH_CONTROL_HEIGHT = 'h-9' * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. */ -export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none` +export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index e488f8a8ea9..0698fbfda16 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -3,7 +3,7 @@ import type React from 'react' import { useLayoutEffect, useRef, useState } from 'react' import { Badge, Button, cn, Tooltip } from '@sim/emcn' -import { ArrowUp, Paperclip, X } from '@sim/emcn/icons' +import { ArrowUp, Paperclip, StopFilled, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' @@ -222,6 +222,7 @@ export const ChatInput: React.FC<{ ) : ( + + + ) : ( - + )} diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index fb92898c7e2..f81ea1523a4 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,7 +1,13 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { + ComposerActionButton, + cn, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' import { ArrowUp, Search } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -25,11 +31,6 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useVoiceInput } from '@/hooks/use-voice-input' -const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0 transition-colors' -const SUBMIT_BUTTON_ACTIVE = - 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' -const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' - interface SearchFieldProps { initialValue: string onSubmit: (value: string) => void @@ -98,19 +99,16 @@ function SearchField({ onToggle={voice.toggleListening} /> )} - + ({ + isConnectorSyncingOrPending: (row: { + status: string + accessMode?: string + memberSyncStatus?: string + }) => + ['pending', 'syncing'].includes(row.status) || + ['pending', 'running'].includes(row.memberSyncStatus ?? ''), useSearchIndex: ( scope: { workspaceId?: string; organizationId?: string }, options: { enabled: boolean } @@ -981,7 +988,8 @@ describe('member content credentials in real add and edit dialogs', () => { await chooseSyncFrequency('Manual only') expect(document.body.textContent).toContain('Documents become unavailable after 24 hours') await chooseSyncFrequency('Every hour') - expect(document.body.textContent).toContain('Permissions are checked on every sync.') + expect(document.body.textContent).not.toContain('Documents become unavailable after 24 hours') + expect(document.body.textContent).not.toContain('Permissions are checked on every sync.') }) it('saves source settings without changing a dedicated indexing account', async () => { @@ -1367,10 +1375,10 @@ describe('administrator source prerequisites in real connector dialogs', () => { (node) => node.textContent?.trim() === replacement.name )! await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) - expect(button('Save')).toBeDisabled() - expect(button('Change service account')).toBeEnabled() + expect(button('Save')).toBeEnabled() + expect(document.body.textContent).not.toContain('Change service account') - await click(button('Change service account')) + await click(button('Save')) expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith( { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx index c97e4c8a3c4..31435adee4a 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx @@ -38,7 +38,8 @@ vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.f vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: mocks.index, useConnectorDetail: mocks.detail, - isConnectorSyncingOrPending: () => false, + isConnectorSyncingOrPending: (row: ConnectorData) => + row.status === 'syncing' || row.status === 'pending', })) vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.integrations, @@ -187,6 +188,19 @@ describe('organization source detail navigation', () => { expect(button, `Missing ${text}`).toBeTruthy() await act(async () => button!.click()) } + + it('passes live sync status to the form without replacing its settings baseline', async () => { + await render('?view=settings') + const baseline = mocks.form.mock.lastCall![0].connector + mocks.dirty = true + mocks.detail.mockReturnValue({ data: { ...connector, status: 'syncing' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: true }) + + mocks.detail.mockReturnValue({ data: { ...connector, status: 'active' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: false }) + }) it.each(['documents', 'settings', 'history'])( 'replaces the removed connection with Sources from the %s view', async (view) => { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index ae654870d01..8c75f40fefc 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -425,6 +425,7 @@ function SourceSettingsForm({ }: SourceSettingsFormProps) { const form = useConnectorSettingsForm({ connector: baseline, + syncing: isConnectorSyncingOrPending(connector), scope, knowledgeBaseId: connector.knowledgeBaseId, isSearchIndex: true, @@ -444,6 +445,7 @@ function SourceSettingsForm({ dirty: form.dirty, saving: form.saving, saveDisabled: !form.canSave, + saveTooltip: form.saveBlockedReason, onSave: form.save, onDiscard, })} diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index e07ffe5a381..fa5a295eacd 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -168,7 +168,12 @@ export default function PlaygroundPage() {
- @@ -178,7 +183,12 @@ export default function PlaygroundPage() {
- diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx index 4eb54fb68e8..5ee4cac94d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx @@ -1,4 +1,4 @@ -import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' +import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' export default function Loading() { return diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx index b3d49e88885..fa84a020a79 100644 --- a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' -import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' -import { MyAccessRequests } from '@/components/access-requests/my-access-requests' +import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' +import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' export const metadata: Metadata = { title: 'My access requests' } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index f928eec1c6b..7ded34f10d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -2,8 +2,7 @@ import type { ComponentType } from 'react' import { - Button, - chipFilledFillTokens, + BulkActionButton, cn, DropdownMenu, DropdownMenuContent, @@ -16,12 +15,6 @@ import { Download } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' -/** Shared chrome for every action button, so the bar reads as one control strip. */ -const ACTION_BUTTON_CLASS = cn( - chipFilledFillTokens, - 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' -) - interface ActionButtonProps { icon: ComponentType<{ className?: string }> label: string @@ -33,14 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp return ( - + {label} @@ -128,13 +116,9 @@ export function ResourceActionBar({ - + Move diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index cf548ac70e5..02cf2f87344 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -512,6 +512,7 @@ const Pagination = memo(function Pagination({
, })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx index d5ee4c9a8db..466afe3e7de 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx @@ -121,6 +121,7 @@ export function QueuedMessages({ + + ) } return ( - + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index ff7515d1711..56846b001d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,7 +19,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' import { @@ -45,6 +44,7 @@ import { resolveResourceSelectionUpdate, } from '@/app/workspace/[workspaceId]/home/resource-view-policy' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx index 51890cea304..b363f80287d 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx @@ -1,10 +1,10 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { INTEGRATIONS } from '@/lib/integrations' import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' export async function generateMetadata({ params, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx index 4267bc40e60..96e4ce8721e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ConnectedCredentialDetail } from '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' export const metadata: Metadata = { title: 'Connected Integration', diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index a1b1ea26755..4f814f592f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -13,7 +13,6 @@ import { } from '@sim/emcn' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { blockTypeToIconMap, formatIntegrationType, @@ -35,6 +34,7 @@ import { } from '@/app/workspace/[workspaceId]/integrations/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index 4096360c8a3..13819242fb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -407,6 +407,7 @@ export function DocumentTagsModal({
+ Enable @@ -109,15 +97,9 @@ export function ActionBar({ {showDisableButton && ( - + Disable @@ -126,15 +108,9 @@ export function ActionBar({ {onDelete && canEdit && ( - + Delete diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index 56144f8dee7..f70b4f8c7ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -203,6 +203,7 @@ export function AddDocumentsModal({ <> {isFailed && ( @@ -565,7 +567,7 @@ function DetailCodeSection({ @@ -231,7 +233,7 @@ export const WorkflowOutputSection = memo( -
diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx index f74b6c91084..482b16115b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/permission-groups/application/read-user-config', () => ({ readUserPermissionConfig: { execute: mocks.policy }, })) -vi.mock('@/lib/permission-access-requests/application/requests', () => ({ +vi.mock('@/ee/access-requests/lib/application/requests', () => ({ discoverAccessRequests: { execute: mocks.discovery }, })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) @@ -39,21 +39,21 @@ vi.mock('@sim/emcn/icons', () => ({ Upload: () => null, BookOpen: () => null, })) -vi.mock('@/components/access-requests/request-access-action', () => ({ +vi.mock('@/ee/access-requests/components/request-access-action', () => ({ RequestAccessAction: ({ pendingRequestId }: { pendingRequestId: string | null }) => ( ), })) -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ApiClientError } from '@/lib/api/client/errors' import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts index 182426e174a..1b05ba5913a 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -11,7 +11,7 @@ import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' import { PERMISSION_GROUPS_STALE_TIME, permissionGroupKeys, @@ -46,7 +46,7 @@ export async function prefetchWorkspaceAccess( queryKey: accessRequestKeys.discovery(query), queryFn: async () => { const { discoverAccessRequests } = await import( - '@/lib/permission-access-requests/application/requests' + '@/ee/access-requests/lib/application/requests' ) return discoverAccessRequestsContract.response.schema.parse( await discoverAccessRequests.execute({ principal, input: query }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 04e42904940..aeffeec2629 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -28,7 +28,7 @@ const { vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/components/access-requests/permission-access-boundary', () => ({ +vi.mock('@/ee/access-requests/components/permission-access-boundary', () => ({ PermissionAccessBoundary: vi.fn(() => null), })) vi.mock('@/lib/settings/application/workspace-section-access', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 97cf21c2783..df6f6c2d7b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { EmptyState } from '@/components/empty-state/empty-state' import { getOrganizationSettingsHref, @@ -13,6 +12,7 @@ import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/wo import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { SECTION_PREFETCHERS } from './prefetch' import { SettingsPage } from './settings' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 016de4cb3e4..997bf5af779 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { getSettingsPermissionConfigKey } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' @@ -15,6 +14,7 @@ import { getSettingsSectionMeta, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 85fa1441c6e..77303d43160 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -445,7 +445,7 @@ export function Admin() {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index 9b7d34e3e11..f411281d21e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -238,7 +238,8 @@ export function EnrichmentConfig({ variant='ghost' size='sm' onClick={onBack} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Back to enrichments' > @@ -249,7 +250,8 @@ export function EnrichmentConfig({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index 64cd19f1134..0aebd34cb4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -77,7 +77,8 @@ function EnrichmentsSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > @@ -123,7 +124,8 @@ function EnrichmentsSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx index 9a834d80e57..b71c689b2ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx @@ -40,7 +40,7 @@ export const RunStatusControl = memo(function RunStatusControl({
+ {label}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 1b3a17006b1..8fe52025569 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -243,24 +243,14 @@ export function TableFilter({ ))}
- {!autoApply && (
{filter !== null && ( - )} @@ -392,7 +382,8 @@ const FilterRuleRow = memo(function FilterRuleRow({ variant='ghost' size='sm' onClick={() => onRemove(rule.id)} - className='size-7 shrink-0 p-1!' + iconPadding='sm' + className='size-7 shrink-0' aria-label='Remove filter' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 03e835b4fb8..18b04cfd5b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -637,7 +637,8 @@ export function WorkflowSidebarBody({ variant='ghost' size='sm' onClick={onBack} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Back to enrichments' > @@ -651,7 +652,8 @@ export function WorkflowSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > @@ -718,6 +720,7 @@ export function WorkflowSidebarBody({ ), cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + ComposerActionButton: ({ + children, + size: _size, + active: _active, + ...props + }: ButtonHTMLAttributes & { size?: string; active?: boolean }) => ( + + ), Input: ({ ref, className: _className, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 1da08f3bc7a..d6a91f24477 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { Badge, Button, + ComposerActionButton, cn, Input, Popover, @@ -135,6 +136,7 @@ function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) { )}
@@ -1095,32 +1105,28 @@ export function Chat() { {isStreaming ? ( - + ) : ( - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index 90fd0769377..e7be57bc069 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -478,7 +478,8 @@ console.log(limits);` variant='ghost' onClick={() => handleCopy('sync', getSyncCommand())} aria-label='Copy command' - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copied.sync ? : } @@ -508,7 +509,8 @@ console.log(limits);` variant='ghost' onClick={() => handleCopy('stream', getStreamCommand())} aria-label='Copy command' - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copied.stream ? : } @@ -548,7 +550,8 @@ console.log(limits);` variant='ghost' onClick={() => handleCopy('async', getAsyncCommand())} aria-label='Copy command' - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copied.async ? : } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 5d8aa644638..2245b6a3647 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -328,9 +328,10 @@ export function Versions({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 4bfa6ac5db3..330a73b990b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -209,6 +209,7 @@ function SingleFileSelector({ } />
+ ), + ChipCombobox: ({ + options, + value, + placeholder, + }: { + options: Array<{ value: string; label: string; disabled?: boolean }> + value?: string + placeholder?: string + }) => ( +
+ {options.map((option) => ( + + {option.label} + + ))} +
+ ), + ChipDropdown: ({ + options, + value, + placeholder, + }: { + options: Array<{ value: string; label: string }> + value?: string + placeholder?: string + }) => ( +
+ {options.map((option) => ( + {option.label} + ))} +
+ ), + Label: ({ children }: { children?: React.ReactNode }) => {children}, + Tooltip: { + Root: ({ children }: { children?: React.ReactNode }) => <>{children}, + Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + Content: () => null, + }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, + ChevronUp: () => null, + Plus: () => null, + Trash: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', + () => ({ + useSubBlockValue: (_blockId: string, subBlockId: string) => [ + subBlockId === 'model' || subBlockId === 'fallbackModels' ? subBlockValues[subBlockId] : null, + mockSetValue, + ], + }) +) + +vi.mock('@/hooks/queries/environment', () => ({ + usePersonalEnvironment: () => ({ data: { PERSONAL_KEY: 'x' } }), + useWorkspaceEnvironment: () => ({ + data: { workspace: { OPENROUTER_API_KEY: 'x' }, personal: {}, conflicts: [] }, + }), +})) + +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ isModelUsable: (model: string) => model !== 'denied-model' }), +})) + +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), +})) + +vi.mock('@/lib/credentials/client-state', () => ({ + writePendingCredentialCreateRequest: vi.fn(), +})) + +vi.mock('@/stores/providers/store', () => ({ + useProvidersStore: (selector: (state: { providers: object }) => unknown) => + selector({ providers: {} }), +})) + +vi.mock('@/blocks/utils', () => ({ + shouldRequireApiKeyForModel: (model: string) => + model.startsWith('openrouter/') || (model.startsWith('gpt') && !getDeploymentShape().hosted), + getModelOptions: () => [ + { id: 'claude-sonnet-5', label: 'claude-sonnet-5' }, + { id: 'gpt-5', label: 'gpt-5' }, + { id: 'denied-model', label: 'denied-model' }, + { id: 'openrouter/x', label: 'openrouter/x' }, + { id: 'sim-auto', label: 'Auto' }, + ], +})) + +vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isViableFallbackModel: (model: string, primary: string) => + model !== 'sim-auto' && model !== primary, + getFallbackTuningKnobsToShow: (model: string) => (model === 'gpt-5' ? ['reasoningEffort'] : []), + getTuningOptionsForModel: (model: string, knob: string) => + model === 'gpt-5' && knob === 'reasoningEffort' ? ['auto', 'low', 'high'] : null, + } +}) + +import { ModelFallbackList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list' + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) +}) + +afterEach(() => { + resetDeploymentShape() + vi.unstubAllGlobals() +}) + +function render(extra: Partial> = {}) { + return renderToStaticMarkup( + + ) +} + +describe('ModelFallbackList', () => { + beforeEach(() => { + subBlockValues.model = 'claude-sonnet-5' + subBlockValues.fallbackModels = [] + mockSetValue.mockReset() + }) + + it('renders only the add affordance when nothing is configured', () => { + const html = render() + expect(html).toContain('Add fallback model') + expect(html).not.toContain('choice') + }) + + it('labels rows as ordinal choices and offers viable, permitted models', () => { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'gpt-5' }, + { id: 'r2', model: '' }, + ] + const html = render() + expect(html).toContain('2nd choice') + expect(html).toContain('3rd choice') + expect(html).not.toContain('Auto') + expect(html).not.toContain('denied-model') + /** The primary is never offered. A model another row holds is disabled there, never in its own row. */ + expect(html).not.toContain('>claude-sonnet-5<') + expect(html.match(/data-disabled="true">gpt-5gpt-5 { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'openrouter/x', apiKey: 'sk-raw-through-socket' }, + ] + const html = render() + expect(html).not.toContain('aria-label="Move up"') + expect(html).not.toContain('sk-raw-through-socket') + expect(html).toContain('data-combobox="Select a secret" data-value=""') + }) + + it('asks for an environment variable only when the row model needs its own key', () => { + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] + expect(render()).not.toContain('data-combobox="Select a secret"') + + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + ] + const html = render() + expect(html).toContain('data-combobox="Select a secret"') + expect(html).toContain('data-value="{{OPENROUTER_API_KEY}}"') + expect(html).toContain('OPENROUTER_API_KEY') + expect(html).toContain('Create Secret') + }) + + it('updates key visibility when hosted context arrives after mount, without rewriting the rows', async () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: false }) + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] + const container = document.createElement('div') + const root = createRoot(container) + try { + await act(async () => { + root.render() + }) + expect(container.querySelector('[data-combobox="Select a secret"]')).not.toBeNull() + + await act(async () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) + }) + expect(container.querySelector('[data-combobox="Select a secret"]')).toBeNull() + expect(mockSetValue).not.toHaveBeenCalled() + } finally { + await act(async () => root.unmount()) + } + }) + + it('shows a tuning field only for the knobs the helper says need one', () => { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'gpt-5', reasoningEffort: 'low' }, + { id: 'r2', model: 'openrouter/x' }, + ] + const html = render() + expect(html).toContain('data-combobox="Select reasoning effort" data-value="low"') + expect(html.match(/Select reasoning effort/g)).toHaveLength(1) + expect(html).not.toContain('Thinking level') + }) + + it('gates a preview against the previewed primary, not the live block', () => { + /** The live block selects claude-sonnet-5; the previewed version selected gpt-5. */ + const html = render({ + isPreview: true, + previewValue: [{ id: 'r1', model: 'openrouter/x' }], + previewPrimary: { model: 'gpt-5' }, + }) + expect(html).not.toContain('>gpt-5<') + expect(html).toContain('>claude-sonnet-5<') + expect(html).not.toContain('Add fallback model') + }) + + it('disables the add affordance at the cap', () => { + subBlockValues.fallbackModels = Array.from({ length: 5 }, (_, i) => ({ + id: `r${i}`, + model: `m-${i}`, + })) + const html = render() + expect(html).toMatch(/]*disabled=""[^>]*>Add fallback model/) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx new file mode 100644 index 00000000000..15b7bcefdd1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx @@ -0,0 +1,424 @@ +'use client' + +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { Chip, ChipCombobox, ChipDropdown, type ComboboxOption, Label, Tooltip } from '@sim/emcn' +import { ChevronDown, ChevronUp, Plus, Trash } from '@sim/emcn/icons' +import { generateShortId } from '@sim/utils/id' +import { useParams } from 'next/navigation' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state' +import { + addFallbackRow, + changeFallbackRowApiKey, + changeFallbackRowModel, + changeFallbackRowTuning, + FALLBACK_TUNING_LABELS, + type FallbackModelEntry, + type FallbackTuningKnob, + fallbackRowNeedsApiKey, + getFallbackTuningKnobsToShow, + getTuningOptionsForModel, + isViableFallbackModel, + isWholeEnvVarReference, + MAX_FALLBACK_MODELS, + moveFallbackRow, + ordinalChoiceLabel, + removeFallbackRow, +} from '@/lib/workflows/blocks/fallback-models' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getModelOptions } from '@/blocks/utils' +import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useProvidersStore } from '@/stores/providers/store' + +const CREATE_SECRET_VALUE = 'action-create-secret' + +/** The sibling values a preview renders against, since the store holds the live block's. */ +export interface FallbackListPreviewPrimary { + model?: unknown + reasoningEffort?: unknown + thinkingLevel?: unknown + verbosity?: unknown +} + +interface ModelFallbackListProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: FallbackModelEntry[] | null + /** Required for a faithful preview; ignored outside preview mode. */ + previewPrimary?: FallbackListPreviewPrimary + disabled?: boolean +} + +/** A viable model before the per-row `disabled` flag is stamped on it. */ +interface ViableModelOption { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> +} + +interface FallbackRowProps { + row: FallbackModelEntry + index: number + /** The row can move down only while another follows it. */ + isLast: boolean + /** Move controls render only once a second row exists. */ + canMove: boolean + primaryModel: string + primaryTuning: Partial> + viableOptions: ViableModelOption[] + /** Models any row holds; a row's own model is exempted when its options are built. */ + takenModels: ReadonlySet + envVarOptions: ComboboxOption[] + readOnly: boolean + onChangeModel: (id: string, model: string) => void + onChangeApiKey: (id: string, apiKey: string) => void + onChangeTuning: (id: string, knob: FallbackTuningKnob, value: string) => void + onMove: (id: string, direction: -1 | 1) => void + onRemove: (id: string) => void +} + +const FallbackRow = memo(function FallbackRow({ + row, + index, + isLast, + canMove, + primaryModel, + primaryTuning, + viableOptions, + takenModels, + envVarOptions, + readOnly, + onChangeModel, + onChangeApiKey, + onChangeTuning, + onMove, + onRemove, +}: FallbackRowProps) { + /** Credential visibility follows the server-resolved shape, including late hydration. */ + useDeploymentShape() + const modelOptions = useMemo( + (): ComboboxOption[] => + viableOptions.map((option) => ({ + ...option, + disabled: option.value !== row.model && takenModels.has(option.value), + })), + [viableOptions, takenModels, row.model] + ) + + const needsApiKey = fallbackRowNeedsApiKey(row.model, primaryModel) + const tuningFields = getFallbackTuningKnobsToShow(row.model, primaryModel, primaryTuning).map( + (knob) => ({ + knob, + options: (getTuningOptionsForModel(row.model, knob) ?? []).map((value) => ({ + label: value, + value, + })), + }) + ) + + /** Only a reference is ever shown; anything else that reached the store reads as unset. */ + const apiKeyValue = isWholeEnvVarReference(row.apiKey) ? row.apiKey : '' + + return ( +
+
+ {ordinalChoiceLabel(index)} +
+ {canMove && ( + <> + + + onMove(row.id, -1)} + disabled={readOnly || index === 0} + aria-label='Move up' + /> + + Move up + + + + onMove(row.id, 1)} + disabled={readOnly || isLast} + aria-label='Move down' + /> + + Move down + + + )} + + + onRemove(row.id)} + disabled={readOnly} + aria-label='Remove fallback model' + /> + + Remove + +
+
+ +
+ onChangeModel(row.id, model)} + placeholder='Select a model' + aria-label={`${ordinalChoiceLabel(index)} model`} + disabled={readOnly} + searchable + searchPlaceholder='Search models...' + maxHeight={240} + emptyMessage='No models available' + /> + {needsApiKey && ( +
+ + onChangeApiKey(row.id, apiKey)} + placeholder='Select a secret' + aria-label={`${ordinalChoiceLabel(index)} API key`} + disabled={readOnly} + searchable + searchPlaceholder='Search secrets...' + maxHeight={240} + emptyMessage='No secrets' + /> +
+ )} + {tuningFields.map(({ knob, options }) => ( +
+ + onChangeTuning(row.id, knob, value)} + placeholder={`Select ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`} + aria-label={`${ordinalChoiceLabel(index)} ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`} + disabled={readOnly} + className='w-full' + /> +
+ ))} +
+
+ ) +}) + +/** + * Ordered fallback models for a model-driven block: the 2nd, 3rd, ... choice + * tried in sequence when the request to the block's own model fails. + * + * Every write is the whole array, so a collaborator's concurrent edit and an + * undo both flow straight through the store. A row's key is stored only as a + * `{{ENV_VAR}}` reference: the picker offers the workspace's secret names and + * nothing else, which is what keeps a raw secret out of the list value (see + * `FallbackModelEntry`). The row transforms live in `fallback-models.ts`. + */ +export function ModelFallbackList({ + blockId, + subBlockId, + isPreview = false, + previewValue, + previewPrimary, + disabled = false, +}: ModelFallbackListProps) { + const params = useParams() + const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : '' + const { navigateToSettings } = useSettingsNavigation() + const { isModelUsable } = usePermissionConfig() + const deploymentShape = useDeploymentShape() + const providers = useProvidersStore((state) => state.providers) + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [primaryModelValue] = useSubBlockValue(blockId, 'model') + const [primaryReasoningEffort] = useSubBlockValue(blockId, 'reasoningEffort') + const [primaryThinkingLevel] = useSubBlockValue(blockId, 'thinkingLevel') + const [primaryVerbosity] = useSubBlockValue(blockId, 'verbosity') + const { data: personalEnv = {} } = usePersonalEnvironment() + const { data: workspaceEnv } = useWorkspaceEnvironment(workspaceId, { + enabled: Boolean(workspaceId), + }) + + const readOnly = isPreview || disabled + /** A preview shows another version's rows, so its gates read that version's primary, not the live one. */ + const primarySource = isPreview + ? { + model: previewPrimary?.model, + reasoningEffort: previewPrimary?.reasoningEffort, + thinkingLevel: previewPrimary?.thinkingLevel, + verbosity: previewPrimary?.verbosity, + } + : { + model: primaryModelValue, + reasoningEffort: primaryReasoningEffort, + thinkingLevel: primaryThinkingLevel, + verbosity: primaryVerbosity, + } + const primaryModel = typeof primarySource.model === 'string' ? primarySource.model : '' + const { + reasoningEffort: sourceReasoningEffort, + thinkingLevel: sourceThinkingLevel, + verbosity: sourceVerbosity, + } = primarySource + const primaryTuning = useMemo( + () => ({ + reasoningEffort: sourceReasoningEffort, + thinkingLevel: sourceThinkingLevel, + verbosity: sourceVerbosity, + }), + [sourceReasoningEffort, sourceThinkingLevel, sourceVerbosity] + ) + const rows: FallbackModelEntry[] = useMemo(() => { + const value = isPreview ? previewValue : storeValue + return Array.isArray(value) ? value : [] + }, [isPreview, previewValue, storeValue]) + + /** + * `getModelOptions` reads the providers store itself; subscribing to + * `providers` here is what recomputes the list when a dynamic provider's + * models finish loading. + */ + const viableOptions = useMemo( + (): ViableModelOption[] => + getModelOptions() + .filter( + (option) => isModelUsable(option.id) && isViableFallbackModel(option.id, primaryModel) + ) + .map((option) => ({ + label: option.label, + value: option.id, + ...(option.icon ? { icon: option.icon } : {}), + })), + [primaryModel, isModelUsable, providers, deploymentShape] + ) + + const takenModels = useMemo(() => new Set(rows.map((row) => row.model).filter(Boolean)), [rows]) + + const envVarOptions = useMemo((): ComboboxOption[] => { + const names = workspaceId + ? [ + ...Object.keys(workspaceEnv?.workspace ?? {}), + ...Object.keys(workspaceEnv?.personal ?? {}), + ] + : Object.keys(personalEnv) + const options: ComboboxOption[] = [...new Set(names)].map((name) => ({ + label: name, + value: `{{${name}}}`, + })) + options.push({ + label: 'Create Secret', + value: CREATE_SECRET_VALUE, + icon: Plus, + onSelect: () => { + if (workspaceId) { + writePendingCredentialCreateRequest({ + workspaceId, + type: 'env_personal', + requestedAt: Date.now(), + }) + } + navigateToSettings({ section: 'secrets' }) + }, + }) + return options + }, [workspaceId, workspaceEnv, personalEnv, navigateToSettings]) + + /** + * Handlers read the latest rows through a ref so their identity survives an + * edit; otherwise every keystroke in one row would re-render all of them. + */ + const rowsRef = useRef(rows) + useEffect(() => { + rowsRef.current = rows + }, [rows]) + + const write = useCallback( + (transform: (current: FallbackModelEntry[]) => FallbackModelEntry[]) => { + if (readOnly) return + const current = rowsRef.current + const next = transform(current) + if (next !== current) setStoreValue(next) + }, + [readOnly, setStoreValue] + ) + + const handleAdd = useCallback( + () => write((current) => addFallbackRow(current, generateShortId())), + [write] + ) + const handleRemove = useCallback( + (id: string) => write((current) => removeFallbackRow(current, id)), + [write] + ) + const handleMove = useCallback( + (id: string, direction: -1 | 1) => write((current) => moveFallbackRow(current, id, direction)), + [write] + ) + const handleChangeModel = useCallback( + (id: string, model: string) => + write((current) => changeFallbackRowModel(current, id, model, primaryModel)), + [primaryModel, write] + ) + const handleChangeTuning = useCallback( + (id: string, knob: FallbackTuningKnob, value: string) => + write((current) => changeFallbackRowTuning(current, id, knob, value)), + [write] + ) + const handleChangeApiKey = useCallback( + (id: string, apiKey: string) => { + if (apiKey === CREATE_SECRET_VALUE) return + write((current) => changeFallbackRowApiKey(current, id, apiKey)) + }, + [write] + ) + + return ( +
+ {rows.map((row, index) => ( + 1} + primaryModel={primaryModel} + primaryTuning={primaryTuning} + viableOptions={viableOptions} + takenModels={takenModels} + envVarOptions={envVarOptions} + readOnly={readOnly} + onChangeModel={handleChangeModel} + onChangeApiKey={handleChangeApiKey} + onChangeTuning={handleChangeTuning} + onMove={handleMove} + onRemove={handleRemove} + /> + ))} + {!readOnly && ( + = MAX_FALLBACK_MODELS} + > + Add fallback model + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index 27713248118..3130bf0f9ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -337,6 +337,7 @@ export function SelectorCombobox({ /> {showClearButton && ( @@ -801,7 +801,8 @@ export const Panel = memo(function Panel() { )}
@@ -609,6 +614,7 @@ function WorkflowSearchReplacePanel({ focusRef }: WorkflowSearchReplacePanelProp onChange={(event) => setQuery(event.target.value)} /> @@ -357,7 +358,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleSearchClick} aria-label='Search in output' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -375,7 +377,8 @@ export const OutputPanel = React.memo(function OutputPanel({ @@ -393,7 +396,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleCopyClick} aria-label='Copy output' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > {showCopySuccess ? ( @@ -414,7 +418,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleExportConsole} aria-label='Export console CSV' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -429,7 +434,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleClearConsole} aria-label='Clear console' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -446,7 +452,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={(e) => e.stopPropagation()} aria-label='Terminal options' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -511,7 +518,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={goToPreviousMatch} aria-label='Previous match' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' disabled={matchCount === 0} > @@ -520,7 +528,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={goToNextMatch} aria-label='Next match' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' disabled={matchCount === 0} > @@ -529,7 +538,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={closeOutputSearch} aria-label='Close search' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx index 84265672062..e23a97cb40b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx @@ -17,7 +17,8 @@ export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: return ( @@ -1290,7 +1292,8 @@ export const Terminal = memo(function Terminal() { variant='ghost' onClick={handleExportConsole} aria-label='Export console CSV' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -1305,7 +1308,8 @@ export const Terminal = memo(function Terminal() { variant='ghost' onClick={handleClearConsole} aria-label='Clear console' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -1325,7 +1329,8 @@ export const Terminal = memo(function Terminal() { e.stopPropagation() }} aria-label='Terminal options' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 6285e7ac407..2bafb623824 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -51,7 +51,6 @@ export const ROW_STYLES = { status: 'shrink-0 text-sm', statusIdle: 'text-[var(--text-muted)]', nested: 'mt-0.5 ml-[3px] flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-[9px]', - iconButton: 'p-1.5! -m-1.5', } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index 7505fcf6720..0baf5e457a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -458,7 +458,8 @@ export function Variables({ readOnly = false }: VariablesProps) {
)} @@ -1155,7 +1161,13 @@ function PreviewEditorContent({ className='flex-1 text-[var(--text-primary)] text-sm' /> {onClose && ( - )} @@ -1224,6 +1236,7 @@ function PreviewEditorContent({ -
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index b3935655903..04dfea0021a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -23,6 +23,7 @@ import { getDisplayValue, hasDisplayableRowValue, resolveDropdownLabel, + resolveFallbackModelsLabel, resolveFolderPathLabel, resolveSkillsLabel, resolveToolsLabel, @@ -151,6 +152,7 @@ function resolvePreviewDisplayValue( // schema/registry fallbacks rather than the API. const toolsDisplay = resolveToolsLabel(subBlock, rawValue, []) const skillsDisplay = resolveSkillsLabel(subBlock, rawValue, []) + const fallbackModelsDisplay = resolveFallbackModelsLabel(subBlock, rawValue) const workflowName = resolveWorkflowSelectionLabel(subBlock, rawValue, workflowLookup) const workflowMultiSelectionNames = resolveWorkflowMultiSelectLabel( subBlock, @@ -165,6 +167,7 @@ function resolvePreviewDisplayValue( variablesDisplay || toolsDisplay || skillsDisplay || + fallbackModelsDisplay || workflowName || workflowMultiSelectionNames || /* diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index 288bbc4ee31..1a93e446901 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -298,7 +298,7 @@ export function Preview({ @@ -124,7 +124,7 @@ export function GeneratedPasswordInput({ onClick={() => copy(displayValue)} disabled={!displayValue || disabled} aria-label='Copy password' - className='p-1.5!' + iconPadding='md' > {copied ? : } @@ -141,7 +141,7 @@ export function GeneratedPasswordInput({ onClick={toggleShowPassword} disabled={disabled || isFetchingCurrent} aria-label={showPassword ? 'Hide password' : 'Show password'} - className='p-1.5!' + iconPadding='md' > {isFetchingCurrent ? ( diff --git a/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx b/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx new file mode 100644 index 00000000000..8c511cddaac --- /dev/null +++ b/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx @@ -0,0 +1,184 @@ +--- +slug: best-ai-agent-builders-with-mcp-support +title: 'Best AI Agent Builders with MCP Support' +description: 'Compare the best AI agent builders with MCP support, including Sim, n8n, Zapier, Make, and Gumloop, across client and server roles, authentication, deployment, and enterprise controls.' +date: 2026-09-19 +updated: 2026-09-19 +authors: + - andrew +readingTime: 14 +tags: [AI Agents, MCP, Automation, Open Source, Sim] +ogImage: /library/best-ai-agent-builders-with-mcp-support/cover.jpg +canonical: https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support +draft: false +faq: + - q: "What is Model Context Protocol?" + a: "Model Context Protocol, or MCP, gives AI applications a standard way to discover and call external tools. An MCP server publishes tools and their input requirements, while an MCP client lets an agent use them." + - q: "What is the difference between MCP client and MCP server support?" + a: "MCP client support lets a platform connect its agents to tools published by external MCP servers. MCP server support lets the platform publish its own workflows as callable tools for clients such as Claude Desktop, Cursor, or VS Code." + - q: "Can no-code platforms build MCP agents?" + a: "Yes. No-code platforms can build agents that call MCP tools when they provide native client support and visual steps for mapping inputs and outputs. Some platforms limit custom logic, deployment choices, or server creation, so buyers should check both sides of MCP support." + - q: "How do MCP tool calling and authentication work?" + a: "An agent reads the tools published by an MCP server, selects an appropriate tool, and sends arguments that match its input definition. Authentication depends on the server and platform. Common methods include access tokens, OAuth connections, and platform-managed credentials." + - q: "What does a custom remote MCP server require?" + a: "A remote MCP server needs a reachable endpoint that publishes valid tool definitions and handles tool requests. You also need hosting, authentication, and operational monitoring. Sim can publish deployed workflows as remote MCP tools and provides connection configurations for supported clients." + - q: "Which platform fits enterprise MCP use?" + a: "Sim and n8n fit enterprises that require source access, self-hosting, or custom workflow control. Zapier, Make, and Gumloop fit enterprises that prefer vendor-managed visual automation, but their governance features and deployment options differ by plan. Before purchasing any platform, verify its current identity controls and audit logging, along with its data residency and governance options." +--- + +## TL;DR + +- **Sim** consumes MCP tools and exposes deployed workflows as MCP tools. Its open-source codebase and multi-model agent workspace suit buyers building custom agents. +- **n8n** [supports both MCP patterns](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp). It fits technical users who want [low-code workflow control and self-hosting](https://n8n.io/). +- **Zapier** [connects AI clients to app actions through managed MCP servers](https://docs.zapier.com/mcp/home). It fits no-code users already working within Zapier's integration catalog. +- **Make** [supports consuming MCP tools](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) and [exposing scenarios to AI clients](https://help.make.com/make-mcp-server). It fits visual builders who want granular automation control. +- **Gumloop** [supports both MCP patterns](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) for AI-focused workflows. It fits operations users who want [agent automation with limited coding](https://docs.gumloop.com/). + +## What Model Context Protocol support actually means for agent builders + +[Model Context Protocol](https://modelcontextprotocol.io/) lets an AI application discover and call tools through a shared interface. An MCP tool might search a database, update a CRM record, or run an automation. The protocol standardizes how the AI application finds that tool, describes its inputs, and receives its output. + +An MCP client consumes tools published by external MCP servers. For example, an agent builder may connect to a remote server and let its agents call the server's tools. [Sim](https://docs.sim.ai/agents/mcp), [n8n](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp), [Make](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt), and [Gumloop](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) provide ways to connect workflows or agents to external MCP tools. Supported transports and authentication methods vary by platform. + +An MCP server publishes tools that other applications can call. Sim can expose deployed workflows as MCP tools and connect them to supported clients such as Claude Desktop, Cursor, and VS Code. The [Sim MCP deployment documentation](https://docs.sim.ai/workflows/deployment/mcp) covers its supported connection configurations. [n8n can expose selected workflows through its MCP Server Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger). [Make](https://help.make.com/get-started-with-make-mcp-server) and [Gumloop](https://docs.gumloop.com/mcp-server/overview) can make workflows available to compatible clients. [Zapier's MCP offering gives AI clients access to configured app actions](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server). That approach differs from publishing any existing Zap as a custom MCP tool. + +Verify whether each platform consumes external MCP tools, publishes its own tools, or supports both roles before comparing products. A builder that consumes MCP tools can add external capabilities to its own agents, but other AI clients cannot necessarily call the builder's workflows. A builder that publishes workflows may serve Claude or Cursor without supporting external MCP tools inside its own agents. + +Buyers should also compare authentication, model choice, deployment, enterprise controls, and coding requirements. Authentication may rely on static credentials, OAuth, or platform-managed access. Deployment determines whether you can use a hosted endpoint or run the platform on your own infrastructure. When MCP tools can change business data, access policies and execution logs help administrators control and review those actions. Visual platforms reduce routine configuration work, but advanced tools and authentication may still require code. The broader [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) explains how to evaluate those operational requirements. + +## Comparison table: AI agent builders with MCP support + +The table separates platforms that consume external MCP tools from those that make workflows callable by MCP clients. + +| Platform | MCP client support | Exposes workflows as MCP tools | Authentication | Model support | Deployment options | Enterprise controls | Coding required | Best-fit buyer | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **Sim** | [Yes](https://docs.sim.ai/agents/mcp) | [Yes, deployed workflows](https://docs.sim.ai/workflows/deployment/mcp) | API keys and provider credentials | Multi-model and BYOK | [Cloud or self-hosted](https://docs.sim.ai/platform/self-hosting) | [Enterprise access controls](https://docs.sim.ai/platform/enterprise) and self-hosting | Low-code, code optional | Teams building custom, portable agents | +| **n8n** | [Yes](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) | [Yes](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) | [Bearer, header, or OAuth authentication](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) | [Multiple model providers](https://n8n.io/) | [Cloud or self-hosted](https://n8n.io/) | [SSO, role controls, and audit features vary by plan](https://n8n.io/pricing/) | [Low-code](https://n8n.io/) | Technical teams needing workflow control | +| **Zapier** | [Yes, through its MCP Client integration](https://help.zapier.com/hc/en-us/articles/38777069364109-Connect-remote-MCP-servers-to-Zapier-using-MCP-Client) | [Exposes selected app actions rather than existing Zaps](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) | [OAuth and managed connections](https://docs.zapier.com/mcp/home) | Managed within Zapier products | [Managed cloud](https://docs.zapier.com/mcp/home) | [Admin and governance features vary by plan](https://zapier.com/pricing) | [No-code](https://zapier.com/mcp) | Buyers using Zapier's app ecosystem | +| **Make** | [Yes](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) | [Yes, scenarios](https://help.make.com/get-started-with-make-mcp-server) | [Tokens, OAuth, and managed connections](https://help.make.com/mcp-toolboxes) | [Multiple providers through integrations](https://www.make.com/) | [Managed cloud](https://www.make.com/) | [Administration features vary by plan](https://www.make.com/en/pricing) | [No-code to low-code](https://www.make.com/en/pricing) | Visual automation users needing granular scenarios | +| **Gumloop** | [Yes](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) | [Yes](https://docs.gumloop.com/mcp-server/overview) | [API keys, OAuth, and managed connections](https://docs.gumloop.com/mcp-server/overview) | [Multi-model](https://docs.gumloop.com/core-concepts/ai_models) | [Managed cloud](https://docs.gumloop.com/) | [Enterprise identity controls](https://docs.gumloop.com/enterprise-features/sso_saml_scim) | [No-code](https://docs.gumloop.com/) | Operations teams building AI-focused automations | + +Authentication methods and enterprise controls can vary by plan and MCP connection type. Buyers should confirm current limits before choosing a production deployment. + +### Sim + +Sim is an open-source, multi-model workspace for building custom agents with specific tools, models, and data sources. You construct and deploy your own workflows rather than start with a single ready-made assistant. The open-source code lets technical buyers inspect and modify the software and operate a [self-hosted deployment](https://docs.sim.ai/platform/self-hosting). Teams comparing source access and deployment rights can also read this guide to [open-source AI agent frameworks](https://www.sim.ai/library/best-open-source-ai-agent-frameworks). + +Sim can turn a deployed workflow into a tool that other applications call through an MCP server. After you create a server and add the workflow as a tool, Sim provides [connection configurations for supported MCP clients](https://docs.sim.ai/workflows/deployment/mcp). Supported clients include Cursor, Codex, Claude Code, Claude Desktop, VS Code, and Sim itself. Each client can then invoke the workflow through the tool interface instead of reproducing its logic locally. + +Sim supports hosted models and bring-your-own-key access for connecting a provider account. Model and deployment availability can vary by plan and environment, so buyers should verify current terms for their intended setup. The [BYOK and multi-model agent builder guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) covers the tradeoffs behind provider choice. + +**Best for.** Sim fits technical buyers who want to build custom, multi-model agents and expose their workflows to several MCP clients. It also suits buyers who value access to source code and deployment flexibility. + +**Pros.** Sim combines agent construction, workflow deployment, and MCP tool exposure in one workspace. Multi-model access reduces dependence on one model provider, and client-specific configurations simplify connections to common coding and assistant applications. + +**Cons.** Buyers seeking a ready-made assistant may find Sim broader than necessary. Complex custom integrations and self-hosted deployments still require technical ownership. + +**Pricing.** Sim offers hosted and enterprise options alongside its self-hosted codebase. Check Sim's current plan details for feature limits and deployment terms. + +### n8n + +[n8n supports both MCP roles](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) through separate nodes for calling external tools and exposing automations. Its MCP Client Tool node lets an AI Agent node access tools from an external MCP server. The [MCP Server Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) takes the opposite role by making connected n8n tools and workflows available to compatible MCP clients. + +Builders can combine MCP nodes with [n8n's visual automation library, branching logic, and API requests](https://n8n.io/). Most integrations use the visual editor, but uncommon APIs and complex data transformations may require [JavaScript or Python](https://docs.n8n.io/build/code-in-n8n/using-the-code-node). A self-hosted deployment also requires you to manage deployment, updates, security, and availability. + +Authentication depends on the MCP node and server configuration. The MCP Client Tool supports [bearer, header, and OAuth2 methods](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp). You should confirm that the selected transport and authentication method work with the intended client before building production workflows. Self-hosting gives you more infrastructure control, while n8n Cloud reduces operational work. + +**Best for:** Technical teams that want low-code agent workflows, extensive automation controls, and a self-hosting option. + +**Pros** + +- [MCP client](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) and [server](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) patterns support both consuming external tools and exposing n8n capabilities. +- The [workflow editor](https://n8n.io/) provides granular control over branching, data transformations, and error handling. +- n8n provides built-in integrations and [community nodes](https://docs.n8n.io/integrations/community-nodes/installation-and-management/environment-variable-installation) for business applications. +- [Self-hosting](https://n8n.io/) supports buyers with specific infrastructure or data residency requirements. + +**Cons** + +- Complex agents can become difficult to test and maintain as node counts grow. +- Self-hosting requires operational knowledge. +- Custom nodes, unsupported APIs, and advanced transformations may require code. +- [Enterprise governance features depend on the selected plan](https://n8n.io/pricing/). + +**Pricing:** [n8n offers self-hosted and paid cloud options, with cloud pricing based on workflow executions](https://n8n.io/pricing/). Buyers should compare execution limits because agent loops and tool calls can increase usage quickly. + +### Zapier + +Zapier offers a [managed MCP option](https://docs.zapier.com/mcp/home) for users who already automate work through its app catalog. Zapier MCP acts as a hosted server that lets supported AI clients call selected Zapier app actions. You choose which accounts and actions the client can access rather than giving it unrestricted access to every Zapier connection. + +Zapier also supports consuming remote MCP tools through its [MCP Client integration](https://help.zapier.com/hc/en-us/articles/38777069364109-Connect-remote-MCP-servers-to-Zapier-using-MCP-Client). Existing Zaps do not automatically become MCP tools; instead, users configure [app actions as tools](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) or use another supported entry point. + +**Best for:** Operations and business users who want no-code MCP access to apps they already manage through Zapier. + +**Pros:** [Zapier handles MCP hosting, credentials, and connection setup](https://docs.zapier.com/mcp/home). Its integration catalog lets agents use configured actions in supported business applications. [Action-level configuration](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) also limits which capabilities an MCP client can invoke. + +**Cons:** Zapier offers less control over server behavior, deployment, and custom tool logic than open-source or developer-focused platforms. Zapier MCP is vendor-hosted, and advanced workflows remain subject to Zapier's product limits. + +**Pricing:** [Zapier uses plan-based pricing](https://zapier.com/pricing), and MCP-triggered actions may count toward applicable usage limits. Buyers should confirm current MCP access and task allowances for their chosen plan. + +### Make + +Make suits users who want visual control over how an AI agent moves data and calls tools. Its [visual scenario editor](https://www.make.com/) displays each step and its associated filters or data mappings. Buyers can use that detail to configure branching and transformations within a scenario. + +Make supports both MCP directions. Its [MCP Client app](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) can connect scenarios to external MCP servers and call their available tools. Make can also [expose eligible scenarios through its MCP server](https://help.make.com/get-started-with-make-mcp-server) so supported AI clients can run them as tools. Authentication relies on configured connections and access controls, while each scenario defines the actions an AI client can reach. + +**Best for:** Operations and technical users who want detailed visual workflows without building an automation service in code. + +**Pros:** The visual canvas makes branching logic and data transformations easier to inspect. Make also provides an integration catalog and supports both [consuming MCP tools](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) and [making scenarios callable through MCP](https://help.make.com/mcp-toolboxes). + +**Cons:** Complex scenarios can become difficult to maintain as routes and mappings multiply. Standard integrations require little coding, but custom APIs, JSON payloads, and unsupported authentication methods may require technical knowledge. Make is a managed cloud platform rather than a self-hosted open-source platform such as Sim or n8n. + +**Pricing:** [Make offers a free plan and paid tiers based on usage credits](https://www.make.com/en/pricing). Scenario module actions count toward credits, so buyers should estimate costs using expected agent call volume. + +### Gumloop + +Gumloop gives operations and AI teams a [no-code environment for building agent-driven automations](https://docs.gumloop.com/). Its visual workflows center on AI tasks and actions across connected business applications. + +Gumloop supports both sides of MCP. Workflows can [call tools from external MCP servers](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers), and the [Gumloop MCP server](https://docs.gumloop.com/mcp-server/overview) lets compatible clients manage and trigger workflows and agents. The [visual builder](https://docs.gumloop.com/core-concepts/workbooks) removes most coding requirements, although custom APIs and unusual authentication flows may still need technical work. + +**Best for:** Operations and AI teams that want to create MCP-connected agents without managing application code or infrastructure. + +**Pros:** Gumloop combines [no-code workflow design](https://docs.gumloop.com/core-concepts/workbooks) with AI-focused nodes and [reusable subflows](https://docs.gumloop.com/core-concepts/subflows). Its MCP client and server capabilities support agents that consume external tools or provide automations to other MCP clients. + +**Cons:** Gumloop focuses more narrowly on AI workflows than broad automation platforms such as n8n, Zapier, and Make. Buyers with large libraries of conventional business automations should compare connector coverage before migrating. Deployment and infrastructure requirements may also matter more to developer-led or regulated organizations. + +**Pricing:** [Gumloop bills agent chats and workflow runs with credits](https://docs.gumloop.com/core-concepts/credits). Buyers should check its [current pricing page](https://www.gumloop.com/pricing) for workflow, collaboration, and enterprise terms before estimating production costs. + +## How to expose a Sim workflow as an MCP tool + +Sim exposes a workflow through MCP in four steps. + +1. Create an MCP server in Sim. The server groups the workflow tools that external clients can call. +2. Deploy the workflow you want to expose. Deployment creates a callable version of the workflow rather than exposing an unpublished draft. +3. Add the deployed workflow to the MCP server as a tool. Give the tool a clear name and description so the connected model can determine when to call it. +4. Copy the connection configuration that Sim provides for Cursor, Codex, Claude Code, Claude Desktop, VS Code, or Sim. The client can then discover the tool and invoke the workflow with the required inputs. + +Authentication settings control who can connect to the MCP server. Use the configuration and credentials Sim provides, and avoid placing sensitive credentials directly inside workflow prompts. A remote client must also have network access to the deployed MCP endpoint. A self-hosted environment may require network routing and firewall configuration so the client can reach the MCP endpoint. + +Sim's [MCP deployment guide](https://docs.sim.ai/workflows/deployment/mcp) provides the current client-specific configuration fields, authentication instructions, and deployment details. + +## Choosing between MCP client tools and MCP-exposed workflows + +Choose MCP client support when your agent needs to call tools hosted elsewhere. For example, you might connect an agent to an existing CRM or internal service without publishing your own workflow. No-code builders fit this scenario when they provide guided connections, credential storage, and ready-made tool selection. + +Choose MCP server support when external assistants need to call your workflow. For example, you might package an approval process or data lookup as a reusable tool for Cursor, Claude Desktop, or another MCP client. Buyers should verify that the platform can deploy remote endpoints, define tool inputs, and authenticate incoming requests. + +Choose a platform that supports both patterns when agents must consume external tools and provide capabilities to other clients. For example, an internal research agent could query third-party data through MCP and expose its completed report workflow as another MCP tool. A platform that handles both roles can keep tool consumption and workflow publishing in the same workspace. + +Enterprise requirements can narrow the choice. Regulated buyers should verify identity controls such as SSO and role-based access. They should also examine audit logs and policies for secrets and data retention. Self-hosting matters when company policy prevents workflows or credentials from running in a vendor-managed cloud. + +Coding requirements determine who can maintain the deployment. Ops users benefit from visual tool configuration and managed authentication. Developers may prefer low-code or open-source platforms when they need custom server logic, private network access, or control over deployment. Before committing, test authentication and logging with a representative workflow. A feature checklist cannot show how much configuration a specific connection requires. The guide to the [best no-code and low-code AI agent builders](https://www.sim.ai/library/best-no-code-ai-agent-builders-2026) provides another view of this maintenance tradeoff. + +## Why Sim fits teams building custom agents beyond a single assistant + +Sim fits buyers who need custom agents with tailored access to company tools and data. Its open-source codebase supports [self-hosting and modification](https://docs.sim.ai/platform/self-hosting). Multi-model support lets buyers configure workflows with more than one model provider. + +Sim provides blocks and logs for reviewing outputs, approving runs, and inspecting execution. Human-in-the-loop blocks can pause a run for approval, and guardrails can restrict inputs or outputs. Evaluator blocks assess results against defined criteria. Wait blocks suspend execution, while run logs support debugging and review. + +Sim makes the most sense when developers or technical operators will extend, deploy, and govern the agent workspace. Buyers seeking pure no-code automation with minimal engineering involvement may find Zapier or Gumloop easier to adopt. Teams centered on general workflow automation and self-hosting should also compare n8n before deciding. + +## Conclusion + +Choose an MCP agent builder by confirming whether it consumes external tools, exposes workflows to MCP clients, or supports both roles. Then compare authentication, deployment, governance, and coding requirements. + +Sim fits buyers who want an open-source, multi-model workspace for building custom agents and exposing deployed workflows as MCP tools. Buyers focused on familiar no-code automation may prefer another platform. Use the comparison table to identify suitable platforms. If Sim matches your requirements, follow [Sim's MCP deployment guide](https://docs.sim.ai/workflows/deployment/mcp) for setup instructions. diff --git a/apps/sim/ee/README.md b/apps/sim/ee/README.md index 6a7ee3c8d52..c84b39518a3 100644 --- a/apps/sim/ee/README.md +++ b/apps/sim/ee/README.md @@ -7,6 +7,7 @@ for production use. - **SSO (Single Sign-On)**: OIDC and SAML authentication integration - **Access Control**: Permission groups for fine-grained user access management +- **Access Requests**: Members request restricted features or a higher credit cap; organization admins review and apply them. On for every entitled organization unless it opts out - **Whitelabeling**: Custom branding and theming for enterprise deployments - **Directory provisioning (SCIM)**: SCIM 2.0 user and group provisioning from Okta, Microsoft Entra, and other identity providers, with group-to-access mapping diff --git a/apps/sim/ee/access-control/components/access-control-layout.test.tsx b/apps/sim/ee/access-control/components/access-control-layout.test.tsx new file mode 100644 index 00000000000..e47f7ebcbab --- /dev/null +++ b/apps/sim/ee/access-control/components/access-control-layout.test.tsx @@ -0,0 +1,175 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ groups: vi.fn(), requests: vi.fn() })) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace' }), + usePathname: () => '/settings/access-control', +})) +vi.mock('@/ee/access-control/components/group-detail', () => ({ GroupDetail: () => null })) +vi.mock('@/ee/access-requests/components/access-request-review', () => ({ + AccessRequestReview: () => null, +})) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useCreatePermissionGroup: () => ({ isPending: false, mutateAsync: vi.fn() }), + useOrganizationWorkspaces: () => ({ data: [], isPending: false }), + usePermissionGroups: mocks.groups, + useUserPermissionConfig: () => ({ data: { entitled: true }, isPending: false }), +})) +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizationBilling: () => ({ data: undefined, isPending: false }), +})) +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ + ACCESS_REQUEST_PAGE_SIZE: 25, + useOrganizationAccessRequests: mocks.requests, + useAccessRequestSettings: () => ({ data: { allowRequests: true }, isPending: false }), + useUpdateAccessRequestSettings: () => ({ mutate: vi.fn(), isPending: false }), +})) + +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsSectionProvider } from '@/components/settings/settings-panel' +import { AccessControl } from '@/ee/access-control/components/access-control' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.groups.mockReturnValue({ data: [], isPending: false }) + mocks.requests.mockReturnValue({ data: { requests: [], hasMore: false }, isPending: false }) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() +}) + +function render(searchParams = '') { + act(() => + root.render( + + + + + + + + + + ) + ) +} + +function switchView(value: string) { + const button = container.querySelector( + `[aria-label="Access Control views"] [role="radio"][value="${value}"]` + ) + expect(button).not.toBeNull() + act(() => button!.click()) +} + +describe('permission groups search layout', () => { + it('resets pagination and hides stale results while a new request search is debounced', async () => { + vi.useFakeTimers() + mocks.requests.mockReturnValue({ + data: { + requests: [ + { + id: 'request', + targetLabel: 'Previous result', + requester: { name: 'Member' }, + status: 'pending', + createdAt: '2026-09-01T00:00:00Z', + }, + ], + hasMore: true, + }, + isPending: false, + }) + render('?access-view=requests&request-page=2') + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 50, 'pending', '') + const input = container.querySelector('input')! + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Tables' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(container.textContent).toContain('Loading requests...') + expect(container.textContent).not.toContain('Previous result') + expect(container.textContent).not.toContain('Page 3') + await act(async () => vi.advanceTimersByTimeAsync(500)) + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') + }) + it('keeps the same search input above the switch and restores each view’s search', () => { + render('?search=Design&request-search=Tables') + const input = container.querySelector('input')! + const viewSwitch = container.querySelector('[aria-label="Access Control views"]')! + expect(input.value).toBe('Design') + expect( + input.compareDocumentPosition(viewSwitch) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(input.placeholder).toBe('Search requests...') + expect(input.value).toBe('Tables') + expect(input.maxLength).toBe(200) + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') + expect(container.textContent).toContain('No requests found matching "Tables"') + + switchView('groups') + expect(container.querySelector('input')).toBe(input) + expect(input.value).toBe('Design') + expect(input.placeholder).toBe('Search permission groups...') + expect(input.hasAttribute('maxlength')).toBe(false) + }) + + it('retains search when either list is loading or fails', () => { + mocks.groups.mockReturnValue({ isPending: true }) + render() + const input = container.querySelector('input')! + expect(input.disabled).toBe(true) + mocks.requests.mockReturnValue({ isPending: true }) + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(input.disabled).toBe(false) + expect(container.textContent).toContain('Loading requests...') + + mocks.groups.mockReturnValue({ + isPending: false, + error: new Error('Groups unavailable'), + isFetching: false, + refetch: vi.fn(), + }) + switchView('groups') + expect(container.querySelector('input')).toBe(input) + expect(container.textContent).toContain('Groups unavailable') + + mocks.requests.mockReturnValue({ + isPending: false, + isError: true, + error: new Error('Requests unavailable'), + isFetching: false, + refetch: vi.fn(), + }) + switchView('requests') + expect(container.querySelector('input')).toBe(input) + expect(container.textContent).toContain('Requests unavailable') + }) +}) diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx index bd2718e1684..a3538511c08 100644 --- a/apps/sim/ee/access-control/components/access-control.test.tsx +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -32,7 +32,7 @@ vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()], useQueryStates: () => [{ 'access-view': 'groups' }, vi.fn()], })) -vi.mock('@/components/access-requests/organization-access-requests', () => ({ +vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ OrganizationAccessRequests: () => null, })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 97aaf7dac7c..e013167a580 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -18,11 +18,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' -import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' -import { - accessRequestUrlOptions, - accessReviewSearchParams, -} from '@/components/access-requests/search-params' import { isEnterprise } from '@/lib/billing/plan-helpers' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { @@ -54,6 +49,11 @@ import { usePermissionGroups, useUserPermissionConfig, } from '@/ee/access-control/hooks/permission-groups' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' +import { + accessRequestUrlOptions, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' import { useOrganizationBilling } from '@/hooks/queries/organization' const logger = createLogger('AccessControl') @@ -78,9 +78,7 @@ export function AccessControl(props: AccessControlProps) { onChange={(value) => void setParams({ 'access-view': value, 'request-id': null })} /> {params['access-view'] === 'requests' ? ( - - - + ) : ( )} @@ -150,9 +148,6 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl ...groupIdUrlKeys, }) - // Params scoped to the detail sub-view are cleared alongside the group id, so - // a tab/search/filter can't linger on the list URL after going back. nuqs - // batches these same-tick writes into a single URL update. const [, setGroupTab] = useQueryState(groupTabParam.key, { ...groupTabParam.parser, ...groupTabUrlKeys, @@ -197,16 +192,13 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl [organizationWorkspaces] ) - const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) return permissionGroups - const searchLower = searchTerm.toLowerCase() - return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower)) - }, [permissionGroups, searchTerm]) - - const selectedGroup = useMemo( - () => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined), - [permissionGroups, selectedGroupId] - ) + const searchLower = searchTerm.trim().toLowerCase() + const filteredGroups = searchLower + ? permissionGroups.filter((group) => group.name.toLowerCase().includes(searchLower)) + : permissionGroups + const selectedGroup = selectedGroupId + ? permissionGroups.find((group) => group.id === selectedGroupId) + : undefined const handleCreatePermissionGroup = async () => { if (!newGroupName.trim() || !organizationId) return @@ -284,7 +276,7 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl if (groupsError) { return ( - + - -
+ + {(aria) => ( - {!newGroupIsDefault && ( -

- Applies to all members of the selected workspaces. Restrict to specific people - later from the group's Members section. -

- )} -
+ )}
{createError} diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx index 8f6ab20a7fd..0b5c758c32a 100644 --- a/apps/sim/ee/access-control/components/workspace-select.tsx +++ b/apps/sim/ee/access-control/components/workspace-select.tsx @@ -1,8 +1,12 @@ 'use client' -import { ChipDropdown } from '@sim/emcn' +import { ChipDropdown, type ChipDropdownProps } from '@sim/emcn' -interface WorkspaceSelectProps { +interface WorkspaceSelectProps + extends Pick< + ChipDropdownProps, + 'id' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-required' | 'aria-invalid' + > { workspaceIds: string[] onChange: (ids: string[]) => void options: { value: string; label: string }[] @@ -31,9 +35,11 @@ export function WorkspaceSelect({ fullWidth = false, className, allowAllWorkspaces = true, + ...fieldAria }: WorkspaceSelectProps) { return ( ({ ...(await importOriginal()), @@ -13,7 +13,7 @@ vi.mock('@sim/emcn', async (importOriginal) => ({ })) const mocks = vi.hoisted(() => ({ preview: vi.fn(), resolve: vi.fn() })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useAccessRequestPreview: mocks.preview, useResolveAccessRequest: mocks.resolve, })) diff --git a/apps/sim/components/access-requests/access-request-review.tsx b/apps/sim/ee/access-requests/components/access-request-review.tsx similarity index 96% rename from apps/sim/components/access-requests/access-request-review.tsx rename to apps/sim/ee/access-requests/components/access-request-review.tsx index db68369704f..6772705a1fc 100644 --- a/apps/sim/components/access-requests/access-request-review.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.tsx @@ -11,9 +11,12 @@ import { ChipModalHeader, toast, } from '@sim/emcn' -import { PolicyChanges } from '@/components/access-requests/policy-changes' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' -import { useAccessRequestPreview, useResolveAccessRequest } from '@/hooks/queries/access-requests' +import { PolicyChanges } from '@/ee/access-requests/components/policy-changes' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { + useAccessRequestPreview, + useResolveAccessRequest, +} from '@/ee/access-requests/hooks/access-requests' interface AccessRequestReviewProps { organizationId: string diff --git a/apps/sim/components/access-requests/access-requests-loading.tsx b/apps/sim/ee/access-requests/components/access-requests-loading.tsx similarity index 100% rename from apps/sim/components/access-requests/access-requests-loading.tsx rename to apps/sim/ee/access-requests/components/access-requests-loading.tsx diff --git a/apps/sim/components/access-requests/member-limit-request-action.tsx b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx similarity index 82% rename from apps/sim/components/access-requests/member-limit-request-action.tsx rename to apps/sim/ee/access-requests/components/member-limit-request-action.tsx index f174e442c38..f02409de2ad 100644 --- a/apps/sim/components/access-requests/member-limit-request-action.tsx +++ b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx @@ -1,8 +1,8 @@ 'use client' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' -import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' interface MemberLimitRequestActionProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/my-access-request-details.tsx b/apps/sim/ee/access-requests/components/my-access-request-details.tsx similarity index 94% rename from apps/sim/components/access-requests/my-access-request-details.tsx rename to apps/sim/ee/access-requests/components/my-access-request-details.tsx index 5eb44c1e1fc..12b69995577 100644 --- a/apps/sim/components/access-requests/my-access-request-details.tsx +++ b/apps/sim/ee/access-requests/components/my-access-request-details.tsx @@ -11,9 +11,12 @@ import { ChipTag, toast, } from '@sim/emcn' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' -import { useCancelAccessRequest, useMyAccessRequests } from '@/hooks/queries/access-requests' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { + useCancelAccessRequest, + useMyAccessRequests, +} from '@/ee/access-requests/hooks/access-requests' interface MyAccessRequestDetailsProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx similarity index 96% rename from apps/sim/components/access-requests/my-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/my-access-requests.test.tsx index 2913b635dd8..b62b94f4232 100644 --- a/apps/sim/components/access-requests/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MyAccessRequests } from '@/components/access-requests/my-access-requests' +import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' const mocks = vi.hoisted(() => ({ mine: vi.fn(), @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), url: vi.fn(), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useMyAccessRequests: mocks.mine, useDiscoverAccessRequests: mocks.discovery, diff --git a/apps/sim/components/access-requests/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx similarity index 93% rename from apps/sim/components/access-requests/my-access-requests.tsx rename to apps/sim/ee/access-requests/components/my-access-requests.tsx index 3504065a114..fe9a579f934 100644 --- a/apps/sim/components/access-requests/my-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.tsx @@ -3,27 +3,27 @@ import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn' import { Lock, Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' -import { MyAccessRequestDetails } from '@/components/access-requests/my-access-request-details' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' -import { - accessRequestSearchParams, - accessRequestUrlOptions, -} from '@/components/access-requests/search-params' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' import { EmptyState } from '@/components/empty-state/empty-state' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { WORKSPACES_PATH } from '@/lib/navigation/paths' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/lib/permission-access-requests/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { + accessRequestSearchParams, + accessRequestUrlOptions, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' import { ACCESS_REQUEST_PAGE_SIZE, useDiscoverAccessRequests, useMyAccessRequests, -} from '@/hooks/queries/access-requests' +} from '@/ee/access-requests/hooks/access-requests' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' diff --git a/apps/sim/components/access-requests/organization-access-requests.test.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx similarity index 95% rename from apps/sim/components/access-requests/organization-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/organization-access-requests.test.tsx index 216b03924ed..22a170ba1e4 100644 --- a/apps/sim/components/access-requests/organization-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx @@ -11,17 +11,17 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), refetch: vi.fn(), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useAccessRequestSettings: mocks.settings, useOrganizationAccessRequests: mocks.requests, useUpdateAccessRequestSettings: mocks.update, })) -vi.mock('@/components/access-requests/access-request-review', () => ({ +vi.mock('@/ee/access-requests/components/access-request-review', () => ({ AccessRequestReview: () => null, })) -import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' describe('organization access request settings', () => { let container: HTMLDivElement diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.tsx new file mode 100644 index 00000000000..f31ddb33008 --- /dev/null +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -0,0 +1,212 @@ +'use client' + +import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { AccessRequestReview } from '@/ee/access-requests/components/access-request-review' +import { + accessRequestUrlOptions, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { + ACCESS_REQUEST_PAGE_SIZE, + useAccessRequestSettings, + useOrganizationAccessRequests, + useUpdateAccessRequestSettings, +} from '@/ee/access-requests/hooks/access-requests' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +interface OrganizationAccessRequestsProps { + organizationId: string + standalone?: boolean +} + +export function OrganizationAccessRequests({ + organizationId, + standalone = false, +}: OrganizationAccessRequestsProps) { + const [params, setParams] = useQueryStates(accessReviewSearchParams, { + ...accessRequestUrlOptions, + urlKeys: { 'request-id': standalone ? 'requestId' : 'request-id' }, + }) + const searchTerm = params['request-search'] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setParams({ 'request-search': value, 'request-page': 0 }, options) + ) + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const searchPending = searchTerm.trim() !== debouncedSearch + const page = params['request-page'] + const requests = useOrganizationAccessRequests( + organizationId, + page * ACCESS_REQUEST_PAGE_SIZE, + params['request-status'], + debouncedSearch + ) + const settings = useAccessRequestSettings(organizationId) + const updateSettings = useUpdateAccessRequestSettings(organizationId) + + const content = ( +
+ {settings.isPending ? ( + Loading request settings... + ) : settings.isError ? ( + void settings.refetch()} + /> + ) : ( + + updateSettings.mutate(value === 'enabled', { + onError: (error) => toast.error(error.message), + }) + } + disabled={updateSettings.isPending} + /> + } + /> + )} + + void setParams({ + 'request-status': value as (typeof params)['request-status'], + 'request-page': 0, + }) + } + options={[ + { value: 'pending', label: 'Pending' }, + { value: 'fulfilled', label: ACCESS_REQUEST_STATUS_LABELS.fulfilled }, + { value: 'declined', label: 'Declined' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'closed', label: 'Closed' }, + { value: 'all', label: 'All requests' }, + ]} + aria-label='Filter request status' + /> + } + > + {searchPending || requests.isPending ? ( + + Loading requests... + + ) : requests.isError ? ( + void requests.refetch()} + /> + ) : ( +
+ {requests.data.requests.length === 0 && ( + + {debouncedSearch + ? `No requests found matching "${searchTerm.trim()}"` + : 'No access requests. Requests from your members will appear here.'} + + )} + {requests.data.requests.map((request) => ( + {ACCESS_REQUEST_STATUS_LABELS[request.status]} + } + onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })} + clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`} + navigable + /> + ))} +
+ )} + {!searchPending && (page > 0 || requests.data?.hasMore) && ( +
+ void setParams({ 'request-page': page - 1 })} + > + Previous + + Page {page + 1} + void setParams({ 'request-page': page + 1 })} + > + Next + +
+ )} +
+ {params['request-id'] && ( + void setParams({ 'request-id': null })} + /> + )} +
+ ) + + const search = { + value: searchTerm, + onChange: setSearchTerm, + placeholder: 'Search requests...', + maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH, + } + + return standalone ? ( +
+ search.onChange(event.target.value)} + placeholder={search.placeholder} + maxLength={search.maxLength} + aria-label='Search requests' + autoComplete='off' + /> + {content} +
+ ) : ( + {content} + ) +} diff --git a/apps/sim/components/access-requests/permission-access-boundary.test.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx similarity index 95% rename from apps/sim/components/access-requests/permission-access-boundary.test.tsx rename to apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx index 6dc658227b3..a48b07d88a6 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.test.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx @@ -34,12 +34,14 @@ vi.mock('@sim/emcn/icons', () => ({ BookOpen: () => null, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: policy })) -vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: discovery })) -vi.mock('@/components/access-requests/request-access-action', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ + useDiscoverAccessRequests: discovery, +})) +vi.mock('@/ee/access-requests/components/request-access-action', () => ({ RequestAccessAction: () => , })) -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' describe('PermissionAccessBoundary', () => { let container: HTMLDivElement diff --git a/apps/sim/components/access-requests/permission-access-boundary.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx similarity index 94% rename from apps/sim/components/access-requests/permission-access-boundary.tsx rename to apps/sim/ee/access-requests/components/permission-access-boundary.tsx index bc111f3e694..77d0f78487b 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx @@ -3,15 +3,15 @@ import type { ReactNode } from 'react' import { Chip } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import { EmptyState } from '@/components/empty-state/empty-state' import type { BooleanPermissionGroupConfigKey } from '@/lib/permission-groups/features' import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' -import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' -import { workspaceFeatureDiscoveryQuery } from '@/hooks/queries/utils/access-request-keys' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { workspaceFeatureDiscoveryQuery } from '@/ee/access-requests/hooks/access-request-keys' +import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' /** Safe feature metadata shared by navigation and access-required pages. */ export function useWorkspaceAccessRequestFeatures() { diff --git a/apps/sim/components/access-requests/policy-changes.test.ts b/apps/sim/ee/access-requests/components/policy-changes.test.ts similarity index 99% rename from apps/sim/components/access-requests/policy-changes.test.ts rename to apps/sim/ee/access-requests/components/policy-changes.test.ts index ace9e0ae6c6..173bbff5f98 100644 --- a/apps/sim/components/access-requests/policy-changes.test.ts +++ b/apps/sim/ee/access-requests/components/policy-changes.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { describePolicyChange, describePolicyValue, -} from '@/components/access-requests/policy-changes' +} from '@/ee/access-requests/components/policy-changes' const target = { kind: 'integration', id: 'slack_v2' } as const diff --git a/apps/sim/components/access-requests/policy-changes.tsx b/apps/sim/ee/access-requests/components/policy-changes.tsx similarity index 100% rename from apps/sim/components/access-requests/policy-changes.tsx rename to apps/sim/ee/access-requests/components/policy-changes.tsx diff --git a/apps/sim/components/access-requests/request-access-action.test.tsx b/apps/sim/ee/access-requests/components/request-access-action.test.tsx similarity index 98% rename from apps/sim/components/access-requests/request-access-action.test.tsx rename to apps/sim/ee/access-requests/components/request-access-action.test.tsx index 8a4263d68ff..7a13b40ac72 100644 --- a/apps/sim/components/access-requests/request-access-action.test.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.test.tsx @@ -4,8 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import type { AccessRequestTarget } from '@/lib/api/contracts/access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' const mocks = vi.hoisted(() => ({ discovery: vi.fn(), @@ -17,7 +17,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), usePathname: () => '/workspace/workspace', })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }), useDiscoverAccessRequests: mocks.discovery, })) diff --git a/apps/sim/components/access-requests/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx similarity index 97% rename from apps/sim/components/access-requests/request-access-action.tsx rename to apps/sim/ee/access-requests/components/request-access-action.tsx index 34e5703d77f..49f5641ce3d 100644 --- a/apps/sim/components/access-requests/request-access-action.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.tsx @@ -17,8 +17,11 @@ import { import { Lock } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import { getAccessRequestTargetKey } from '@/lib/permission-groups/access-requests/targets' -import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { + useCreateAccessRequest, + useDiscoverAccessRequests, +} from '@/ee/access-requests/hooks/access-requests' +import { getAccessRequestTargetKey } from '@/ee/access-requests/lib/targets' interface RequestAccessActionProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/search-params.test.ts b/apps/sim/ee/access-requests/components/search-params.test.ts similarity index 79% rename from apps/sim/components/access-requests/search-params.test.ts rename to apps/sim/ee/access-requests/components/search-params.test.ts index b13b6dff99e..98846339a08 100644 --- a/apps/sim/components/access-requests/search-params.test.ts +++ b/apps/sim/ee/access-requests/components/search-params.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { accessRequestSearchParams, accessReviewSearchParams, -} from '@/components/access-requests/search-params' +} from '@/ee/access-requests/components/search-params' describe('access request URL bounds', () => { it.each(['-1', '1.5', '40001', '1e3', '999999999999999999999'])( @@ -20,5 +20,7 @@ describe('access request URL bounds', () => { expect(accessRequestSearchParams.requestId.parse('request-1')).toBe('request-1') expect(accessRequestSearchParams.requestId.parse('x'.repeat(129))).toBeNull() expect(accessRequestSearchParams.search.parse('x'.repeat(201))).toBeNull() + expect(accessReviewSearchParams['request-search'].parse('Tables')).toBe('Tables') + expect(accessReviewSearchParams['request-search'].parse('x'.repeat(201))).toBeNull() }) }) diff --git a/apps/sim/components/access-requests/search-params.ts b/apps/sim/ee/access-requests/components/search-params.ts similarity index 92% rename from apps/sim/components/access-requests/search-params.ts rename to apps/sim/ee/access-requests/components/search-params.ts index afc9c44f46a..efe8d9cdb99 100644 --- a/apps/sim/components/access-requests/search-params.ts +++ b/apps/sim/ee/access-requests/components/search-params.ts @@ -4,7 +4,7 @@ import { ACCESS_REQUEST_MAX_ID_LENGTH, ACCESS_REQUEST_MAX_OFFSET, ACCESS_REQUEST_MAX_SEARCH_LENGTH, -} from '@/lib/permission-access-requests/constants' +} from '@/ee/access-requests/lib/constants' const accessRequestPageParser = createParser({ parse(value) { @@ -39,6 +39,7 @@ export const accessRequestSearchParams = { export const accessReviewSearchParams = { 'access-view': parseAsStringLiteral(['groups', 'requests'] as const).withDefault('groups'), 'request-id': accessRequestIdParser, + 'request-search': accessRequestSearchParser, 'request-page': accessRequestPageParser, 'request-status': parseAsStringLiteral([ 'pending', @@ -57,5 +58,6 @@ export const accessRequestEntrySearchParams = { organizationId: accessRequestIdParser, view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'), 'request-page': accessReviewSearchParams['request-page'], + 'request-search': accessReviewSearchParams['request-search'], 'request-status': accessReviewSearchParams['request-status'], } as const diff --git a/apps/sim/components/access-requests/status.ts b/apps/sim/ee/access-requests/components/status.ts similarity index 100% rename from apps/sim/components/access-requests/status.ts rename to apps/sim/ee/access-requests/components/status.ts diff --git a/apps/sim/hooks/queries/utils/access-request-keys.ts b/apps/sim/ee/access-requests/hooks/access-request-keys.ts similarity index 89% rename from apps/sim/hooks/queries/utils/access-request-keys.ts rename to apps/sim/ee/access-requests/hooks/access-request-keys.ts index 3f53588e4d4..a279fab5bfe 100644 --- a/apps/sim/hooks/queries/utils/access-request-keys.ts +++ b/apps/sim/ee/access-requests/hooks/access-request-keys.ts @@ -11,8 +11,13 @@ export const accessRequestKeys = { lists: () => [...accessRequestKeys.all, 'list'] as const, mine: (scope: AccessRequestScope, offset: number, requestId?: string) => [...accessRequestKeys.lists(), 'mine', scope, offset, requestId ?? ''] as const, - organization: (organizationId: string, offset: number, status: AccessRequestStatus | 'all') => - [...accessRequestKeys.lists(), 'organization', organizationId, offset, status] as const, + organization: ( + organizationId: string, + offset: number, + status: AccessRequestStatus | 'all', + search = '' + ) => + [...accessRequestKeys.lists(), 'organization', organizationId, offset, status, search] as const, discoveries: () => [...accessRequestKeys.all, 'discovery'] as const, discovery: (query: DiscoverAccessRequestsQuery) => [...accessRequestKeys.discoveries(), query] as const, diff --git a/apps/sim/hooks/queries/access-requests.test.tsx b/apps/sim/ee/access-requests/hooks/access-requests.test.tsx similarity index 98% rename from apps/sim/hooks/queries/access-requests.test.tsx rename to apps/sim/ee/access-requests/hooks/access-requests.test.tsx index 21d97bf12b7..9ce34d47fc1 100644 --- a/apps/sim/hooks/queries/access-requests.test.tsx +++ b/apps/sim/ee/access-requests/hooks/access-requests.test.tsx @@ -14,12 +14,12 @@ import { listMyAccessRequestsContract, resolveAccessRequestContract, } from '@/lib/api/contracts/access-requests' +import { accessRequestKeys } from '@/ee/access-requests/hooks/access-request-keys' import { useDiscoverAccessRequests, useMyAccessRequests, useResolveAccessRequest, -} from '@/hooks/queries/access-requests' -import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-requests' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/ee/access-requests/hooks/access-requests.ts similarity index 95% rename from apps/sim/hooks/queries/access-requests.ts rename to apps/sim/ee/access-requests/hooks/access-requests.ts index 2c1b6d3a38a..9b1d31d5c6d 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/ee/access-requests/hooks/access-requests.ts @@ -22,11 +22,11 @@ import type { WorkspaceCreditAvailability, WorkspaceUsageGate, } from '@/lib/api/contracts/workspaces' -import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/lib/permission-access-requests/constants' import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' +import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' @@ -106,14 +106,20 @@ export function useMyAccessRequests( export function useOrganizationAccessRequests( organizationId: string, offset = 0, - status: AccessRequestStatus | 'all' = 'pending' + status: AccessRequestStatus | 'all' = 'pending', + search = '' ) { return useQuery({ - queryKey: accessRequestKeys.organization(organizationId, offset, status), + queryKey: accessRequestKeys.organization(organizationId, offset, status, search), queryFn: ({ signal }) => requestJson(listOrganizationAccessRequestsContract, { params: { id: organizationId }, - query: { offset, limit: ACCESS_REQUEST_PAGE_SIZE, ...(status === 'all' ? {} : { status }) }, + query: { + offset, + limit: ACCESS_REQUEST_PAGE_SIZE, + ...(status === 'all' ? {} : { status }), + ...(search ? { search } : {}), + }, signal, }), enabled: Boolean(organizationId), diff --git a/apps/sim/lib/permission-access-requests/application/authorization.test.ts b/apps/sim/ee/access-requests/lib/application/authorization.test.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/application/authorization.test.ts rename to apps/sim/ee/access-requests/lib/application/authorization.test.ts index 679a19206fb..bb0e652f8e8 100644 --- a/apps/sim/lib/permission-access-requests/application/authorization.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.test.ts @@ -18,8 +18,8 @@ import type { DbOrTx } from '@/lib/db/types' import { authorizeAccessRequestScope, loadAccessRequestMembership, -} from '@/lib/permission-access-requests/application/authorization' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +} from '@/ee/access-requests/lib/application/authorization' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' const principal: SessionPrincipal = { kind: 'session', userId: 'person', sessionId: 'session' } const workspaceScope = { kind: 'workspace' as const, workspaceId: 'workspace' } diff --git a/apps/sim/lib/permission-access-requests/application/authorization.ts b/apps/sim/ee/access-requests/lib/application/authorization.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/authorization.ts rename to apps/sim/ee/access-requests/lib/application/authorization.ts index 0dac5ff2e7c..6e613b6c737 100644 --- a/apps/sim/lib/permission-access-requests/application/authorization.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.ts @@ -15,8 +15,8 @@ import { } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' export interface AccessRequestContext { organizationId: string | null diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts rename to apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts index 607dc7a6963..652f072253b 100644 --- a/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbound: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -25,8 +25,8 @@ vi.mock('@/lib/core/network/context.server', () => ({ import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' const principal: SessionPrincipal = { kind: 'session', userId: 'requester', sessionId: 'session' } const scope: AccessRequestScope = { kind: 'workspace', workspaceId: 'workspace' } diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts similarity index 94% rename from apps/sim/lib/permission-access-requests/application/authorized-use-case.ts rename to apps/sim/ee/access-requests/lib/application/authorized-use-case.ts index 7eea4a76977..3672ee15f84 100644 --- a/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts @@ -12,9 +12,9 @@ import type { DbOrTx } from '@/lib/db/types' import { type AccessRequestContext, authorizeAccessRequestScope, -} from '@/lib/permission-access-requests/application/authorization' -import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +} from '@/ee/access-requests/lib/application/authorization' +import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' interface AccessRequestPreparationArgs { principal: SessionPrincipal diff --git a/apps/sim/lib/permission-access-requests/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/application/operations.ts rename to apps/sim/ee/access-requests/lib/application/operations.ts diff --git a/apps/sim/lib/permission-access-requests/application/prepare.ts b/apps/sim/ee/access-requests/lib/application/prepare.ts similarity index 67% rename from apps/sim/lib/permission-access-requests/application/prepare.ts rename to apps/sim/ee/access-requests/lib/application/prepare.ts index a05269b1f9d..11568b99267 100644 --- a/apps/sim/lib/permission-access-requests/application/prepare.ts +++ b/apps/sim/ee/access-requests/lib/application/prepare.ts @@ -1,10 +1,9 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { loadAccessRequestCatalog } from '@/lib/permission-access-requests/catalog' -import type { AccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { loadAccessRequestCatalog } from '@/ee/access-requests/lib/catalog' +import type { AccessRequestTarget } from '@/ee/access-requests/lib/targets' /** Resolve deployment metadata before opening a transaction or taking policy locks. */ export async function prepareAccessRequestPolicy( @@ -17,7 +16,7 @@ export async function prepareAccessRequestPolicy( 'forbidden', 'Access requests require an organization-owned workspace' ) - const [catalog, entitled, globalEnabled] = await Promise.all([ + const [catalog, entitled] = await Promise.all([ loadAccessRequestCatalog( { organizationId: context.organizationId, @@ -29,9 +28,8 @@ export async function prepareAccessRequestPolicy( isHosted ? isOrganizationOnEnterprisePlan(context.organizationId) : Promise.resolve(isAccessControlEnabled), - isFeatureEnabled('permission-access-requests'), ]) - return { catalog, entitled, globalEnabled } + return { catalog, entitled } } export type PreparedAccessRequestPolicy = Awaited> diff --git a/apps/sim/lib/permission-access-requests/application/requests.test.ts b/apps/sim/ee/access-requests/lib/application/requests.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/requests.test.ts rename to apps/sim/ee/access-requests/lib/application/requests.test.ts index 616c518ca57..c3e20698881 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.test.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.test.ts @@ -11,9 +11,9 @@ import { import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' -import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import type { StoredAccessRequest } from '@/ee/access-requests/lib/repository' +import { createAccessRequestCatalog } from '@/ee/access-requests/lib/targets' const mocks = vi.hoisted(() => ({ authorize: vi.fn(), @@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbox: vi.fn(), enabled: vi.fn(), - featureEnabled: vi.fn(), enterprise: vi.fn(), catalog: vi.fn(), targets: vi.fn(), @@ -34,7 +33,7 @@ const mocks = vi.hoisted(() => ({ list: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, loadAccessRequestMembership: mocks.membership, })) @@ -46,16 +45,15 @@ vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ })) vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mocks.enabled, readAccessRequestSettings: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, })) -vi.mock('@/lib/permission-access-requests/catalog', () => ({ +vi.mock('@/ee/access-requests/lib/catalog', () => ({ loadAccessRequestCatalog: mocks.catalog, listAccessRequestTargets: mocks.targets, getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, @@ -64,7 +62,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group, resolveDefaultGroup: mocks.group, })) -vi.mock('@/lib/permission-access-requests/repository', () => ({ +vi.mock('@/ee/access-requests/lib/repository', () => ({ presentAccessRequest: mocks.present, loadStoredAccessRequest: mocks.stored, listAccessRequestRecords: mocks.list, @@ -75,11 +73,11 @@ import { createAccessRequest, discoverAccessRequests, listMyAccessRequests, -} from '@/lib/permission-access-requests/application/requests' +} from '@/ee/access-requests/lib/application/requests' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/notification-events' const principal = { kind: 'session', userId: 'requester', sessionId: 'session' } as const const scope = { kind: 'workspace', workspaceId: 'workspace' } as const @@ -152,7 +150,6 @@ beforeEach(() => { mocks.authorize.mockResolvedValue(context) mocks.membership.mockResolvedValue(null) mocks.enabled.mockResolvedValue(true) - mocks.featureEnabled.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) mocks.catalog.mockResolvedValue(catalog) mocks.deploymentReason.mockReturnValue(null) diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/ee/access-requests/lib/application/requests.ts similarity index 94% rename from apps/sim/lib/permission-access-requests/application/requests.ts rename to apps/sim/ee/access-requests/lib/application/requests.ts index e3e023492b3..e3bf1be02d5 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.ts @@ -9,37 +9,44 @@ import { and, count, eq, gte, isNull, or } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' -import { prepareAccessRequestPolicy } from '@/lib/permission-access-requests/application/prepare' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { prepareAccessRequestPolicy } from '@/ee/access-requests/lib/application/prepare' import { listAccessRequestTargets, loadAccessRequestCatalog, -} from '@/lib/permission-access-requests/catalog' +} from '@/ee/access-requests/lib/catalog' import { ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS, ACCESS_REQUEST_MAX_PENDING, ACCESS_REQUEST_SUBMISSION_WINDOW_MS, -} from '@/lib/permission-access-requests/constants' +} from '@/ee/access-requests/lib/constants' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/notification-events' import { evaluateAccessRequestTarget, loadAccessRequestPolicy, -} from '@/lib/permission-access-requests/policy' +} from '@/ee/access-requests/lib/policy' import { listAccessRequestRecords, loadStoredAccessRequest, presentAccessRequest, -} from '@/lib/permission-access-requests/repository' +} from '@/ee/access-requests/lib/repository' import { isAccessRequestEnabled, readAccessRequestSettings, -} from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/settings' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' +import { + describeAccessRequestTarget, + getAccessRequestTargetKey, + validateAccessRequestTarget, +} from '@/ee/access-requests/lib/targets' import type { AccessRequestDiscovery, AccessRequestRecord, @@ -47,14 +54,7 @@ import type { AccessRequestStatus, CreateAccessRequestInput, DiscoverAccessRequestsInput, -} from '@/lib/permission-access-requests/types' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' -import { - describeAccessRequestTarget, - getAccessRequestTargetKey, - validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +} from '@/ee/access-requests/lib/types' function requireOrganization(organizationId: string | null): string { if (!organizationId) @@ -229,7 +229,7 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ prepared, }): Promise { const organizationId = requireOrganization(context.organizationId) - if (!(await isAccessRequestEnabled(organizationId, executor, prepared.globalEnabled))) + if (!(await isAccessRequestEnabled(organizationId, executor))) throw new OrchestrationError( 'forbidden', 'Access requests are turned off for this organization' @@ -517,6 +517,7 @@ interface OrganizationListInput extends OrganizationInput { limit: number offset: number status?: AccessRequestStatus + search?: string } const organizationScope = (input: OrganizationInput): AccessRequestScope => ({ kind: 'organization', @@ -534,7 +535,8 @@ export const listOrganizationAccessRequests = defineAuthorizedAccessRequestUseCa input.status ? eq(permissionAccessRequest.status, input.status) : undefined )!, input.limit, - input.offset + input.offset, + input.search ), }) diff --git a/apps/sim/lib/permission-access-requests/application/review.test.ts b/apps/sim/ee/access-requests/lib/application/review.test.ts similarity index 95% rename from apps/sim/lib/permission-access-requests/application/review.test.ts rename to apps/sim/ee/access-requests/lib/application/review.test.ts index cc111932b7f..f4abf9fe9a6 100644 --- a/apps/sim/lib/permission-access-requests/application/review.test.ts +++ b/apps/sim/ee/access-requests/lib/application/review.test.ts @@ -12,9 +12,9 @@ import { import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' -import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import type { StoredAccessRequest } from '@/ee/access-requests/lib/repository' +import { createAccessRequestCatalog } from '@/ee/access-requests/lib/targets' const mocks = vi.hoisted(() => ({ authorize: vi.fn(), @@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbox: vi.fn(), enabled: vi.fn(), - featureEnabled: vi.fn(), enterprise: vi.fn(), catalog: vi.fn(), deploymentReason: vi.fn(), @@ -35,7 +34,7 @@ const mocks = vi.hoisted(() => ({ setLimit: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, loadAccessRequestMembership: mocks.membership, })) @@ -47,15 +46,14 @@ vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ })) vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mocks.enabled, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, })) -vi.mock('@/lib/permission-access-requests/catalog', () => ({ +vi.mock('@/ee/access-requests/lib/catalog', () => ({ loadAccessRequestCatalog: mocks.catalog, getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, })) @@ -63,10 +61,10 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group, resolveDefaultGroup: mocks.group, })) -vi.mock('@/lib/permission-access-requests/impact', () => ({ +vi.mock('@/ee/access-requests/lib/impact', () => ({ loadAccessRequestGroupImpact: mocks.impact, })) -vi.mock('@/lib/permission-access-requests/repository', () => ({ +vi.mock('@/ee/access-requests/lib/repository', () => ({ presentAccessRequest: mocks.present, loadStoredAccessRequest: mocks.stored, })) @@ -77,8 +75,8 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({ import { previewAccessRequest, resolveAccessRequest, -} from '@/lib/permission-access-requests/application/review' -import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/application/review' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events' const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const const input = { organizationId: 'organization', requestId: 'request' } @@ -176,7 +174,6 @@ beforeEach(() => { }) mocks.membership.mockResolvedValue({ membershipId: 'membership', role: 'read' }) mocks.enabled.mockResolvedValue(true) - mocks.featureEnabled.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) mocks.catalog.mockResolvedValue(catalog) mocks.deploymentReason.mockReturnValue(null) diff --git a/apps/sim/lib/permission-access-requests/application/review.ts b/apps/sim/ee/access-requests/lib/application/review.ts similarity index 93% rename from apps/sim/lib/permission-access-requests/application/review.ts rename to apps/sim/ee/access-requests/lib/application/review.ts index 630cd7d4fca..e1a45306dc3 100644 --- a/apps/sim/lib/permission-access-requests/application/review.ts +++ b/apps/sim/ee/access-requests/lib/application/review.ts @@ -9,36 +9,36 @@ import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authoriz import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' import { type PreparedAccessRequestPolicy, prepareAccessRequestPolicy, -} from '@/lib/permission-access-requests/application/prepare' -import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' -import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/application/prepare' +import { loadAccessRequestGroupImpact } from '@/ee/access-requests/lib/impact' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events' import { evaluateAccessRequestTarget, loadAccessRequestPolicy, loadMemberLimit, -} from '@/lib/permission-access-requests/policy' +} from '@/ee/access-requests/lib/policy' import { loadStoredAccessRequest, presentAccessRequest, type StoredAccessRequest, -} from '@/lib/permission-access-requests/repository' +} from '@/ee/access-requests/lib/repository' import { storedAccessRequestDecisionSchema, storedAccessRequestTargetSchema, -} from '@/lib/permission-access-requests/schemas' -import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/schemas' +import { isAccessRequestEnabled } from '@/ee/access-requests/lib/settings' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' import type { AccessRequestPreview, ResolveAccessRequestDecision, -} from '@/lib/permission-access-requests/types' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +} from '@/ee/access-requests/lib/types' interface ReviewInput { organizationId: string @@ -139,7 +139,7 @@ async function loadReviewPreview( catalog, currentPolicy ) - const enabled = await isAccessRequestEnabled(row.organizationId, executor, prepared.globalEnabled) + const enabled = await isAccessRequestEnabled(row.organizationId, executor) const audience = policy.group ? await loadAccessRequestGroupImpact( executor, diff --git a/apps/sim/lib/permission-access-requests/catalog-registry.ts b/apps/sim/ee/access-requests/lib/catalog-registry.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/catalog-registry.ts rename to apps/sim/ee/access-requests/lib/catalog-registry.ts index c3678f3ae4a..bb45379fac7 100644 --- a/apps/sim/lib/permission-access-requests/catalog-registry.ts +++ b/apps/sim/ee/access-requests/lib/catalog-registry.ts @@ -11,14 +11,6 @@ import { isIntegrationDeploymentAvailableForVisibility, isOAuthServiceDeploymentAvailable, } from '@/lib/integrations/availability.server' -import { - type AccessRequestCatalog, - type AccessRequestCatalogItem, - type AccessRequestModelItem, - type AccessRequestTarget, - type AccessRequestToolItem, - createAccessRequestCatalog, -} from '@/lib/permission-groups/access-requests/targets' import { resolveAccessControlBlockType, toAccessControlAllowlist, @@ -26,6 +18,14 @@ import { import { getBlockRegistry } from '@/blocks/registry' import { isHiddenUnder } from '@/blocks/visibility/context' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { + type AccessRequestCatalog, + type AccessRequestCatalogItem, + type AccessRequestModelItem, + type AccessRequestTarget, + type AccessRequestToolItem, + createAccessRequestCatalog, +} from '@/ee/access-requests/lib/targets' import { getStaticProviderModels, PROVIDER_DEFINITIONS } from '@/providers/models' import { filterBlacklistedModels } from '@/providers/utils' import { getToolMetadata } from '@/tools/metadata' diff --git a/apps/sim/lib/permission-access-requests/catalog.test.ts b/apps/sim/ee/access-requests/lib/catalog.test.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/catalog.test.ts rename to apps/sim/ee/access-requests/lib/catalog.test.ts index ad85beb35d3..c0c81cfc667 100644 --- a/apps/sim/lib/permission-access-requests/catalog.test.ts +++ b/apps/sim/ee/access-requests/lib/catalog.test.ts @@ -94,17 +94,17 @@ vi.mock('@/connectors/registry', () => ({ }, })) +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { getAccessRequestDeploymentUnavailableReason, listAccessRequestTargets, loadAccessRequestCatalog, -} from '@/lib/permission-access-requests/catalog' +} from '@/ee/access-requests/lib/catalog' import { buildAccessRequestPolicyDelta, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' const context = { userId: 'viewer', organizationId: 'org', workspaceId: 'ws' } diff --git a/apps/sim/lib/permission-access-requests/catalog.ts b/apps/sim/ee/access-requests/lib/catalog.ts similarity index 93% rename from apps/sim/lib/permission-access-requests/catalog.ts rename to apps/sim/ee/access-requests/lib/catalog.ts index 983be1eda05..e6442f3b139 100644 --- a/apps/sim/lib/permission-access-requests/catalog.ts +++ b/apps/sim/ee/access-requests/lib/catalog.ts @@ -6,14 +6,14 @@ import { isSandboxesEnabled, isSsoEnabled, } from '@/lib/core/config/env-flags' -import type { AccessRequestCatalogContext } from '@/lib/permission-access-requests/catalog-registry' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { FILE_SHARE_AUTH_TYPES } from '@/lib/permission-groups/fields' +import type { AccessRequestCatalogContext } from '@/ee/access-requests/lib/catalog-registry' import { type AccessRequestCatalog, type AccessRequestTarget, createAccessRequestCatalog, -} from '@/lib/permission-groups/access-requests/targets' -import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' -import { FILE_SHARE_AUTH_TYPES } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' /** Small navigation and credit-limit checks never load or enumerate the integration registries. */ export async function loadAccessRequestCatalog( @@ -32,7 +32,7 @@ export async function loadAccessRequestCatalog( knowledgeConnectors: [], }) const { loadAccessRequestRegistryCatalog } = await import( - '@/lib/permission-access-requests/catalog-registry' + '@/ee/access-requests/lib/catalog-registry' ) return loadAccessRequestRegistryCatalog(context, targetKind) } diff --git a/apps/sim/lib/permission-access-requests/constants.ts b/apps/sim/ee/access-requests/lib/constants.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/constants.ts rename to apps/sim/ee/access-requests/lib/constants.ts diff --git a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts b/apps/sim/ee/access-requests/lib/impact.postgres.test.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/impact.postgres.test.ts rename to apps/sim/ee/access-requests/lib/impact.postgres.test.ts index 3f9eb90359a..bd36ae5dd62 100644 --- a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts +++ b/apps/sim/ee/access-requests/lib/impact.postgres.test.ts @@ -5,7 +5,7 @@ import { generateId } from '@sim/utils/id' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { describe, expect, it, vi } from 'vitest' -import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' +import { loadAccessRequestGroupImpact } from '@/ee/access-requests/lib/impact' vi.unmock('@sim/db/schema') vi.unmock('drizzle-orm') diff --git a/apps/sim/lib/permission-access-requests/impact.ts b/apps/sim/ee/access-requests/lib/impact.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/impact.ts rename to apps/sim/ee/access-requests/lib/impact.ts index 2a0ae391b48..66199e0e4a1 100644 --- a/apps/sim/lib/permission-access-requests/impact.ts +++ b/apps/sim/ee/access-requests/lib/impact.ts @@ -8,7 +8,7 @@ import { } from '@sim/db/schema' import { and, count, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestImpact } from '@/lib/permission-access-requests/types' +import type { AccessRequestImpact } from '@/ee/access-requests/lib/types' /** Order-independent change detector with fixed-size aggregate state instead of sorted row strings. */ function membershipRevision(value: SQL): SQL { diff --git a/apps/sim/lib/permission-access-requests/notification-events.ts b/apps/sim/ee/access-requests/lib/notification-events.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/notification-events.ts rename to apps/sim/ee/access-requests/lib/notification-events.ts diff --git a/apps/sim/lib/permission-access-requests/notifications.test.ts b/apps/sim/ee/access-requests/lib/notifications.test.ts similarity index 97% rename from apps/sim/lib/permission-access-requests/notifications.test.ts rename to apps/sim/ee/access-requests/lib/notifications.test.ts index 9d11dda7fac..2ecee67538f 100644 --- a/apps/sim/lib/permission-access-requests/notifications.test.ts +++ b/apps/sim/ee/access-requests/lib/notifications.test.ts @@ -25,19 +25,19 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSend, hasEmailService: mockHasEmailService, })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ loadAccessRequestMembership: mockMembership, })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.example' })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mockEnabled, })) import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' -import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' +} from '@/ee/access-requests/lib/notification-events' +import { permissionAccessRequestOutboxHandlers } from '@/ee/access-requests/lib/notifications' const request = { id: 'request-one', diff --git a/apps/sim/lib/permission-access-requests/notifications.ts b/apps/sim/ee/access-requests/lib/notifications.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/notifications.ts rename to apps/sim/ee/access-requests/lib/notifications.ts index 5516dde5dd7..0ef80c6ae7f 100644 --- a/apps/sim/lib/permission-access-requests/notifications.ts +++ b/apps/sim/ee/access-requests/lib/notifications.ts @@ -13,12 +13,12 @@ import { } from '@/lib/core/outbox/service' import { getBaseUrl } from '@/lib/core/utils/urls' import { hasEmailService, sendEmail } from '@/lib/messaging/email/mailer' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' -import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/notification-events' +import { isAccessRequestEnabled } from '@/ee/access-requests/lib/settings' const logger = createLogger('PermissionAccessRequestNotifications') const ADMIN_RECIPIENT_PAGE_SIZE = 50 diff --git a/apps/sim/lib/permission-access-requests/policy.ts b/apps/sim/ee/access-requests/lib/policy.ts similarity index 92% rename from apps/sim/lib/permission-access-requests/policy.ts rename to apps/sim/ee/access-requests/lib/policy.ts index 29c4459f2c3..31bac8dc673 100644 --- a/apps/sim/lib/permission-access-requests/policy.ts +++ b/apps/sim/ee/access-requests/lib/policy.ts @@ -5,12 +5,10 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { getAccessRequestDeploymentUnavailableReason } from '@/lib/permission-access-requests/catalog' -import type { - AccessRequestScope, - AccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' +import { resolveDefaultGroup, resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { getAccessRequestDeploymentUnavailableReason } from '@/ee/access-requests/lib/catalog' +import type { AccessRequestScope, AccessRequestTarget } from '@/ee/access-requests/lib/targets' import { type AccessRequestCatalog, buildAccessRequestPolicyDelta, @@ -18,8 +16,7 @@ import { isAccessRequestTargetDenied, isAccessRequestTargetInScope, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { resolveDefaultGroup, resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +} from '@/ee/access-requests/lib/targets' export async function loadMemberLimit( executor: DbOrTx, diff --git a/apps/sim/ee/access-requests/lib/repository.postgres.test.ts b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts new file mode 100644 index 00000000000..cce7027acde --- /dev/null +++ b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts @@ -0,0 +1,100 @@ +/** @vitest-environment node */ +import { permissionAccessRequest } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { listAccessRequestRecords } from '@/ee/access-requests/lib/repository' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +const databaseUrl = process.env.ACCESS_REQUESTS_TEST_DATABASE_URL + +async function createFixture() { + const url = new URL(databaseUrl ?? '') + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + url.pathname !== '/sim_access_requests_test' + ) + throw new Error('Use a disposable local sim_access_requests_test database') + const schema = `access_search_${generateId().replaceAll('-', '')}` + const client = postgres(url.toString(), { max: 1, onnotice: () => undefined }) + await client.unsafe(`CREATE SCHEMA "${schema}"`) + await client.unsafe(`SET search_path TO "${schema}"`) + await client.unsafe(` + CREATE TABLE "user" (id text PRIMARY KEY, name text, email text); + CREATE TABLE permission_access_request ( + id text PRIMARY KEY, organization_id text, workspace_id text, requester_id text, + target jsonb DEFAULT '{"kind":"feature","configKey":"hideTablesTab"}', + target_label text, reason text DEFAULT '', status text, decision_reason text, + created_at timestamp DEFAULT now(), decided_at timestamp, group_name text + ); + INSERT INTO "user" VALUES + ('one', 'Alex Example', 'alex@example.com'), + ('two', 'Jamie Example', 'jamie@example.com'); + INSERT INTO permission_access_request (id, organization_id, requester_id, target_label, status) VALUES + ('a', 'org', 'one', 'Tables', 'pending'), + ('b', 'org', 'two', 'Tables', 'pending'), + ('c', 'org', 'one', 'Tables', 'fulfilled'), + ('d', 'other', 'one', 'Tables', 'pending'), + ('e', 'org', 'two', '100%_complete', 'pending'), + ('f', 'org', 'one', '100percent-complete', 'pending'); + `) + return { + executor: drizzle(client), + async cleanup() { + try { + await client.unsafe(`DROP SCHEMA "${schema}" CASCADE`) + } finally { + await client.end() + } + }, + } +} + +describe.skipIf(!databaseUrl)('organization request search on PostgreSQL', () => { + let fixture: Awaited> + beforeAll(async () => { + fixture = await createFixture() + }) + afterAll(async () => { + await fixture?.cleanup() + }) + + const where = and( + eq(permissionAccessRequest.organizationId, 'org'), + eq(permissionAccessRequest.status, 'pending') + )! + + it('searches before pagination and keeps totals scoped to the organization and status', async () => { + const first = await listAccessRequestRecords(fixture.executor, where, 1, 0, ' TABLES ') + expect(first.requests.map((request) => request.id)).toEqual(['b']) + expect(first.total).toBe(2) + expect(first.hasMore).toBe(true) + const second = await listAccessRequestRecords(fixture.executor, where, 1, 1, 'tables') + expect(second.requests.map((request) => request.id)).toEqual(['a']) + expect(second.total).toBe(2) + expect(second.hasMore).toBe(false) + }) + + it.each(['Alex', 'ALEX@EXAMPLE.COM'])( + 'matches requester name or email with %s without broadening access', + async (search) => { + const result = await listAccessRequestRecords(fixture.executor, where, 25, 0, search) + expect(result.requests.map((request) => request.id)).toEqual(['f', 'a']) + expect(result.total).toBe(2) + } + ) + + it('treats SQL wildcard characters literally and supports clearing search', async () => { + const result = await listAccessRequestRecords(fixture.executor, where, 25, 0, '%_') + expect(result.requests.map((request) => request.id)).toEqual(['e']) + expect(result.total).toBe(1) + const cleared = await listAccessRequestRecords(fixture.executor, where, 25, 0, ' ') + expect(cleared.total).toBe(4) + const missing = await listAccessRequestRecords(fixture.executor, where, 25, 0, 'missing') + expect(missing).toEqual({ requests: [], total: 0, hasMore: false }) + }) +}) diff --git a/apps/sim/lib/permission-access-requests/repository.ts b/apps/sim/ee/access-requests/lib/repository.ts similarity index 81% rename from apps/sim/lib/permission-access-requests/repository.ts rename to apps/sim/ee/access-requests/lib/repository.ts index c43e8986bf5..2e651e0e1d2 100644 --- a/apps/sim/lib/permission-access-requests/repository.ts +++ b/apps/sim/ee/access-requests/lib/repository.ts @@ -1,9 +1,10 @@ import { permissionAccessRequest, user } from '@sim/db/schema' -import { and, count, desc, eq, type SQL } from 'drizzle-orm' +import { and, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm' +import { escapeLikePattern } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import { storedAccessRequestTargetSchema } from '@/lib/permission-access-requests/schemas' -import type { AccessRequestList, AccessRequestRecord } from '@/lib/permission-access-requests/types' +import { storedAccessRequestTargetSchema } from '@/ee/access-requests/lib/schemas' +import type { AccessRequestList, AccessRequestRecord } from '@/ee/access-requests/lib/types' export type StoredAccessRequest = typeof permissionAccessRequest.$inferSelect @@ -79,8 +80,21 @@ export async function listAccessRequestRecords( executor: DbOrTx, where: SQL, limit: number, - offset: number + offset: number, + search?: string ): Promise { + const searchTerm = search?.trim() + const pattern = searchTerm ? `%${escapeLikePattern(searchTerm)}%` : undefined + const filteredWhere = and( + where, + pattern + ? or( + ilike(permissionAccessRequest.targetLabel, pattern), + ilike(user.name, pattern), + ilike(user.email, pattern) + ) + : undefined + ) const rows = await executor .select({ row: { @@ -100,14 +114,15 @@ export async function listAccessRequestRecords( }) .from(permissionAccessRequest) .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) - .where(where) + .where(filteredWhere) .orderBy(desc(permissionAccessRequest.createdAt), desc(permissionAccessRequest.id)) .limit(limit) .offset(offset) const [aggregate] = await executor .select({ total: count() }) .from(permissionAccessRequest) - .where(where) + .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) + .where(filteredWhere) const total = aggregate?.total ?? 0 return { requests: rows.map(({ row, requester }) => projectAccessRequest(row, requester)), diff --git a/apps/sim/lib/permission-access-requests/schemas.test.ts b/apps/sim/ee/access-requests/lib/schemas.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/schemas.test.ts rename to apps/sim/ee/access-requests/lib/schemas.test.ts index d98cf87162a..234e21edc1c 100644 --- a/apps/sim/lib/permission-access-requests/schemas.test.ts +++ b/apps/sim/ee/access-requests/lib/schemas.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { storedAccessRequestPolicyChangeSchema } from '@/lib/permission-access-requests/schemas' import { DEFAULT_PERMISSION_GROUP_CONFIG, PERMISSION_GROUP_FIELDS, } from '@/lib/permission-groups/fields' +import { storedAccessRequestPolicyChangeSchema } from '@/ee/access-requests/lib/schemas' describe('stored access request policy changes', () => { it('accepts unchanged canonical values for every field', () => { diff --git a/apps/sim/lib/permission-access-requests/schemas.ts b/apps/sim/ee/access-requests/lib/schemas.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/schemas.ts rename to apps/sim/ee/access-requests/lib/schemas.ts index 5e17319cd38..2ce98b701af 100644 --- a/apps/sim/lib/permission-access-requests/schemas.ts +++ b/apps/sim/ee/access-requests/lib/schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' import { FILE_SHARE_AUTH_TYPES, PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' +import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/ee/access-requests/lib/targets' const targetIdSchema = z.string().min(1, 'Target ID cannot be empty').max(512) const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) diff --git a/apps/sim/ee/access-requests/lib/settings.test.ts b/apps/sim/ee/access-requests/lib/settings.test.ts new file mode 100644 index 00000000000..8a540763fcb --- /dev/null +++ b/apps/sim/ee/access-requests/lib/settings.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { organizationAccessRequestSettings } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { + isAccessRequestEnabled, + readAccessRequestSettings, +} from '@/ee/access-requests/lib/settings' + +beforeEach(() => { + resetDbChainMock() +}) + +describe('permission access request settings', () => { + it('defaults the organization preference on when no settings row exists', async () => { + queueTableRows(organizationAccessRequestSettings, []) + + await expect(readAccessRequestSettings('organization-one')).resolves.toEqual({ + allowRequests: true, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'eq', + left: organizationAccessRequestSettings.organizationId, + right: 'organization-one', + }) + }) + + it('honors an organization opt-out', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) + }) + + it('preserves an explicit enabled preference', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: true }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) + }) + + it('does not reinterpret a failed settings lookup as permission to submit', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(isAccessRequestEnabled('organization-one')).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/permission-access-requests/settings.ts b/apps/sim/ee/access-requests/lib/settings.ts similarity index 71% rename from apps/sim/lib/permission-access-requests/settings.ts rename to apps/sim/ee/access-requests/lib/settings.ts index f2b35394116..24f7daf2585 100644 --- a/apps/sim/lib/permission-access-requests/settings.ts +++ b/apps/sim/ee/access-requests/lib/settings.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { organizationAccessRequestSettings } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { DbOrTx } from '@/lib/db/types' /** Missing settings preserve the default-on organization preference. */ @@ -14,13 +13,10 @@ export async function readAccessRequestSettings(organizationId: string, executor return { allowRequests: row?.allowRequests ?? true } } -/** The rollout flag is evaluated globally; organization preferences can only narrow it. */ +/** Access requests are on unless the organization has opted out. */ export async function isAccessRequestEnabled( organizationId: string, - executor: DbOrTx = db, - enabledAtAdmission?: boolean + executor: DbOrTx = db ): Promise { - if (enabledAtAdmission === false || !(await isFeatureEnabled('permission-access-requests'))) - return false return (await readAccessRequestSettings(organizationId, executor)).allowRequests } diff --git a/apps/sim/lib/permission-groups/access-requests/targets.test.ts b/apps/sim/ee/access-requests/lib/targets.test.ts similarity index 99% rename from apps/sim/lib/permission-groups/access-requests/targets.test.ts rename to apps/sim/ee/access-requests/lib/targets.test.ts index d5ac0b2db25..43d59c1a081 100644 --- a/apps/sim/lib/permission-groups/access-requests/targets.test.ts +++ b/apps/sim/ee/access-requests/lib/targets.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { type AccessRequestTarget, buildAccessRequestPolicyDelta, @@ -11,9 +13,7 @@ import { isAccessRequestTargetDenied, isAccessRequestTargetInScope, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' const catalog = createAccessRequestCatalog({ integrations: [ diff --git a/apps/sim/lib/permission-groups/access-requests/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts similarity index 100% rename from apps/sim/lib/permission-groups/access-requests/targets.ts rename to apps/sim/ee/access-requests/lib/targets.ts diff --git a/apps/sim/lib/permission-access-requests/types.ts b/apps/sim/ee/access-requests/lib/types.ts similarity index 90% rename from apps/sim/lib/permission-access-requests/types.ts rename to apps/sim/ee/access-requests/lib/types.ts index 9acb355d778..33f6e18e476 100644 --- a/apps/sim/lib/permission-access-requests/types.ts +++ b/apps/sim/ee/access-requests/lib/types.ts @@ -1,8 +1,5 @@ -import type { AccessRequestDecision } from '@/lib/permission-access-requests/schemas' -import type { - AccessRequestScope, - AccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestDecision } from '@/ee/access-requests/lib/schemas' +import type { AccessRequestScope, AccessRequestTarget } from '@/ee/access-requests/lib/targets' export type AccessRequestStatus = 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx index ab4edcff65a..7ea41dc0bfb 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx @@ -133,11 +133,12 @@ describe('organization provider configuration UI', () => { mcpServers: NonNullable['mcpServers'] = [ { ...provider, enabled: true }, ], - options: NonNullable['options'] = [] + options: NonNullable['options'] = [], + searchParams = '' ) { await act(async () => root.render( - + { expect(button?.disabled).toBe(false) await act(async () => button?.click()) } + + it('filters integrations without dropping hidden providers from configuration updates', async () => { + await render([], [gmail, github], '?credential-group-provider=+GMAIL+') + expect(container.textContent).toContain('Gmail') + expect(container.textContent).not.toContain('GitHub') + await clickButton('Update configurations') + expect( + mocks.update.mock.calls[0][0].update.options.map( + (option: { provider: string }) => option.provider + ) + ).toEqual(['gmail', 'github-repositories']) + }) + + it('distinguishes an integration search miss from an unconfigured group', async () => { + await render([], [gmail], '?credential-group-provider=missing') + expect(container.textContent).toContain('No integrations match your search') + expect(container.textContent).not.toContain('Add an integration to start connecting accounts.') + }) async function fill(labelText: string, value: string) { const label = Array.from(document.querySelectorAll('label')).find((node) => node.textContent?.startsWith(labelText) diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index d303f264094..c6bf38321cc 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -12,6 +12,7 @@ import { } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' @@ -20,8 +21,13 @@ import { type CredentialGroupProvider, getCredentialGroupProviderService, } from '@/lib/credential-groups/providers' +import { + credentialGroupProviderSearchParam, + credentialGroupProviderSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, @@ -38,6 +44,7 @@ import { useRemoveOrganizationAccountMcpProvider, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface OrganizationAccountProvidersProps { organizationId: string @@ -50,6 +57,11 @@ export function OrganizationAccountProviders({ group, availableProviders, }: OrganizationAccountProvidersProps) { + const [searchTerm, setSearchParam] = useQueryState(credentialGroupProviderSearchParam.key, { + ...credentialGroupProviderSearchParam.parser, + ...credentialGroupProviderSearchUrlKeys, + }) + const setSearchTerm = useDebouncedSearchSetter(setSearchParam) const [catalogOpen, setCatalogOpen] = useState(false) const [removing, setRemoving] = useState(null) const [slackOpen, setSlackOpen] = useState(false) @@ -149,6 +161,8 @@ export function OrganizationAccountProviders({ choice: { kind: 'mcp', connectorId: server.managedConnectorId } as const, })), ].sort((left, right) => left.name.localeCompare(right.name)) + const normalizedSearch = searchTerm.trim().toLowerCase() + const visibleRows = rows.filter((row) => row.name.toLowerCase().includes(normalizedSearch)) const error = update.error ?? addMcp.error ?? removeMcp.error const removingName = removing ? removing.kind === 'oauth' @@ -157,7 +171,9 @@ export function OrganizationAccountProviders({ : '' return ( -
+ )}
- {rows.map(({ id, name, icon: Icon, configure, choice }) => ( + {visibleRows.map(({ id, name, icon: Icon, configure, choice }) => ( } @@ -221,9 +237,11 @@ export function OrganizationAccountProviders({ } /> ))} - {!rows.length && ( + {!visibleRows.length && ( - Add an integration to start connecting accounts. + {normalizedSearch + ? 'No integrations match your search' + : 'Add an integration to start connecting accounts.'} )}
@@ -295,6 +313,6 @@ export function OrganizationAccountProviders({ /> )} -
+
) } diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx index 04e7cc23740..af9046a7d8c 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx @@ -3,6 +3,7 @@ */ import { act, type ComponentProps, type ReactNode } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { @@ -32,6 +33,9 @@ vi.mock('@sim/emcn', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess }, })) vi.mock('@sim/emcn/icons', () => ({ Workspaces: () => null, Plus: () => null })) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children: ReactNode }) => <>{children}, +})) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccountWorkspaceAccess: mocks.useAccess, useUpdateOrganizationAccountWorkspaceAccess: () => ({ @@ -134,12 +138,18 @@ function setAccess(grants = gmailGrants(['workspace-1']), revision = 3) { }) } -function renderAccess() { +function renderAccess(searchParams = '') { const container = document.createElement('div') const root = createRoot(container) mountedRoots.push(root) const rerender = () => - act(() => root.render()) + act(() => + root.render( + + + + ) + ) const button = (label: string, scope: ParentNode = container) => { const match = [...scope.querySelectorAll('button')].find( (candidate) => candidate.textContent === label @@ -218,7 +228,8 @@ it('adds an explicit All integrations grant', async () => { it('edits one workspace without changing other workspace grants', async () => { setAccess(gmailGrants(['workspace-1', 'workspace-2'])) - const editor = renderAccess() + const editor = renderAccess('?credential-group-workspace=+FINANCE+') + expect(editor.rows()).toEqual(['Finance']) const finance = editor.container.querySelector('[data-workspace="Finance"]') if (!finance) throw new Error('Finance row not found') act(() => editor.button('Edit access', finance).click()) diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx index bc29477405e..396a3c8a593 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx @@ -4,22 +4,26 @@ import { useState } from 'react' import { Chip, toast } from '@sim/emcn' import { Plus, Workspaces } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' import type { OrganizationAccountWorkspaceAccess as WorkspaceAccess } from '@/lib/api/contracts/organization-accounts' import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { OrganizationWorkspaceGrantModal } from '@/ee/credential-groups/components/organization-workspace-grant-modal' +import { credentialGroupWorkspaceSearchParam } from '@/ee/credential-groups/search-params' import { useOrganizationAccountWorkspaceAccess, useUpdateOrganizationAccountWorkspaceAccess, } from '@/hooks/queries/organization-accounts' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' type Grant = WorkspaceAccess['grants'][number] type GrantEditor = @@ -34,31 +38,44 @@ export function OrganizationAccountWorkspaceAccess({ organizationId, }: OrganizationAccountWorkspaceAccessProps) { const access = useOrganizationAccountWorkspaceAccess(organizationId) - if (access.error) - return ( - void access.refetch()} - /> - ) - if (!access.data) - return

Loading workspace access…

+ const [searchTerm, setSearchParam] = useQueryState(credentialGroupWorkspaceSearchParam.key, { + ...credentialGroupWorkspaceSearchParam.parser, + history: 'replace', + clearOnDefault: true, + }) + const setSearchTerm = useDebouncedSearchSetter(setSearchParam) return ( - + + {access.error ? ( + void access.refetch()} + variant='inline' + /> + ) : !access.data ? ( + Loading workspace access… + ) : ( + + )} + ) } interface WorkspaceAccessFormProps extends OrganizationAccountWorkspaceAccessProps { access: WorkspaceAccess + searchTerm: string } -function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProps) { +function WorkspaceAccessForm({ organizationId, access, searchTerm }: WorkspaceAccessFormProps) { const update = useUpdateOrganizationAccountWorkspaceAccess() const [editor, setEditor] = useState(null) const byId = new Map(access.workspaces.map((workspace) => [workspace.id, workspace])) @@ -76,6 +93,10 @@ function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProp } } const allowedWorkspaces = access.workspaces.filter((workspace) => grantsById.has(workspace.id)) + const normalizedSearch = searchTerm.trim().toLowerCase() + const visibleWorkspaces = allowedWorkspaces.filter((workspace) => + workspace.name.toLowerCase().includes(normalizedSearch) + ) const availableWorkspaces = access.workspaces.filter((workspace) => !grantsById.has(workspace.id)) const save = async (grants: WorkspaceAccess['grants'], revision: number) => { @@ -135,11 +156,13 @@ function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProp {update.error.message}

)} - {!allowedWorkspaces.length ? ( - No workspaces have access + {!visibleWorkspaces.length ? ( + + {normalizedSearch ? 'No workspaces match your search' : 'No workspaces have access'} + ) : (
- {allowedWorkspaces.map((workspace) => { + {visibleWorkspaces.map((workspace) => { const grant = grantsById.get(workspace.id)! return ( ) if (!accounts.data) - return

Loading Credential Groups…

+ return Loading Credential Groups… if (!accounts.data.canManage) - return

An organization admin manages Credential Groups.

+ return An organization admin manages Credential Groups. const group = accounts.data.credentialGroup if (!group) return ( @@ -59,9 +62,10 @@ export function OrganizationConnectedAccounts({
) return ( -
+
void setView({ tab: value })} options={[ diff --git a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx index e2c01a12cb2..5c27e1aa1a1 100644 --- a/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx @@ -160,6 +160,18 @@ describe('workspace integration grant editor', () => { }) }) + it('connects the integration selector to its required state and explanatory hint', async () => { + await render({ mode: 'all' }) + const trigger = document.querySelector('[aria-label="Integrations"]')! + expect(trigger.hasAttribute('aria-required')).toBe(false) + const description = trigger + .getAttribute('aria-describedby')! + .split(' ') + .map((id) => document.getElementById(id)?.textContent) + .join(' ') + expect(description).toBe('Includes integrations added in the future. Required.') + }) + it.each(['all', 'selected'] as const)( 'does not treat clearing the last %s selection as unrestricted access', async (mode) => { diff --git a/apps/sim/ee/credential-groups/search-params.ts b/apps/sim/ee/credential-groups/search-params.ts index c720460dca3..942578d66f0 100644 --- a/apps/sim/ee/credential-groups/search-params.ts +++ b/apps/sim/ee/credential-groups/search-params.ts @@ -1,7 +1,12 @@ -import { parseAsStringLiteral } from 'nuqs/server' +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' export const credentialGroupsParsers = { tab: parseAsStringLiteral(['providers', 'people', 'workspace-access'] as const).withDefault( 'providers' ), } + +export const credentialGroupWorkspaceSearchParam = { + key: 'credential-group-workspace', + parser: parseAsString.withDefault(''), +} as const diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 6ee3c467b1e..ac56a55b105 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -523,6 +523,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
{}}> + + + + + ) + const [previous, current] = view.querySelectorAll('button') + /** Class order changes when composing recipes; the resolved utility set must not. */ + previous.className = previous.className.split(/\s+/).sort().join(' ') + current.className = current.className.split(/\s+/).sort().join(' ') + expect(current.outerHTML).toBe(previous.outerHTML) + }) + } + + it('forwards the native ref, attributes and original events', () => { + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + mount( + + ) + expect(ref.current).toBe(button()) + expect(button().dataset.action).toBe('download') + act(() => button().focus()) + expect(document.activeElement).toBe(button()) + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + act(() => button().dispatchEvent(keyEvent)) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('does not invoke disabled actions', () => { + const onClick = vi.fn() + mount() + act(() => button().click()) + expect(button().disabled).toBe(true) + expect(onClick).not.toHaveBeenCalled() + }) + + for (const type of [undefined, 'button'] as const) { + it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => { + const onSubmit = vi.fn((event) => event.preventDefault()) + mount( +
+ + + ) + act(() => button().click()) + expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1) + }) + } + + it('composes with the tooltip and menu triggers used by Move', () => { + const onOpenChange = vi.fn() + const onKeyDown = vi.fn() + const ref = createRef() + mount( + + + + + + + + Move + + + ) + expect(container?.querySelectorAll('button')).toHaveLength(1) + expect(ref.current).toBe(button()) + expect(button().getAttribute('aria-haspopup')).toBe('menu') + act(() => + button().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('retains the tooltip and accessible name on a direct action', () => { + vi.useFakeTimers() + mount( + + + + + Download selected files + + ) + act(() => + button().dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + ) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Download selected files') + expect(button().getAttribute('aria-label')).toBe('Download') + }) +}) diff --git a/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx new file mode 100644 index 00000000000..b24e402e274 --- /dev/null +++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx @@ -0,0 +1,52 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' +import { chipFilledFillTokens, chipRadiusClass } from '../chip/chip-chrome' + +/** The shared 28px geometry and brand-hover treatment of selection action bars. */ +export const bulkActionButtonVariants = cva( + `${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!`, + { + variants: { + surface: { + adaptive: chipFilledFillTokens, + uniform: 'bg-[var(--surface-5)]', + }, + }, + defaultVariants: { surface: 'adaptive' }, + } +) + +export interface BulkActionButtonProps extends Omit { + /** Accessible name for the icon action; tooltip content is supplied separately. */ + 'aria-label': string + /** + * `adaptive` follows the filled chip surface: surface-5 in light mode and surface-4 in dark. + * `uniform` retains surface-5 in both themes, as used by table-cell action bars. + * @default 'adaptive' + */ + surface?: NonNullable['surface']> +} + +/** + * Icon action for a selection's bulk-action bar. Owns its geometry and visual states; + * callers provide icon content, labels, disabled state and command behavior. + * Forwards the native button ref and props for tooltip/menu `asChild` composition. + * Native form behavior is inherited from Button; pass `type` when it must be explicit. + * + * @example + */ +export const BulkActionButton = forwardRef( + ({ surface, className, ...props }, ref) => ( + + */ + iconPadding?: VariantProps['iconPadding'] +} const Button = forwardRef( - ({ className, variant, size, ...props }, ref) => { + ({ className, variant, size, iconPadding, ...props }, ref) => { return ( - + {fieldState && ( + + {fieldState} + + )} event.preventDefault() : undefined} className={cn( matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none', + insideModal && 'max-h-[min(240px,var(--radix-popper-available-height,240px))]', contentClassName )} > diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index c6305e37e32..53a6a1dd16b 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -341,6 +341,40 @@ describe('ChipModalField file actions', () => { describe('ChipModal default actions', () => { beforeEach(makeElementsVisible) + it.each(['save', 'confirm'] as const)( + 'makes a disabled %s explanation reachable without enabling the action', + (variant) => { + const onClick = vi.fn() + const action = { + label: 'Save', + disabled: true, + disabledTooltip: 'Wait for the current sync to finish before saving.', + onClick, + } + mount( + variant === 'confirm' ? ( + {}} title='Save settings' confirm={action} /> + ) : ( + {}} srTitle='Save settings'> + {}}>Save settings + {}} primaryAction={action} /> + + ) + ) + + const save = buttonByText('Save') + const trigger = save.parentElement! + expect(save.disabled).toBe(true) + expect(trigger.tabIndex).toBe(0) + act(() => trigger.focus()) + expect(document.activeElement).toBe(trigger) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(action.disabledTooltip) + pressEnter(trigger) + act(() => save.click()) + expect(onClick).not.toHaveBeenCalled() + } + ) + it('fails safe to the dismiss decision in a confirmation', () => { mount( - + {primaryChip} {primaryAction.disabledTooltip} @@ -1649,7 +1649,7 @@ function renderChipConfirmButton( if (!confirm.disabledTooltip || !disabled) return chip return ( - + {chip} {confirm.disabledTooltip} diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx new file mode 100644 index 00000000000..27bf8856ef0 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx @@ -0,0 +1,122 @@ +/** @vitest-environment jsdom */ +import { act, createRef, type ReactNode } from 'react' +import { Button, ComposerActionButton } from '@sim/emcn' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) + return container +} + +function button() { + const element = container?.querySelector('button') + if (!element) throw new Error('Button did not render') + return element +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +/** Exact pre-migration class inputs from the organization composer and workflow chat. */ +const PREVIOUS = { + md: { + base: 'size-[28px] rounded-full border-0 p-0', + active: 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]', + }, + sm: { + base: 'size-[22px] rounded-full p-0', + active: 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]', + }, +} as const + +describe('ComposerActionButton', () => { + for (const size of ['md', 'sm'] as const) { + for (const active of [true, false]) { + it(`preserves the previous ${size} markup with active=${active}`, () => { + const previous = PREVIOUS[size] + const view = mount( + <> + + + + + + ) + const [before, after] = view.querySelectorAll('button') + before.className = before.className.split(/\s+/).sort().join(' ') + after.className = after.className.split(/\s+/).sort().join(' ') + expect(after.outerHTML).toBe(before.outerHTML) + }) + } + } + + it('forwards refs and events while keeping active appearance independent of disabled', () => { + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + const action = (disabled: boolean) => ( + + ) + mount(action(false)) + expect(ref.current).toBe(button()) + expect(button().dataset.action).toBe('send') + act(() => button().focus()) + expect(document.activeElement).toBe(button()) + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + act(() => button().dispatchEvent(keyEvent)) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + const activeClasses = button().className + act(() => root?.render(action(true))) + expect(button().disabled).toBe(true) + expect(button().className).toBe(activeClasses) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + for (const type of [undefined, 'button'] as const) { + it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => { + const onSubmit = vi.fn((event) => event.preventDefault()) + mount( +
+ + + ) + act(() => button().click()) + expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1) + }) + } +}) diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx new file mode 100644 index 00000000000..806f128a179 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' + +/** Shared circular send, stop and search appearance, including the compact chat treatment. */ +export const composerActionButtonVariants = cva('rounded-full p-0', { + variants: { + size: { + sm: 'size-[22px]', + md: 'size-[28px] border-0', + }, + active: { + true: 'bg-[#383838] dark:bg-[#E0E0E0]', + false: 'bg-[#808080] dark:bg-[#808080]', + }, + }, + compoundVariants: [ + { size: 'md', active: true, className: 'hover:bg-[#575757] dark:hover:bg-[#CFCFCF]' }, + { + size: 'sm', + active: true, + className: 'hover-hover:bg-[#575757] dark:hover-hover:bg-[#CFCFCF]', + }, + ], + defaultVariants: { size: 'md', active: true }, +}) + +export interface ComposerActionButtonProps extends Omit { + /** Accessible name for the caller's icon action. */ + 'aria-label': string + /** 28px by default; `sm` retains compact chat's 22px geometry and hover treatment. */ + size?: NonNullable['size']> + /** + * Whether to show the active fill. Independent of `disabled`: a populated composer + * can retain its active appearance while execution temporarily prevents submission. + * @default true + */ + active?: boolean +} + +/** + * Circular action at the end of a composer or search field. Owns the button appearance; + * callers retain icons, labels, handlers and submission/streaming conditions. + * Forwards the native button ref and props, including Button's native form behavior. + * + * @example + */ +export const ComposerActionButton = forwardRef( + ({ size, active, className, ...props }, ref) => ( +