diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d0c66e6b72..66b5c9ee63d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,7 +198,7 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Deploy to Trigger.dev working-directory: ./apps/sim @@ -584,7 +584,7 @@ jobs: bun-version: 1.3.13 - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Create release env: diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index f67ad2ea61c..a8d882c4a57 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -41,7 +41,7 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Process docs embeddings working-directory: ./apps/sim diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index ab177ed0c5d..c91b9c300ce 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -48,7 +48,7 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts # The expression maps the explicit environment input to exactly one repo # secret, so the job never holds another environment's database URL. An diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 54f8b0b7038..0bc5fadd5c8 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js for npm publishing uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '18' + node-version: '22' registry-url: 'https://registry.npmjs.org/' - name: Cache Bun dependencies @@ -41,7 +41,7 @@ jobs: - name: Install dependencies working-directory: packages/cli - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Build package working-directory: packages/cli diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index e6bb8891b5f..65b5e02411f 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -40,7 +40,7 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Run tests working-directory: packages/ts-sdk diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 1d1fa2d2f62..3a934e51eda 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -60,7 +60,7 @@ jobs: path: ./.turbo - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts # Surfaces known CVEs in the dependency tree. Non-blocking until the # existing advisory backlog is triaged, then flip to a required gate by @@ -278,7 +278,7 @@ jobs: fi - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Build application env: diff --git a/README.md b/README.md index 40dec306468..7f51bd8f7ac 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,12 @@ ### Self-hosted ```bash -npx simstudio +git clone https://github.com/simstudioai/sim.git && cd sim +bun run setup ``` Open [http://localhost:3000](http://localhost:3000) -Docker must be installed and running. Use `-p, --port ` to run Sim on a different port, or `--no-pull` to skip pulling the latest Docker images. -

The Sim platform — chat on the left, the visual workflow builder on the right

@@ -75,71 +74,36 @@ Docker must be installed and running. Use `-p, --port ` to run Sim on a di ## Self-hosting -### Docker Compose - -```bash -git clone https://github.com/simstudioai/sim.git && cd sim -docker compose -f docker-compose.prod.yml up -d -``` - -Open [http://localhost:3000](http://localhost:3000) - -Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. - -### Manual Setup - -**Requirements:** [Bun](https://bun.sh/), [Node.js](https://nodejs.org/) v20+, PostgreSQL 12+ with [pgvector](https://github.com/pgvector/pgvector) - -1. Clone and install: +**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/). -```bash -git clone https://github.com/simstudioai/sim.git -cd sim -bun install -bun run prepare # Set up pre-commit hooks -``` - -2. Set up PostgreSQL with pgvector: - -```bash -docker run --name simstudio-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_DB=simstudio -p 5432:5432 -d pgvector/pgvector:pg17 -``` +`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose: -Or install manually via the [pgvector guide](https://github.com/pgvector/pgvector#installation). +- **Local dev** — run from source to contribute or hack on Sim +- **Docker Compose** — a self-contained instance for testing self-hosting +- **Kubernetes (Helm)** — deploy to a local cluster -3. Configure environment: +When it finishes, open [http://localhost:3000](http://localhost:3000). -```bash -cp apps/sim/.env.example apps/sim/.env -# Create your secrets -perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env -perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env -perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env -# DB configs for migration -cp packages/db/.env.example packages/db/.env -# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio" -``` - -4. Run migrations: +Manage your install with `bun run sim`: ```bash -cd packages/db && bun run db:migrate +bun run sim start | stop | restart # bring your install up / down / cycle +bun run sim status # what's installed and healthy +bun run sim logs # follow logs +bun run sim doctor # diagnose configuration problems +bun run sim down # remove containers (data kept) +bun run sim reset # archive .env and wipe managed data ``` -5. Start development servers: +`sim` detects how you're running (Docker Compose, local dev, or Kubernetes) and acts accordingly. -```bash -bun run dev:full # Starts Next.js app and realtime socket server -``` +Prefer a bare `sim`? Run `bun link` once — but note `sim` lands in `~/.bun/bin`, which Homebrew's bun doesn't add to your PATH, so you may need `export PATH="$HOME/.bun/bin:$PATH"` in your shell profile. -Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). +Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [self-hosting docs](https://docs.sim.ai/self-hosting/docker) for details. ## Chat API Keys -Chat is a Sim-managed service. To use Chat on a self-hosted instance: - -- Go to https://sim.ai → Settings → Chat keys and generate a Chat API key -- Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value +Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/selfhost/settings/chat-keys](https://sim.ai/selfhost/settings/chat-keys). ## Environment Variables diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 1ebe7b408bc..a10c0acf1f9 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3683,6 +3683,20 @@ export const AnthropicIcon = (props: SVGProps) => ( ) +export const ClaudeIcon = (props: SVGProps) => ( + + Claude + + +) + export function AzureIcon(props: SVGProps) { const id = useId() const gradient0 = `azure_paint0_${id}` @@ -3823,6 +3837,20 @@ export const ZaiIcon = (props: SVGProps) => ( ) +export const KimiIcon = (props: SVGProps) => ( + + Kimi + + + +) + export function MetaIcon(props: SVGProps) { const id = useId() const gradient1Id = `meta_gradient_1_${id}` @@ -5723,7 +5751,6 @@ export function SmtpIcon(props: SVGProps) { strokeLinecap='round' strokeLinejoin='round' /> - ) } diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index fa78d11a69a..20d8020c0bf 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -3,7 +3,6 @@ import { ChipLink } from '@sim/emcn' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { LanguageDropdown } from '@/components/ui/language-dropdown' import { SearchTrigger } from '@/components/ui/search-trigger' import { SimWordmark } from '@/components/ui/sim-logo' import { ThemeToggle } from '@/components/ui/theme-toggle' @@ -53,7 +52,6 @@ export function Navbar() {
- Get started diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index cdab0cd4c76..df939ac6f77 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -3,7 +3,9 @@ // Maps block types to their icon component references import type { ComponentType, SVGProps } from 'react' +import { Library } from '@sim/emcn/icons' import { + A2AIcon, AgentMailIcon, AgentPhoneIcon, AgiloftIcon, @@ -31,6 +33,7 @@ import { CalComIcon, CalendlyIcon, CirclebackIcon, + ClaudeIcon, ClayIcon, ClerkIcon, ClickHouseIcon, @@ -184,6 +187,7 @@ import { RipplingIcon, RocketlaneIcon, RootlyIcon, + RssIcon, S3Icon, SalesforceIcon, SapConcurIcon, @@ -197,6 +201,7 @@ import { ServiceNowIcon, SftpIcon, ShopifyIcon, + SimDeploymentsIcon, SimilarwebIcon, SimTriggerIcon, SixtyfourIcon, @@ -211,12 +216,14 @@ import { StagehandIcon, StripeIcon, SupabaseIcon, + TableIcon, TailscaleIcon, TavilyIcon, TelegramIcon, TemporalIcon, TextractIcon, ThriveIcon, + TikTokIcon, TinybirdIcon, TrelloIcon, TriggerDevIcon, @@ -229,6 +236,7 @@ import { VideoIcon, WealthboxIcon, WebflowIcon, + WebhookIcon, WhatsAppIcon, WikipediaIcon, WizaIcon, @@ -246,6 +254,7 @@ import { type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { + a2a: A2AIcon, agentmail: AgentMailIcon, agentphone: AgentPhoneIcon, agiloft: AgiloftIcon, @@ -292,6 +301,7 @@ export const blockTypeToIconMap: Record = { datadog: DatadogIcon, datagma: DatagmaIcon, daytona: DaytonaIcon, + deployments: SimDeploymentsIcon, devin: DevinIcon, discord: DiscordIcon, docusign: DocuSignIcon, @@ -323,6 +333,7 @@ export const blockTypeToIconMap: Record = { fireflies_v2: FirefliesIcon, flint: FlintIcon, gamma: GammaIcon, + generic: WebhookIcon, github: GithubIcon, github_v2: GithubIcon, gitlab: GitLabIcon, @@ -351,6 +362,9 @@ export const blockTypeToIconMap: Record = { google_tasks: GoogleTasksIcon, google_translate: GoogleTranslateIcon, google_vault: GoogleVaultIcon, + 'google-calendar': GoogleCalendarIcon, + 'google-drive': GoogleDriveIcon, + 'google-sheets': GoogleSheetsIcon, grafana: GrafanaIcon, grain: GrainIcon, grain_v2: GrainIcon, @@ -373,6 +387,7 @@ export const blockTypeToIconMap: Record = { jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, + jsm: JiraServiceManagementIcon, jupyter: JupyterIcon, kalshi: KalshiIcon, kalshi_v2: KalshiIcon, @@ -388,10 +403,13 @@ export const blockTypeToIconMap: Record = { linkedin: LinkedInIcon, linkup: LinkupIcon, linq: LinqIcon, + logs: Library, + logs_v2: Library, loops: LoopsIcon, luma: LumaIcon, mailchimp: MailchimpIcon, mailgun: MailgunIcon, + managed_agent: ClaudeIcon, mem0: Mem0Icon, memory: BrainIcon, microsoft_ad: AzureIcon, @@ -400,6 +418,7 @@ export const blockTypeToIconMap: Record = { microsoft_excel_v2: MicrosoftExcelIcon, microsoft_planner: MicrosoftPlannerIcon, microsoft_teams: MicrosoftTeamsIcon, + 'microsoft-teams': MicrosoftTeamsIcon, millionverifier: MillionVerifierIcon, mistral_parse: MistralIcon, mistral_parse_v2: MistralIcon, @@ -447,6 +466,7 @@ export const blockTypeToIconMap: Record = { rippling: RipplingIcon, rocketlane: RocketlaneIcon, rootly: RootlyIcon, + rss: RssIcon, s3: S3Icon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, @@ -477,6 +497,7 @@ export const blockTypeToIconMap: Record = { stt: STTIcon, stt_v2: STTIcon, supabase: SupabaseIcon, + table: TableIcon, tailscale: TailscaleIcon, tavily: TavilyIcon, telegram: TelegramIcon, @@ -484,9 +505,11 @@ export const blockTypeToIconMap: Record = { textract: TextractIcon, textract_v2: TextractIcon, thrive: ThriveIcon, + tiktok: TikTokIcon, tinybird: TinybirdIcon, trello: TrelloIcon, trigger_dev: TriggerDevIcon, + twilio: TwilioIcon, twilio_sms: TwilioIcon, twilio_voice: TwilioIcon, typeform: TypeformIcon, diff --git a/apps/docs/components/ui/language-dropdown.tsx b/apps/docs/components/ui/language-dropdown.tsx deleted file mode 100644 index fab29829f7c..00000000000 --- a/apps/docs/components/ui/language-dropdown.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client' - -import { ChipDropdown } from '@sim/emcn' -import { useParams, usePathname, useRouter } from 'next/navigation' - -const languages = { - en: { name: 'English', flag: '🇺🇸' }, - es: { name: 'Español', flag: '🇪🇸' }, - fr: { name: 'Français', flag: '🇫🇷' }, - de: { name: 'Deutsch', flag: '🇩🇪' }, - ja: { name: '日本語', flag: '🇯🇵' }, - zh: { name: '简体中文', flag: '🇨🇳' }, -} - -export function LanguageDropdown() { - const pathname = usePathname() - const params = useParams() - const { push } = useRouter() - - const languageOptions = Object.entries(languages).map(([code, lang]) => ({ - value: code, - label: lang.name, - iconElement: {lang.flag}, - })) - - const langFromParams = params?.lang as string - const currentLang = - langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en' - - const handleLanguageChange = (locale: string) => { - if (locale === currentLang) return - - const segments = pathname.split('/').filter(Boolean) - - if (segments[0] && Object.keys(languages).includes(segments[0])) { - segments.shift() - } - - let newPath = '' - if (locale === 'en') { - newPath = segments.length > 0 ? `/${segments.join('/')}` : '/introduction' - } else { - newPath = `/${locale}${segments.length > 0 ? `/${segments.join('/')}` : '/introduction'}` - } - - push(newPath) - } - - return ( - - ) -} diff --git a/apps/docs/content/docs/en/integrations/ashby.mdx b/apps/docs/content/docs/en/integrations/ashby.mdx index baa92879323..e056dd71eea 100644 --- a/apps/docs/content/docs/en/integrations/ashby.mdx +++ b/apps/docs/content/docs/en/integrations/ashby.mdx @@ -544,7 +544,6 @@ Lists all applications in an Ashby organization with pagination and optional fil | `perPage` | number | No | Number of results per page \(default 100\) | | `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead | | `jobId` | string | No | Filter applications by a specific job UUID | -| `candidateId` | string | No | Filter applications by a specific candidate UUID | | `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) | #### Output diff --git a/apps/docs/content/docs/en/integrations/brex.mdx b/apps/docs/content/docs/en/integrations/brex.mdx index cd23dc79a51..62ea6e7636d 100644 --- a/apps/docs/content/docs/en/integrations/brex.mdx +++ b/apps/docs/content/docs/en/integrations/brex.mdx @@ -695,9 +695,65 @@ Get a Brex budget by its ID | `amount` | json | Budget amount | | ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | | ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | -| `spendBudgetStatus` | string | Budget status \(ACTIVE, ARCHIVED, DELETED, EXPIRED\) | +| `spendBudgetStatus` | string | Budget status \(ACTIVE, ARCHIVED, DELETED\) | | `limitType` | string | Budget limit type \(HARD or SOFT\) | +### `brex_create_budget` + +Create a new budget in the Brex account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `name` | string | Yes | Name for the budget | +| `description` | string | Yes | Description of what the budget is used for | +| `parentBudgetId` | string | Yes | ID of the parent budget | +| `periodRecurrenceType` | string | Yes | Period type of the budget \(WEEKLY, MONTHLY, QUARTERLY, YEARLY, ONE_TIME\) | +| `amount` | number | Yes | Budget amount, in the smallest unit of the currency \(e.g., cents for USD\) | +| `currency` | string | No | ISO 4217 currency code \(defaults to USD\) | +| `ownerUserIds` | string | No | Comma-separated user IDs of the budget owners | +| `startDate` | string | No | Date the budget should start counting \(YYYY-MM-DD\) | +| `endDate` | string | No | Date the budget should stop counting \(YYYY-MM-DD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `budgetId` | string | Unique budget ID | +| `accountId` | string | Account ID the budget belongs to | +| `name` | string | Budget name | +| `description` | string | Budget description | +| `parentBudgetId` | string | Parent budget ID | +| `ownerUserIds` | array | User IDs of the budget owners | +| `periodRecurrenceType` | string | Budget period recurrence \(WEEKLY, MONTHLY, QUARTERLY, YEARLY, ONE_TIME\) | +| `startDate` | string | Budget start date | +| `endDate` | string | Budget end date | +| `amount` | json | Budget amount | +| ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | +| ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| `spendBudgetStatus` | string | Status of the created budget | +| `limitType` | string | Budget limit type | + +### `brex_archive_budget` + +Archive a Brex budget, making any spend limits beneath it unusable for future expenses and removing it from the UI + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `budgetId` | string | Yes | ID of the budget to archive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `budgetId` | string | ID of the archived budget | +| `spendBudgetStatus` | string | Status of the budget after archiving | + ### `brex_list_spend_limits` List spend limits in the Brex account, optionally filtered by member user @@ -762,7 +818,67 @@ Get a Brex spend limit by its ID | `name` | string | Spend limit name | | `description` | string | Spend limit description | | `parentBudgetId` | string | Parent budget ID | -| `status` | string | Spend limit status \(ACTIVE, EXPIRED, ARCHIVED, DELETED\) | +| `status` | string | Spend limit status \(ACTIVE, EXPIRED, ARCHIVED\) | +| `periodRecurrenceType` | string | Period recurrence \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) | +| `spendType` | string | Spend type of the limit | +| `startDate` | string | Spend limit start date | +| `endDate` | string | Spend limit end date | +| `ownerUserIds` | array | User IDs of the spend limit owners | +| `memberUserIds` | array | User IDs of the spend limit members | +| `currentPeriodBalance` | json | Spend and rollover amounts for the current period | +| ↳ `start_date` | string | Start date of the current period | +| ↳ `end_date` | string | End date of the current period | +| ↳ `start_time` | string | Start time of the current period \(ISO 8601\) | +| ↳ `end_time` | string | End time of the current period \(ISO 8601\) | +| ↳ `amount_spent` | json | Amount spent in the current period | +| ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | +| ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| ↳ `rollover_amount` | json | Amount rolled over from previous periods | +| ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | +| ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| `authorizationSettings` | json | Authorization settings \(base limit, authorization type, rollover refresh\) | + +### `brex_create_spend_limit` + +Create a new spend limit (hard-authorization card program) in the Brex account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `name` | string | Yes | Name for the spend limit | +| `periodRecurrenceType` | string | Yes | Period type of the spend limit \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) | +| `spendType` | string | Yes | Whether the spend limit can only be spent from cards it provisions \(BUDGET_PROVISIONED_CARDS_ONLY, NON_BUDGET_PROVISIONED_CARDS_ALLOWED\) | +| `expenseVisibility` | string | Yes | Whether expenses on this spend limit are viewable by all members \(SHARED, PRIVATE\) | +| `authorizationVisibility` | string | Yes | Whether the limit amount is visible to all members, or just controllers/bookkeepers/owners \(PUBLIC, PRIVATE\) | +| `limitIncreaseSetting` | string | Yes | Whether members can request limit increases \(ENABLED, DISABLED\) | +| `autoTransferCardsSetting` | string | Yes | How auto transfer works for virtual cards on this spend limit \(DISABLED, ENABLED\) | +| `autoCreateLimitCardsSetting` | string | Yes | How auto limit card creation works for members \(DISABLED, ALL_MEMBERS\) | +| `expensePolicyId` | string | Yes | ID of the expense policy corresponding to this spend limit | +| `baseLimitAmount` | number | Yes | Base spend limit amount, without increases/rollovers, in the smallest unit of the currency \(e.g., cents for USD\) | +| `currency` | string | No | ISO 4217 currency code for the base limit \(defaults to USD\) | +| `authorizationType` | string | Yes | Whether authorizations decline based on available balance \(HARD, SOFT\) | +| `rolloverRefreshRate` | string | Yes | Recurrence at which rolled-over unused funds stop rolling over \(OFF, NEVER, PER_MONTH, PER_QUARTER, PER_YEAR\) | +| `limitBufferPercentage` | number | No | Flexible buffer on the limit as a 0-100 percentage | +| `description` | string | No | Description of what the spend limit is used for | +| `parentBudgetId` | string | No | ID of the parent budget | +| `startDate` | string | No | Date the spend limit should start counting \(YYYY-MM-DD\) | +| `endDate` | string | No | Date the spend limit should expire \(YYYY-MM-DD\) | +| `transactionLimitAmount` | number | No | Per-transaction limit this spend limit enforces, in the smallest unit of the currency | +| `ownerUserIds` | string | No | Comma-separated user IDs of the spend limit owners | +| `memberUserIds` | string | No | Comma-separated user IDs of the spend limit members | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Unique spend limit ID | +| `accountId` | string | Account ID the spend limit belongs to | +| `name` | string | Spend limit name | +| `description` | string | Spend limit description | +| `parentBudgetId` | string | Parent budget ID | +| `status` | string | Spend limit status | | `periodRecurrenceType` | string | Period recurrence \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) | | `spendType` | string | Spend type of the limit | | `startDate` | string | Spend limit start date | @@ -828,6 +944,53 @@ Get a Brex vendor by its ID | `phone` | string | Vendor phone number | | `paymentAccounts` | array | Payment accounts associated with the vendor | +### `brex_create_vendor` + +Create a new vendor in the Brex account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `companyName` | string | Yes | Name for the vendor \(must be unique\) | +| `email` | string | No | Email address for the vendor | +| `phone` | string | No | Phone number for the vendor | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Unique vendor ID | +| `companyName` | string | Vendor company name | +| `email` | string | Vendor email address | +| `phone` | string | Vendor phone number | +| `paymentAccounts` | array | Payment accounts associated with the vendor | + +### `brex_update_vendor` + +Update an existing vendor in the Brex account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `vendorId` | string | Yes | ID of the vendor to update | +| `companyName` | string | No | New name for the vendor | +| `email` | string | No | New email address for the vendor | +| `phone` | string | No | New phone number for the vendor | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Unique vendor ID | +| `companyName` | string | Vendor company name | +| `email` | string | Vendor email address | +| `phone` | string | Vendor phone number | +| `paymentAccounts` | array | Payment accounts associated with the vendor | + ### `brex_list_transfers` List money transfers in the Brex account @@ -897,4 +1060,44 @@ Get a Brex money transfer by its ID | `externalMemo` | string | External memo | | `isPproEnabled` | boolean | Whether Principal Protection \(PPRO\) is enabled | +### `brex_create_transfer` + +Create a money transfer from a Brex cash account to a vendor + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Brex user token \(generated from Developer Settings in the Brex dashboard\) | +| `cashAccountId` | string | Yes | ID of the Brex cash account to send the transfer from \(found via the /accounts endpoint\) | +| `vendorPaymentInstrumentId` | string | Yes | ID of the vendor's payment instrument to send the transfer to \(from the vendor's payment_accounts\) | +| `amount` | number | Yes | Amount to transfer, in the smallest unit of the currency \(e.g., cents for USD\) | +| `currency` | string | No | ISO 4217 currency code \(defaults to USD\) | +| `description` | string | Yes | Description of the transfer for internal use \(not exposed externally\) | +| `externalMemo` | string | Yes | External memo shown to the recipient \(max 90 characters for ACH/Wire, 40 for Cheque\) | +| `approvalType` | string | No | Set to MANUAL to require cash admin approval before the transfer is sent | +| `isPproEnabled` | boolean | No | Enable Principal Protection \(PPRO\) to have Brex cover intermediary/receiving bank fees \(international wires only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Unique transfer ID | +| `counterparty` | json | Transfer counterparty details | +| `description` | string | Description of the transfer | +| `paymentType` | string | Payment type \(ACH, DOMESTIC_WIRE, CHEQUE, INTERNATIONAL_WIRE, BOOK_TRANSFER, STABLECOIN\) | +| `amount` | json | Transfer amount | +| ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | +| ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| `processDate` | string | Transaction processing date | +| `originatingAccount` | json | Originating account details for the transfer | +| `status` | string | Transfer status \(PROCESSING, SCHEDULED, PENDING_APPROVAL, FAILED, PROCESSED\) | +| `cancellationReason` | string | Reason the transfer was canceled | +| `estimatedDeliveryDate` | string | Estimated delivery date for the transfer | +| `creatorUserId` | string | ID of the user who created the transfer | +| `createdAt` | string | Creation timestamp of the transfer | +| `displayName` | string | Human-readable name of the transfer | +| `externalMemo` | string | External memo of the transfer | +| `isPproEnabled` | boolean | Whether Principal Protection \(PPRO\) is enabled for the transfer | + diff --git a/apps/docs/content/docs/en/integrations/calendly.mdx b/apps/docs/content/docs/en/integrations/calendly.mdx index 131f1eb0706..1bb3545b23e 100644 --- a/apps/docs/content/docs/en/integrations/calendly.mdx +++ b/apps/docs/content/docs/en/integrations/calendly.mdx @@ -306,7 +306,7 @@ Trigger workflow when someone cancels a scheduled event on Calendly | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Personal Access Token | -| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from | +| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from "Get Current User" operation. | #### Output @@ -361,7 +361,7 @@ Trigger workflow when someone schedules a new event on Calendly | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Personal Access Token | -| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from | +| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from "Get Current User" operation. | #### Output @@ -416,7 +416,7 @@ Trigger workflow when someone submits a Calendly routing form | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Personal Access Token | -| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from | +| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from "Get Current User" operation. | #### Output @@ -451,7 +451,7 @@ Trigger workflow from any Calendly webhook event | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Personal Access Token | -| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from | +| `organization` | string | Yes | Organization URI for the webhook subscription. Get this from "Get Current User" operation. | #### Output diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx index 5a2221b8556..fc8edd3fa85 100644 --- a/apps/docs/content/docs/en/integrations/clickup.mdx +++ b/apps/docs/content/docs/en/integrations/clickup.mdx @@ -795,6 +795,17 @@ A **Trigger** is a block that starts a workflow when an event happens in this se Trigger workflow when a folder is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -811,6 +822,17 @@ Trigger workflow when a folder is created in ClickUp Trigger workflow when a folder is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -827,6 +849,17 @@ Trigger workflow when a folder is deleted in ClickUp Trigger workflow when a folder is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -843,6 +876,17 @@ Trigger workflow when a folder is updated in ClickUp Trigger workflow when a goal is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -858,6 +902,17 @@ Trigger workflow when a goal is created in ClickUp Trigger workflow when a goal is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -873,6 +928,17 @@ Trigger workflow when a goal is deleted in ClickUp Trigger workflow when a goal is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -888,6 +954,17 @@ Trigger workflow when a goal is updated in ClickUp Trigger workflow when a key result is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -903,6 +980,17 @@ Trigger workflow when a key result is created in ClickUp Trigger workflow when a key result is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -918,6 +1006,17 @@ Trigger workflow when a key result is deleted in ClickUp Trigger workflow when a key result is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -933,6 +1032,17 @@ Trigger workflow when a key result is updated in ClickUp Trigger workflow when a list is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -949,6 +1059,17 @@ Trigger workflow when a list is created in ClickUp Trigger workflow when a list is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -965,6 +1086,17 @@ Trigger workflow when a list is deleted in ClickUp Trigger workflow when a list is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -981,6 +1113,17 @@ Trigger workflow when a list is updated in ClickUp Trigger workflow when a space is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -997,6 +1140,17 @@ Trigger workflow when a space is created in ClickUp Trigger workflow when a space is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1013,6 +1167,17 @@ Trigger workflow when a space is deleted in ClickUp Trigger workflow when a space is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1029,6 +1194,17 @@ Trigger workflow when a space is updated in ClickUp Trigger workflow when the assignees of a task change in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1045,6 +1221,17 @@ Trigger workflow when the assignees of a task change in ClickUp Trigger workflow when a comment is posted on a task in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1061,6 +1248,17 @@ Trigger workflow when a comment is posted on a task in ClickUp Trigger workflow when a task comment is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1077,6 +1275,17 @@ Trigger workflow when a task comment is updated in ClickUp Trigger workflow when a task is created in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1093,6 +1302,17 @@ Trigger workflow when a task is created in ClickUp Trigger workflow when a task is deleted in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1109,6 +1329,17 @@ Trigger workflow when a task is deleted in ClickUp Trigger workflow when the due date of a task changes in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1125,6 +1356,17 @@ Trigger workflow when the due date of a task changes in ClickUp Trigger workflow when a task is moved to a different list in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1141,6 +1383,17 @@ Trigger workflow when a task is moved to a different list in ClickUp Trigger workflow when the priority of a task changes in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1157,6 +1410,17 @@ Trigger workflow when the priority of a task changes in ClickUp Trigger workflow when the status of a task changes in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1173,6 +1437,17 @@ Trigger workflow when the status of a task changes in ClickUp Trigger workflow when the tags of a task change in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1189,6 +1464,17 @@ Trigger workflow when the tags of a task change in ClickUp Trigger workflow when the time estimate of a task changes in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1205,6 +1491,17 @@ Trigger workflow when the time estimate of a task changes in ClickUp Trigger workflow when the tracked time of a task changes in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1221,6 +1518,17 @@ Trigger workflow when the tracked time of a task changes in ClickUp Trigger workflow when a task is updated in ClickUp +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | @@ -1237,6 +1545,17 @@ Trigger workflow when a task is updated in ClickUp Trigger workflow on any ClickUp event (subscribes to all events) +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | ClickUp Account | +| `triggerWorkspaceId` | string | Yes | The ClickUp Workspace the webhook is registered in | +| `triggerSpaceId` | string | No | Only receive events from this space. ClickUp applies the most specific location when several are set | +| `triggerFolderId` | string | No | Only receive events from this folder. ClickUp applies the most specific location when several are set | +| `triggerListId` | string | No | Only receive events from this list. ClickUp applies the most specific location when several are set | +| `triggerTaskId` | string | No | Only receive events for this task. ClickUp applies the most specific location when several are set | + #### Output | Parameter | Type | Description | diff --git a/apps/docs/content/docs/en/integrations/file.mdx b/apps/docs/content/docs/en/integrations/file.mdx index f0710cb9bf3..d836d151bb3 100644 --- a/apps/docs/content/docs/en/integrations/file.mdx +++ b/apps/docs/content/docs/en/integrations/file.mdx @@ -24,6 +24,7 @@ With the File block, you can: In Sim, the File block allows your agents to read and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. This makes it possible to move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link. {/* MANUAL-CONTENT-END */} + ## Usage Instructions Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. diff --git a/apps/docs/content/docs/en/integrations/firecrawl.mdx b/apps/docs/content/docs/en/integrations/firecrawl.mdx index 64be2dac640..5a4cde25b0c 100644 --- a/apps/docs/content/docs/en/integrations/firecrawl.mdx +++ b/apps/docs/content/docs/en/integrations/firecrawl.mdx @@ -53,8 +53,6 @@ Extract structured content from web pages with comprehensive metadata support. C | `url` | string | Yes | The URL to scrape content from \(e.g., "https://example.com/page"\) | | `scrapeOptions` | json | No | Options for content scraping | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -94,8 +92,6 @@ Scrape multiple URLs in a single batch job and retrieve structured content from | `scrapeOptions` | json | No | Advanced scraping configuration options | | `zeroDataRetention` | boolean | No | Enable zero data retention | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -163,8 +159,6 @@ Search for information on the web using Firecrawl | --------- | ---- | -------- | ----------- | | `query` | string | Yes | The search query to use | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -202,8 +196,6 @@ Crawl entire websites and extract structured content from all accessible pages | `includePaths` | json | No | URL paths to include in crawling \(e.g., \["/docs/*", "/api/*"\]\). Only these paths will be crawled | | `onlyMainContent` | boolean | No | Extract only main content from pages | | `apiKey` | string | Yes | Firecrawl API Key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -293,8 +285,6 @@ Get a complete list of URLs from any website quickly and reliably. Useful for di | `timeout` | number | No | Request timeout in milliseconds | | `location` | json | No | Geographic context for proxying \(country, languages\) | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -321,8 +311,6 @@ Extract structured data from entire webpages using natural language prompts and | `ignoreInvalidURLs` | boolean | No | Skip invalid URLs in the array \(default: true\) | | `scrapeOptions` | json | No | Advanced scraping configuration options | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output @@ -397,8 +385,6 @@ Parse uploaded documents (PDF, DOCX, HTML, etc.) into clean markdown using Firec | `proxy` | string | No | Proxy mode: "basic" or "auto" | | `zeroDataRetention` | boolean | No | Enable zero data retention. Defaults to false. | | `apiKey` | string | Yes | Firecrawl API key | -| `pricing` | custom | No | No description | -| `rateLimit` | string | No | No description | #### Output diff --git a/apps/docs/content/docs/en/integrations/github.mdx b/apps/docs/content/docs/en/integrations/github.mdx index d5a8eaf3d87..01ec88fb01e 100644 --- a/apps/docs/content/docs/en/integrations/github.mdx +++ b/apps/docs/content/docs/en/integrations/github.mdx @@ -43,10 +43,7 @@ Fetch PR details including diff and files changed | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `owner` | string | Yes | Repository owner | -| `repo` | string | Yes | Repository name | -| `pullNumber` | number | Yes | Pull request number | -| `apiKey` | string | Yes | GitHub API token | +| `includeFiles` | boolean | No | Whether to fetch changed-file details from the separate files endpoint | #### Output @@ -66,12 +63,6 @@ Fetch PR details including diff and files changed | ↳ `label` | string | Branch label \(owner:branch\) | | ↳ `ref` | string | Branch name | | ↳ `sha` | string | Commit SHA | -| `merged_by` | object | GitHub user object | -| ↳ `login` | string | GitHub username | -| ↳ `id` | number | User ID | -| ↳ `avatar_url` | string | Avatar image URL | -| ↳ `html_url` | string | Profile URL | -| ↳ `type` | string | Account type \(User or Organization\) | | `id` | number | Pull request ID | | `number` | number | Pull request number | | `title` | string | PR title | @@ -81,6 +72,12 @@ Fetch PR details including diff and files changed | `body` | string | PR description | | `merged` | boolean | Whether PR is merged | | `mergeable` | boolean | Whether PR is mergeable | +| `merged_by` | object | GitHub user object | +| ↳ `login` | string | GitHub username | +| ↳ `id` | number | User ID | +| ↳ `avatar_url` | string | Avatar image URL | +| ↳ `html_url` | string | Profile URL | +| ↳ `type` | string | Account type \(User or Organization\) | | `comments` | number | Number of comments | | `review_comments` | number | Number of review comments | | `commits` | number | Number of commits | @@ -676,7 +673,8 @@ Submit a review for a pull request. Use APPROVE, REQUEST_CHANGES, or COMMENT. A | `pullNumber` | number | Yes | Pull request number | | `event` | string | Yes | The review action to perform: APPROVE, REQUEST_CHANGES, or COMMENT | | `body` | string | No | The body text of the review \(required for REQUEST_CHANGES and COMMENT\) | -| `commit_id` | string | No | The SHA of the commit that needs a review \(defaults to the most recent commit\) | +| `commit_id` | string | No | The SHA of the commit that needs a review \(required when posting inline comments; defaults to the most recent commit otherwise\) | +| `comments` | array | No | Optional inline comments with required path, body, line, and side fields | | `apiKey` | string | Yes | GitHub API token | #### Output diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index cfeb0c53c8b..1afb2ff4aa9 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -75,6 +75,71 @@ Get details of a specific GitLab project | --------- | ---- | ----------- | | `project` | object | The GitLab project details | +### `gitlab_list_groups` + +List GitLab groups accessible to the authenticated user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `owned` | boolean | No | Limit to groups owned by the current user | +| `search` | string | No | Search groups by name or path | +| `topLevelOnly` | boolean | No | Limit to top-level groups, excluding subgroups | +| `visibility` | string | No | Filter by visibility: public, internal, or private | +| `minAccessLevel` | number | No | Only groups where the current user has at least this access level, as an integer \(e.g. 30 for Developer\). Valid values: 5, 10, 15, 20, 25, 30, 40, 50. | +| `allAvailable` | boolean | No | Include all groups the user can access, not only groups they are a member of \(ignored when owned or a minimum access level is set\) | +| `orderBy` | string | No | Order by field \(name, path, id, similarity\). similarity requires a search term. | +| `sort` | string | No | Sort direction \(asc, desc\) | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `groups` | array | List of GitLab groups | +| `total` | number | Total number of groups | + +### `gitlab_get_group` + +Get details of a specific GitLab group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. mygroup or parent/subgroup\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `group` | object | The GitLab group details | + +### `gitlab_list_user_memberships` + +List a user's project and group memberships. Requires an administrator access token (GET /users/:id/memberships is admin-only). For a non-admin path, iterate List Members on each project or group instead. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | string | Yes | The ID of the user whose memberships to list | +| `membershipType` | string | No | Filter by source: 'Project' or 'Namespace' \(group\). Omit for all memberships. | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberships` | array | The user's project and group memberships | +| `total` | number | Total number of memberships | + ### `gitlab_list_issues` List issues in a GitLab project @@ -1169,6 +1234,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1236,6 +1304,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1303,6 +1374,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1370,6 +1444,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1437,6 +1514,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1504,6 +1584,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1571,6 +1654,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | @@ -1638,6 +1724,9 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | ----------- | | `projects` | json | List of projects | | `project` | json | Project details | +| `groups` | json | List of groups | +| `group` | json | Group details | +| `memberships` | json | A user's project and group memberships | | `issues` | json | List of issues | | `issue` | json | Issue details | | `mergeRequests` | json | List of merge requests | diff --git a/apps/docs/content/docs/en/integrations/gmail.mdx b/apps/docs/content/docs/en/integrations/gmail.mdx index 0804c48b196..9a95f69fa03 100644 --- a/apps/docs/content/docs/en/integrations/gmail.mdx +++ b/apps/docs/content/docs/en/integrations/gmail.mdx @@ -333,7 +333,7 @@ Triggers when new emails are received in Gmail (requires Gmail credentials) | `triggerCredentials` | string | Yes | This trigger requires google email credentials to access your account. | | `labelIds` | string | No | Choose which Gmail labels to monitor. Leave empty to monitor all emails. | | `labelFilterBehavior` | string | Yes | Include only emails with selected labels, or exclude emails with selected labels | -| `searchQuery` | string | No | Optional Gmail search query to filter emails. Use the same format as Gmail search box \(e.g., | +| `searchQuery` | string | No | Optional Gmail search query to filter emails. Use the same format as Gmail search box \(e.g., "subject:invoice", "from:boss@company.com", "has:attachment"\). Leave empty to search all emails. | | `markAsRead` | boolean | No | Automatically mark emails as read after processing | | `includeAttachments` | boolean | No | Download and include email attachments in the trigger payload | diff --git a/apps/docs/content/docs/en/integrations/google_maps.mdx b/apps/docs/content/docs/en/integrations/google_maps.mdx index 0977dd8c435..18be2f935a3 100644 --- a/apps/docs/content/docs/en/integrations/google_maps.mdx +++ b/apps/docs/content/docs/en/integrations/google_maps.mdx @@ -139,7 +139,7 @@ Calculate travel distance and time between multiple origins and destinations | `avoid` | string | No | Features to avoid: tolls, highways, or ferries | | `units` | string | No | Unit system: metric or imperial | | `language` | string | No | Language code for results \(e.g., en, es, fr\) | -| `pricing` | per_request | No | No description | +| `pricing` | custom | No | No description | | `rateLimit` | string | No | No description | #### Output diff --git a/apps/docs/content/docs/en/integrations/google_sheets.mdx b/apps/docs/content/docs/en/integrations/google_sheets.mdx index 27ce97e09fe..bbe54ea579a 100644 --- a/apps/docs/content/docs/en/integrations/google_sheets.mdx +++ b/apps/docs/content/docs/en/integrations/google_sheets.mdx @@ -404,7 +404,7 @@ Triggers when new rows are added to a Google Sheet | `sheetName` | sheet-selector | Yes | The sheet tab to monitor for new rows. | | `manualSheetName` | string | Yes | The sheet tab to monitor for new rows. | | `valueRenderOption` | string | No | How values are rendered. Formatted returns display strings, Unformatted returns raw numbers/booleans, Formula returns the formula text. | -| `dateTimeRenderOption` | string | No | How dates and times are rendered. Only applies when Value Render is not | +| `dateTimeRenderOption` | string | No | How dates and times are rendered. Only applies when Value Render is not "Formatted Value". | #### Output diff --git a/apps/docs/content/docs/en/integrations/hubspot.mdx b/apps/docs/content/docs/en/integrations/hubspot.mdx index 2004d368eb2..acc851b8bfc 100644 --- a/apps/docs/content/docs/en/integrations/hubspot.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot.mdx @@ -241,7 +241,7 @@ Search for contacts in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against contact name, email, and other text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["email", "firstname", "lastname", "phone"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 100\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -460,7 +460,7 @@ Search for companies in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against company name, domain, and other text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["name", "domain", "industry"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 100\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -577,7 +577,7 @@ Create a new deal in HubSpot with the given properties (e.g., dealname, amount, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `properties` | object | Yes | Deal properties as JSON object. Must include dealname \(e.g., \{"dealname": "New Deal", "amount": "5000", "dealstage": "appointmentscheduled"\}\) | +| `properties` | object | Yes | Deal properties as JSON object. Should include dealname and dealstage, plus pipeline when the account has multiple pipelines \(e.g., \{"dealname": "New Deal", "amount": "5000", "dealstage": "appointmentscheduled"\}\) | | `associations` | array | No | Array of associations to create with the deal as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" | #### Output @@ -644,7 +644,7 @@ Search for deals in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against deal name and other text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["dealname", "amount", "dealstage"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 200\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -811,7 +811,7 @@ Search for tickets in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against ticket subject and other text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["subject", "content", "hs_ticket_priority"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 200\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -936,7 +936,7 @@ Search for notes in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against note text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["hs_note_body", "hs_timestamp", "hubspot_owner_id"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 100\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -1076,7 +1076,7 @@ Search for email engagements in HubSpot using filters, sorting, and queries | `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | | `query` | string | No | Search query string to match against email text fields | | `properties` | array | No | Array of HubSpot property names to return \(e.g., \["hs_email_subject", "hs_email_text", "hs_timestamp"\]\) | -| `limit` | number | No | Maximum number of results to return \(max 100\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | | `after` | string | No | Pagination cursor for next page \(from previous response\) | #### Output @@ -1189,6 +1189,282 @@ Associate two HubSpot records. Creates the default (unlabeled) association unles | `labels` | array | Association labels \(empty for default associations\) | | `success` | boolean | Operation success status | +### `hubspot_delete_association` + +Remove all associations between two HubSpot records + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The source object type \(e.g., "contacts", "companies", "deals"\) | +| `objectId` | string | Yes | The ID of the source record | +| `toObjectType` | string | Yes | The target object type \(e.g., "emails", "notes", "contacts"\) | +| `toObjectId` | string | Yes | The ID of the target record | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fromObjectId` | string | Source record ID | +| `toObjectId` | string | Target record ID | +| `deleted` | boolean | Whether the associations were removed | +| `success` | boolean | Operation success status | + +### `hubspot_get_association_labels` + +Retrieve the association types (category, typeId, label) defined between two HubSpot object types + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The source object type \(e.g., "contacts", "companies", "deals"\) | +| `toObjectType` | string | Yes | The target object type \(e.g., "emails", "notes", "contacts"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `labels` | array | Association types defined between the two object types | +| ↳ `category` | string | Association category \(HUBSPOT_DEFINED or USER_DEFINED\) | +| ↳ `typeId` | number | Association type ID | +| ↳ `label` | string | Human-readable label \(null for unlabeled defaults\) | +| `success` | boolean | Operation success status | + +### `hubspot_delete_contact` + +Archive a contact in HubSpot by ID (moves it to the recycling bin) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `contactId` | string | Yes | The numeric ID of the contact to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contactId` | string | ID of the deleted contact | +| `deleted` | boolean | Whether the contact was archived | +| `success` | boolean | Operation success status | + +### `hubspot_delete_company` + +Archive a company in HubSpot by ID (moves it to the recycling bin) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `companyId` | string | Yes | The numeric ID of the company to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `companyId` | string | ID of the deleted company | +| `deleted` | boolean | Whether the company was archived | +| `success` | boolean | Operation success status | + +### `hubspot_delete_deal` + +Archive a deal in HubSpot by ID (moves it to the recycling bin) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `dealId` | string | Yes | The numeric ID of the deal to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `dealId` | string | ID of the deleted deal | +| `deleted` | boolean | Whether the deal was archived | +| `success` | boolean | Operation success status | + +### `hubspot_delete_ticket` + +Archive a ticket in HubSpot by ID (moves it to the recycling bin) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `ticketId` | string | Yes | The numeric ID of the ticket to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ticketId` | string | ID of the deleted ticket | +| `deleted` | boolean | Whether the ticket was archived | +| `success` | boolean | Operation success status | + +### `hubspot_delete_line_item` + +Archive a line item in HubSpot by ID (moves it to the recycling bin) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `lineItemId` | string | Yes | The numeric ID of the line item to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lineItemId` | string | ID of the deleted line item | +| `deleted` | boolean | Whether the line item was archived | +| `success` | boolean | Operation success status | + +### `hubspot_search_line_items` + +Search for line items in HubSpot using filters, sorting, and queries + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" | +| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | +| `query` | string | No | Search query string to match against line item name and other text fields | +| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["name", "quantity", "price"\]\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | +| `after` | string | No | Pagination cursor for next page \(from previous response\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lineItems` | array | Array of HubSpot line item records | +| ↳ `name` | string | Line item name | +| ↳ `description` | string | Full description of the product | +| ↳ `hs_sku` | string | Unique product identifier \(SKU\) | +| ↳ `quantity` | string | Number of units included | +| ↳ `price` | string | Unit price | +| ↳ `amount` | string | Total cost \(quantity * unit price\) | +| ↳ `hs_line_item_currency_code` | string | Currency code | +| ↳ `recurringbillingfrequency` | string | Recurring billing frequency | +| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date | +| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date | +| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) | +| ↳ `createdate` | string | Creation date \(ISO 8601\) | +| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) | +| `paging` | object | Pagination information for fetching more results | +| ↳ `after` | string | Cursor for next page of results | +| ↳ `link` | string | Link to next page | +| `metadata` | object | Response metadata | +| ↳ `totalReturned` | number | Number of records returned in this response | +| ↳ `hasMore` | boolean | Whether more records are available | +| `total` | number | Total number of matching line items | +| `success` | boolean | Operation success status | + +### `hubspot_search_quotes` + +Search for quotes in HubSpot using filters, sorting, and queries + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" | +| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) | +| `query` | string | No | Search query string to match against quote title and other text fields | +| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["hs_title", "hs_expiration_date"\]\) | +| `limit` | number | No | Maximum number of results to return \(max 200, default 10\) | +| `after` | string | No | Pagination cursor for next page \(from previous response\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `quotes` | array | Array of HubSpot quote records | +| ↳ `hs_title` | string | Quote name/title | +| ↳ `hs_expiration_date` | string | Expiration date | +| ↳ `hs_status` | string | Quote status | +| ↳ `hs_esign_enabled` | string | Whether e-signatures are enabled | +| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) | +| ↳ `createdate` | string | Creation date \(ISO 8601\) | +| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) | +| `paging` | object | Pagination information for fetching more results | +| ↳ `after` | string | Cursor for next page of results | +| ↳ `link` | string | Link to next page | +| `metadata` | object | Response metadata | +| ↳ `totalReturned` | number | Number of records returned in this response | +| ↳ `hasMore` | boolean | Whether more records are available | +| `total` | number | Total number of matching quotes | +| `success` | boolean | Operation success status | + +### `hubspot_get_list_memberships` + +Retrieve the record IDs that are members of a HubSpot list, ordered by record ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | The ID of the list to read members from | +| `limit` | string | No | Maximum number of results per page \(max 250, default 100\) | +| `after` | string | No | Pagination cursor for next page of results \(from previous response\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `paging` | object | Pagination information for fetching more results | +| ↳ `after` | string | Cursor for next page of results | +| ↳ `link` | string | Link to next page | +| `metadata` | object | Response metadata | +| ↳ `totalReturned` | number | Number of records returned in this response | +| ↳ `hasMore` | boolean | Whether more records are available | +| `memberships` | array | Records that are members of the list | +| ↳ `recordId` | string | ID of the member record | +| ↳ `membershipTimestamp` | string | When the record was added to the list | +| `success` | boolean | Operation success status | + +### `hubspot_add_list_memberships` + +Add records to a manual (static) HubSpot list by record ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | The ID of the list to add records to \(MANUAL or SNAPSHOT lists only\) | +| `recordIds` | array | Yes | Record IDs to add to the list, as a JSON array \(e.g., \["123","456"\]\) or comma-separated string | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordIdsAdded` | array | IDs of the records that were added to the list | +| `recordIdsMissing` | array | IDs of the requested records that were not found | +| `success` | boolean | Operation success status | + +### `hubspot_remove_list_memberships` + +Remove records from a manual (static) HubSpot list by record ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | The ID of the list to remove records from \(MANUAL or SNAPSHOT lists only\) | +| `recordIds` | array | Yes | Record IDs to remove from the list, as a JSON array \(e.g., \["123","456"\]\) or comma-separated string | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordIdsRemoved` | array | IDs of the records that were removed from the list | +| `recordIdsMissing` | array | IDs of the requested records that were not found | +| `success` | boolean | Operation success status | + ### `hubspot_list_line_items` Retrieve all line items from HubSpot account with pagination support @@ -1459,7 +1735,7 @@ Create a new appointment in HubSpot | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `properties` | object | Yes | Appointment properties as JSON object \(e.g., \{"hs_appointment_name": "Discovery Call", "hs_appointment_start": "2024-01-15T10:00:00Z", "hs_appointment_end": "2024-01-15T11:00:00Z"\}\) | +| `properties` | object | Yes | Appointment properties as JSON object. Must include hs_appointment_start \(e.g., \{"hs_appointment_name": "Discovery Call", "hs_appointment_start": "2024-01-15T10:00:00Z", "hs_appointment_end": "2024-01-15T11:00:00Z"\}\) | | `associations` | array | No | Array of associations to create with the appointment as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" | #### Output @@ -1784,7 +2060,7 @@ Triggers when HubSpot CRM records (contacts, companies, deals, tickets, custom o | --------- | ---- | -------- | ----------- | | `triggerCredentials` | string | Yes | Connect a HubSpot account so Sim can poll your CRM on your behalf. | | `objectType` | string | Yes | What you want to watch. | -| `customObjectTypeId` | string | No | HubSpot custom object type ID \(e.g. | +| `customObjectTypeId` | string | No | HubSpot custom object type ID \(e.g. "2-12345"\). Find it in HubSpot Settings → Objects → Custom Objects. | | `listId` | string | No | The HubSpot list to watch for new members. | | `eventType` | string | No | Created fires once per new record. Updated fires on any modification. Property Changed fires only when the chosen property changes value. | | `targetPropertyName` | string | No | Fires only when this specific property changes value on a record. | @@ -1792,7 +2068,7 @@ Triggers when HubSpot CRM records (contacts, companies, deals, tickets, custom o | `pipelineId` | string | No | Restrict to a single pipeline. | | `stageId` | string | No | Restrict to a single stage within the selected pipeline. | | `ownerId` | string | No | Restrict to records owned by a specific HubSpot user. | -| `filters` | string | No | JSON array of HubSpot search filters, AND-combined. Each item: \{ | +| `filters` | string | No | JSON array of HubSpot search filters, AND-combined. Each item: \{"propertyName":"...","operator":"EQ","value":"..."\}. Operators: EQ, NEQ, CONTAINS_TOKEN, NOT_CONTAINS_TOKEN, GT, GTE, LT, LTE, BETWEEN, IN, NOT_IN, HAS_PROPERTY, NOT_HAS_PROPERTY. | | `maxRecordsPerPoll` | string | No | Soft cap on records emitted per poll \(default 50, max 1000\). Excess rolls over to the next poll. | #### Output diff --git a/apps/docs/content/docs/en/integrations/intercom.mdx b/apps/docs/content/docs/en/integrations/intercom.mdx index e9ee4ae8396..d0a2d8a1d41 100644 --- a/apps/docs/content/docs/en/integrations/intercom.mdx +++ b/apps/docs/content/docs/en/integrations/intercom.mdx @@ -1101,7 +1101,7 @@ Trigger workflow when a new lead is created in Intercom | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output @@ -1126,7 +1126,7 @@ Trigger workflow when a conversation is closed in Intercom | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output @@ -1151,7 +1151,7 @@ Trigger workflow when a new conversation is created in Intercom | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output @@ -1176,7 +1176,7 @@ Trigger workflow when someone replies to an Intercom conversation | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output @@ -1201,7 +1201,7 @@ Trigger workflow when a new user is created in Intercom | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output @@ -1226,7 +1226,7 @@ Trigger workflow on any Intercom webhook event | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `webhookSecret` | string | No | Your app | +| `webhookSecret` | string | No | Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature. | #### Output diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx new file mode 100644 index 00000000000..2a42532eabf --- /dev/null +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -0,0 +1,67 @@ +--- +title: Claude Managed Agents +description: Run a Claude Platform Managed Agent +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Claude Managed Agents](https://www.anthropic.com/) are agents you build and host on the Claude Platform. Anthropic runs the agent loop, and the Claude Managed Agents block calls one from a Sim workflow. + +With Claude Managed Agents, you can: + +- **Run a hosted agent**: Send a message to an agent in your Claude workspace and get its final response back +- **Pick the environment**: Choose the agent and the environment it runs in, so the same workflow can target development or production +- **Attach credential vaults**: Give the agent scoped access to the credentials it needs for the run +- **Use a memory store**: Connect a memory store with read or write access and pass instructions for how the agent should use it +- **Send files**: Attach files to the run for the agent to work with +- **Tag runs with metadata**: Add key-value tags so runs can be traced and grouped later + +In Sim, the Claude Managed Agents block lets a workflow hand work to an agent that lives on the Claude Platform instead of assembling the prompt, tools, and loop yourself. The block returns the agent's final text alongside its session ID and token counts, so you can chain the response into later blocks and track cost per run. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Invoke a Claude Platform Managed Agent from a workflow. Select a Claude Platform account, pick an agent and environment from that workspace, optionally attach vaults, a memory store, and files, and add metadata tags. Returns the assistant's final text. + + + +## Actions + +### `managed_agent_run_session` + +Open a Claude Platform Managed Agent session and return the assistant response as text. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `credential` | string | Yes | Claude Platform credential \(Anthropic workspace API key\) to run the agent with. | +| `agent` | string | Yes | Managed-agent id inside the linked Claude workspace. | +| `environment` | string | Yes | Environment id inside the linked Claude workspace. | +| `environmentType` | string | No | Environment execution model hint \('cloud' \| 'self_hosted'\); the actual type is re-resolved server-side for routing. | +| `userMessage` | string | Yes | The user message to send to the Managed Agent. | +| `vaults` | array | No | Zero or more vault ids for MCP tool auth. | +| `vaultsAck` | boolean | No | Acknowledgement that the author may use the attached vaults. | +| `memoryStoreId` | string | No | Optional Agent Memory Store id. | +| `memoryAccess` | string | No | Memory store access mode: 'read_write' \(default\) or 'read_only'. | +| `memoryInstructions` | string | No | Per-attachment guidance for how the agent should use the memory store. | +| `files` | array | No | File attachments \(cloud envs only\), as \[\{fileId, mountPath?\}\]. | +| `sessionParameters` | object | No | Key/value session metadata forwarded to the session. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Final assistant text from the Managed Agent session. | +| `sessionId` | string | Anthropic session id \(for logs / linking\). | +| `inputTokens` | number | Cumulative input tokens for the session. | +| `outputTokens` | number | Cumulative output tokens for the session. | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 551b8d25890..853e1f180d5 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -1,32 +1,13 @@ { "pages": [ "index", - "---Service Accounts & API Keys---", - "airtable-service-account", - "asana-service-account", - "atlassian-service-account", - "attio-service-account", - "box-service-account", - "calcom-service-account", - "google-service-account", - "hubspot-service-account", - "linear-service-account", - "monday-service-account", - "notion-service-account", - "pipedrive-service-account", - "salesforce-service-account", - "shopify-service-account", - "trello-service-account", - "wealthbox-service-account", - "webflow-service-account", - "zoom-service-account", - "---All Integrations---", "a2a", "agentmail", "agentphone", "agiloft", "ahrefs", "airtable", + "airtable-service-account", "airweave", "algolia", "amplitude", @@ -35,17 +16,22 @@ "appconfig", "arxiv", "asana", + "asana-service-account", "ashby", "athena", + "atlassian-service-account", "attio", + "attio-service-account", "azure_devops", "box", + "box-service-account", "brandfetch", "brex", "brightdata", "browser_use", "buffer", "calcom", + "calcom-service-account", "calendly", "circleback", "clay", @@ -98,6 +84,7 @@ "gitlab", "gmail", "gong", + "google-service-account", "google_ads", "google_appsheet", "google_bigquery", @@ -124,6 +111,7 @@ "greptile", "hex", "hubspot", + "hubspot-service-account", "hubspot-setup", "huggingface", "hunter", @@ -148,6 +136,7 @@ "leadmagic", "lemlist", "linear", + "linear-service-account", "linkedin", "linkup", "linq", @@ -156,6 +145,7 @@ "luma", "mailchimp", "mailgun", + "managed_agent", "mem0", "memory", "microsoft_ad", @@ -166,12 +156,14 @@ "millionverifier", "mistral_parse", "monday", + "monday-service-account", "mongodb", "mysql", "neo4j", "neverbounce", "new_relic", "notion", + "notion-service-account", "obsidian", "okta", "onedrive", @@ -185,6 +177,7 @@ "persona", "pinecone", "pipedrive", + "pipedrive-service-account", "polymarket", "postgresql", "posthog", @@ -207,6 +200,7 @@ "rootly", "s3", "salesforce", + "salesforce-service-account", "sap_concur", "sap_s4hana", "secrets_manager", @@ -219,6 +213,7 @@ "sftp", "sharepoint", "shopify", + "shopify-service-account", "similarweb", "sixtyfour", "slack", @@ -238,8 +233,10 @@ "temporal", "textract", "thrive", + "tiktok", "tinybird", "trello", + "trello-service-account", "trigger_dev", "twilio", "twilio_sms", @@ -250,7 +247,9 @@ "vanta", "vercel", "wealthbox", + "wealthbox-service-account", "webflow", + "webflow-service-account", "whatsapp", "wikipedia", "wiza", @@ -262,6 +261,7 @@ "zep", "zerobounce", "zoom", + "zoom-service-account", "zoominfo" ] } diff --git a/apps/docs/content/docs/en/integrations/parallel_ai.mdx b/apps/docs/content/docs/en/integrations/parallel_ai.mdx index e120ad35107..05a54b743a4 100644 --- a/apps/docs/content/docs/en/integrations/parallel_ai.mdx +++ b/apps/docs/content/docs/en/integrations/parallel_ai.mdx @@ -98,7 +98,7 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `input` | string | Yes | Research query or question \(up to 15,000 characters\) | -| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: pro\) | +| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: $\{DEFAULT_PROCESSOR\}\) | | `include_domains` | string | No | Comma-separated list of domains to restrict research to \(source policy\) | | `exclude_domains` | string | No | Comma-separated list of domains to exclude from research \(source policy\) | | `apiKey` | string | Yes | Parallel AI API Key | diff --git a/apps/docs/content/docs/en/integrations/pipedrive.mdx b/apps/docs/content/docs/en/integrations/pipedrive.mdx index a8da325c01f..34125a5adc2 100644 --- a/apps/docs/content/docs/en/integrations/pipedrive.mdx +++ b/apps/docs/content/docs/en/integrations/pipedrive.mdx @@ -43,6 +43,7 @@ Retrieve all deals from Pipedrive with optional filters | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `status` | string | No | Only fetch deals with a specific status. Values: open, won, lost. If omitted, all not deleted deals are returned | | `person_id` | string | No | If supplied, only deals linked to the specified person are returned \(e.g., "456"\) | | `org_id` | string | No | If supplied, only deals linked to the specified organization are returned \(e.g., "789"\) | @@ -87,6 +88,7 @@ Retrieve detailed information about a specific deal | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `deal_id` | string | Yes | The ID of the deal to retrieve \(e.g., "123"\) | #### Output @@ -104,6 +106,7 @@ Create a new deal in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `title` | string | Yes | The title of the deal \(e.g., "Enterprise Software License"\) | | `value` | string | No | The monetary value of the deal \(e.g., "5000"\) | | `currency` | string | No | Currency code \(e.g., "USD", "EUR", "GBP"\) | @@ -129,6 +132,7 @@ Update an existing deal in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `deal_id` | string | Yes | The ID of the deal to update \(e.g., "123"\) | | `title` | string | No | New title for the deal \(e.g., "Updated Enterprise License"\) | | `value` | string | No | New monetary value for the deal \(e.g., "7500"\) | @@ -151,6 +155,7 @@ Retrieve files from Pipedrive with optional filters | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `sort` | string | No | Sort files by field \(supported: "id", "update_time"\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 100\) | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) | @@ -185,6 +190,7 @@ Retrieve mail threads from Pipedrive mailbox | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `folder` | string | No | Filter by folder: inbox, drafts, sent, archive \(default: inbox\) | | `limit` | string | No | Number of results to return \(e.g., "25", default: 50\) | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) | @@ -207,6 +213,7 @@ Retrieve all messages from a specific mail thread | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `thread_id` | string | Yes | The ID of the mail thread \(e.g., "12345"\) | #### Output @@ -225,6 +232,7 @@ Retrieve all pipelines from Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `sort_by` | string | No | Field to sort by: id, update_time, add_time \(default: id\) | | `sort_direction` | string | No | Sorting direction: asc, desc \(default: asc\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | @@ -256,6 +264,7 @@ Retrieve all deals in a specific pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `pipeline_id` | string | Yes | The ID of the pipeline \(e.g., "1"\) | | `stage_id` | string | No | Filter by specific stage within the pipeline \(e.g., "2"\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | @@ -277,6 +286,7 @@ Retrieve all projects or a specific project from Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `project_id` | string | No | Optional: ID of a specific project to retrieve \(e.g., "123"\) | | `status` | string | No | Filter by project status: open, completed, deleted \(only for listing all\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500, only for listing all\) | @@ -301,6 +311,7 @@ Create a new project in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `title` | string | Yes | The title of the project \(e.g., "Q2 Marketing Campaign"\) | | `description` | string | No | Description of the project | | `start_date` | string | No | Project start date in YYYY-MM-DD format \(e.g., "2025-04-01"\) | @@ -321,6 +332,7 @@ Retrieve activities (tasks) from Pipedrive with optional filters | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `user_id` | string | No | Filter activities by user ID \(e.g., "123"\) | | `type` | string | No | Filter by activity type \(call, meeting, task, deadline, email, lunch\) | | `done` | string | No | Filter by completion status: 0 for not done, 1 for done | @@ -358,6 +370,7 @@ Create a new activity (task) in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `subject` | string | Yes | The subject/title of the activity \(e.g., "Follow up call with John"\) | | `type` | string | Yes | Activity type: call, meeting, task, deadline, email, lunch | | `due_date` | string | Yes | Due date in YYYY-MM-DD format \(e.g., "2025-03-15"\) | @@ -383,6 +396,7 @@ Update an existing activity (task) in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `activity_id` | string | Yes | The ID of the activity to update \(e.g., "12345"\) | | `subject` | string | No | New subject/title for the activity \(e.g., "Updated meeting with client"\) | | `due_date` | string | No | New due date in YYYY-MM-DD format \(e.g., "2025-03-20"\) | @@ -406,6 +420,7 @@ Retrieve all leads or a specific lead from Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `lead_id` | string | No | Optional: ID of a specific lead to retrieve \(e.g., "abc123-def456-ghi789"\) | | `archived` | string | No | Get archived leads instead of active ones \(e.g., "true" or "false"\) | | `owner_id` | string | No | Filter by owner user ID \(e.g., "123"\) | @@ -459,6 +474,7 @@ Create a new lead in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `title` | string | Yes | The name of the lead \(e.g., "Acme Corp - Website Redesign"\) | | `person_id` | string | No | ID of the person \(REQUIRED unless organization_id is provided\) \(e.g., "456"\) | | `organization_id` | string | No | ID of the organization \(REQUIRED unless person_id is provided\) \(e.g., "789"\) | @@ -483,6 +499,7 @@ Update an existing lead in Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `lead_id` | string | Yes | The ID of the lead to update \(e.g., "abc123-def456-ghi789"\) | | `title` | string | No | New name for the lead \(e.g., "Updated Lead - Premium Package"\) | | `person_id` | string | No | New person ID \(e.g., "456"\) | @@ -508,6 +525,7 @@ Delete a specific lead from Pipedrive | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `authStyle` | string | No | Auth scheme for the token; set by the credential resolver for API-token service accounts | | `lead_id` | string | Yes | The ID of the lead to delete \(e.g., "abc123-def456-ghi789"\) | #### Output diff --git a/apps/docs/content/docs/en/integrations/rocketlane.mdx b/apps/docs/content/docs/en/integrations/rocketlane.mdx index b011a0eced1..0f3cbec2eee 100644 --- a/apps/docs/content/docs/en/integrations/rocketlane.mdx +++ b/apps/docs/content/docs/en/integrations/rocketlane.mdx @@ -38,7 +38,7 @@ Create a new Rocketlane project with a customer, owner, dates, team members, tem | `memberUserIds` | array | No | User IDs of team members from your organization to add to the project | | `customerUserIds` | array | No | User IDs of customer stakeholders to add to the project | | `customerChampionUserId` | number | No | User ID of the customer champion | -| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string or number matching the field type\) | +| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string, number, or number array matching the field type\) | | `sources` | array | No | Project templates to import at creation, each with templateId, startDate \(YYYY-MM-DD\), and optional prefix | | `placeholders` | array | No | Placeholder-to-user mappings, each with placeholderId and user \(\{ userId \} or \{ emailId \}\); ignored unless the project is built from sources | | `assignProjectOwner` | boolean | No | Automatically assign unassigned tasks to the project owner \(skipped when no sources are used\) | @@ -448,7 +448,7 @@ Update a Rocketlane project by ID, including name, dates, visibility, owner, sta | `ownerUserId` | number | No | User ID of the new project owner \(transfers ownership and revokes access for the previous owner\) | | `ownerEmailId` | string | No | Email of the new project owner | | `statusValue` | number | No | Value \(identifier\) of the project status | -| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string or number matching the field type\) | +| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string, number, or number array matching the field type\) | | `annualizedRecurringRevenue` | number | No | Recurring revenue of the customer subscriptions for a single calendar year | | `projectFee` | number | No | Total fee charged for the project | | `autoAllocation` | boolean | No | Whether auto allocation is enabled for the project | @@ -1129,6 +1129,8 @@ Assign a placeholder in a Rocketlane project to a user | ↳ `role` | string | Role name of the assigned user | | ↳ `hourlyCostRate` | number | Latest hourly cost rate for the placeholder | | ↳ `costRateCurrency` | string | Currency for the cost rate | +| ↳ `hourlyBillRate` | number | Latest hourly bill rate for the placeholder | +| ↳ `billRateCurrency` | string | Currency for the bill rate | ### `rocketlane_unassign_placeholders` @@ -1261,6 +1263,8 @@ Unassign a placeholder from its user in a Rocketlane project | ↳ `role` | string | Role name of the assigned user | | ↳ `hourlyCostRate` | number | Latest hourly cost rate for the placeholder | | ↳ `costRateCurrency` | string | Currency for the cost rate | +| ↳ `hourlyBillRate` | number | Latest hourly bill rate for the placeholder | +| ↳ `billRateCurrency` | string | Currency for the bill rate | ### `rocketlane_create_task` diff --git a/apps/docs/content/docs/en/integrations/slack.mdx b/apps/docs/content/docs/en/integrations/slack.mdx index b02ea9e6cfb..dc543235e15 100644 --- a/apps/docs/content/docs/en/integrations/slack.mdx +++ b/apps/docs/content/docs/en/integrations/slack.mdx @@ -1913,6 +1913,7 @@ Trigger workflow from Slack events like mentions, messages, and reactions | ↳ `trigger_id` | string | Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands | | ↳ `callback_id` | string | Callback ID of the shortcut or view. Present for shortcuts and modal submissions | | ↳ `api_app_id` | string | Slack app ID. Present for interactivity and slash commands | +| ↳ `app_id` | string | App ID of the app that produced the event \(e.g. the bot that posted a message\). Used to identify the app's own output | | ↳ `message_ts` | string | Timestamp of the message the interaction originated from. Present for block_actions | | ↳ `view` | json | Full Slack view object for modal interactions: state.values \(submitted input values\), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise | | ↳ `message` | json | Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise | diff --git a/apps/docs/content/docs/en/integrations/tiktok.mdx b/apps/docs/content/docs/en/integrations/tiktok.mdx new file mode 100644 index 00000000000..47561089095 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/tiktok.mdx @@ -0,0 +1,239 @@ +--- +title: TikTok +description: Access TikTok profiles and videos, and upload inbox drafts +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Send uploaded videos to the TikTok inbox as drafts for human review and publishing, then track post status. + + + +## Actions + +### `tiktok_get_user` + +Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `fields` | string | No | Comma-separated allowlisted fields to return. open_id and display_name are always included. Available: open_id, union_id, avatar_url, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count. Include avatar_url or avatar_large_url to receive avatarFile. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `openId` | string | Unique TikTok user ID for this application | +| `unionId` | string | Unique TikTok user ID across all apps from the same developer | +| `displayName` | string | User display name | +| `bioDescription` | string | User bio description | +| `profileDeepLink` | string | Deep link to user TikTok profile | +| `isVerified` | boolean | Whether the account is verified | +| `username` | string | TikTok username | +| `followerCount` | number | Number of followers | +| `followingCount` | number | Number of accounts the user follows | +| `likesCount` | number | Total likes received across all videos | +| `videoCount` | number | Total number of public videos | +| `avatarFile` | file | Downloadable copy of the profile avatar image \(largest available variant\), stored as a workflow file so it can be chained into file-consuming blocks \(e.g. attached to an email\). | + +### `tiktok_list_videos` + +Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `maxCount` | number | No | Maximum number of videos to return \(1-20\) | +| `cursor` | number | No | Cursor for pagination \(from previous response\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `videos` | array | List of TikTok videos | +| ↳ `id` | string | Video ID | +| ↳ `title` | string | Video title | +| ↳ `coverImageUrl` | string | Signed TikTok CDN cover URL. It is public but time-limited, so consume it immediately. | +| ↳ `embedLink` | string | Embeddable video URL | +| ↳ `embedHtml` | string | HTML embed markup for the video | +| ↳ `duration` | number | Video duration in seconds | +| ↳ `createTime` | number | Unix timestamp when the video was created | +| ↳ `shareUrl` | string | Shareable video URL | +| ↳ `videoDescription` | string | Video description or caption | +| ↳ `width` | number | Video width in pixels | +| ↳ `height` | number | Video height in pixels | +| ↳ `viewCount` | number | Number of views | +| ↳ `likeCount` | number | Number of likes | +| ↳ `commentCount` | number | Number of comments | +| ↳ `shareCount` | number | Number of shares | +| `cursor` | number | Cursor for fetching the next page of results | +| `hasMore` | boolean | Whether there are more videos to fetch | + +### `tiktok_query_videos` + +Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `videoIds` | array | Yes | Array of video IDs to query \(maximum 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `videos` | array | List of queried TikTok videos | +| ↳ `id` | string | Video ID | +| ↳ `title` | string | Video title | +| ↳ `coverImageUrl` | string | Signed TikTok CDN cover URL. It is public but time-limited, so consume it immediately. | +| ↳ `embedLink` | string | Embeddable video URL | +| ↳ `embedHtml` | string | HTML embed markup for the video | +| ↳ `duration` | number | Video duration in seconds | +| ↳ `createTime` | number | Unix timestamp when the video was created | +| ↳ `shareUrl` | string | Shareable video URL | +| ↳ `videoDescription` | string | Video description or caption | +| ↳ `width` | number | Video width in pixels | +| ↳ `height` | number | Video height in pixels | +| ↳ `viewCount` | number | Number of views | +| ↳ `likeCount` | number | Number of likes | +| ↳ `commentCount` | number | Number of comments | +| ↳ `shareCount` | number | Number of shares | + +### `tiktok_upload_video_draft` + +Send an uploaded video to the authenticated user's TikTok inbox for manual editing and posting. The user must open TikTok and tap the inbox notification to complete the post. Rate limit: 6 requests per minute per user. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `file` | file | Yes | Video file to upload from the workflow. Maximum size: 250 MB. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publishId` | string | Unique identifier for tracking the draft status. Use this with the Get Post Status tool to check when the user has completed posting from their inbox. | + +### `tiktok_get_post_status` + +Check the status of a video draft upload. Use the publishId returned from Upload Video Draft to track progress. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `publishId` | string | Yes | The publish ID returned from a post/upload tool. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Current status of the post. Values: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD \(TikTok is processing the media\), SEND_TO_USER_INBOX \(draft delivered, awaiting user action\), PUBLISH_COMPLETE \(successfully posted\), FAILED \(check failReason\). | +| `failReason` | string | Reason for failure if status is FAILED. Null otherwise. | +| `publiclyAvailablePostId` | array | Array of public post IDs \(as strings\) once the content is published and publicly viewable. Can be used to construct the TikTok post URL. | +| `uploadedBytes` | number | Number of bytes uploaded to TikTok for FILE_UPLOAD posts | +| `downloadedBytes` | number | Number of bytes TikTok reports as downloaded | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### TikTok Authorization Removed + +Trigger when a user deauthorizes your TikTok app + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | TikTok webhook event name | +| `createTime` | number | UTC epoch seconds when the event occurred | +| `userOpenId` | string | TikTok user open_id for the connected account | +| `clientKey` | string | TikTok app client_key that received the event | +| `reason` | number | Revocation reason \(0 unknown, 1 user disconnect, 2 account deleted, 3 age change, 4 banned, 5 developer revoke\) | + + +--- + +### TikTok Post Inbox Delivered + +Trigger when a draft notification is delivered to the creator inbox + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post No Longer Public + +Trigger when a post is no longer publicly viewable on TikTok + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publicly Available + +Trigger when a published post becomes publicly viewable on TikTok + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publish Complete + +Trigger when a TikTok Content Posting publish completes + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + + +--- + +### TikTok Post Publish Failed + +Trigger when a TikTok Content Posting publish fails + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | TikTok Account | + diff --git a/apps/docs/content/docs/en/integrations/twilio.mdx b/apps/docs/content/docs/en/integrations/twilio.mdx index eb72d6f7405..f2522ffd92a 100644 --- a/apps/docs/content/docs/en/integrations/twilio.mdx +++ b/apps/docs/content/docs/en/integrations/twilio.mdx @@ -22,6 +22,7 @@ With the Twilio triggers, you can: In Sim, the Twilio triggers allow your agents to start workflows directly from Twilio messaging events. The Twilio Message Status trigger fires when a message's delivery status changes, exposing fields like `messageSid`, `smsStatus`, `messageStatus`, and `errorCode` so a workflow can react to delivery failures or confirmations. The Twilio SMS Received trigger fires on inbound SMS/MMS messages, exposing the sender and recipient numbers, message body, and any attached media, so a workflow can process incoming texts, route them, or trigger automated replies. {/* MANUAL-CONTENT-END */} + ## Triggers A **Trigger** is a block that starts a workflow when an event happens in this service. diff --git a/apps/docs/content/docs/en/integrations/whatsapp.mdx b/apps/docs/content/docs/en/integrations/whatsapp.mdx index 32d8ad5c094..786e6e96984 100644 --- a/apps/docs/content/docs/en/integrations/whatsapp.mdx +++ b/apps/docs/content/docs/en/integrations/whatsapp.mdx @@ -369,7 +369,7 @@ Trigger workflow from WhatsApp incoming messages and message status webhooks | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `verificationToken` | string | Yes | Enter any secure token here. You | +| `verificationToken` | string | Yes | Enter any secure token here. You'll need to provide the same token in your WhatsApp Business Platform dashboard. | | `appSecret` | string | Yes | Required for WhatsApp POST signature verification. Sim uses it to validate the X-Hub-Signature-256 header on every webhook delivery. | #### Output diff --git a/apps/docs/content/docs/en/integrations/zoom.mdx b/apps/docs/content/docs/en/integrations/zoom.mdx index 3010e9f611e..f8a4616335c 100644 --- a/apps/docs/content/docs/en/integrations/zoom.mdx +++ b/apps/docs/content/docs/en/integrations/zoom.mdx @@ -49,7 +49,7 @@ Create a new Zoom meeting | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user. | +| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user with OAuth credentials. With a Zoom server-to-server service account, "me" is not supported — provide the target user ID or email address. | | `topic` | string | Yes | Meeting topic \(e.g., "Weekly Team Standup" or "Project Review"\) | | `type` | number | No | Meeting type: 1=instant, 2=scheduled, 3=recurring no fixed time, 8=recurring fixed time | | `startTime` | string | No | Meeting start time in ISO 8601 format \(e.g., 2025-06-03T10:00:00Z\) | @@ -121,7 +121,7 @@ List all meetings for a Zoom user | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user. | +| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user with OAuth credentials. With a Zoom server-to-server service account, "me" is not supported — provide the target user ID or email address. | | `type` | string | No | Meeting type filter: scheduled, live, upcoming, upcoming_meetings, or previous_meetings | | `pageSize` | number | No | Number of records per page, 1-300 \(e.g., 30, 50, 100\) | | `nextPageToken` | string | No | Token for pagination to get next page of results | @@ -282,7 +282,7 @@ List all cloud recordings for a Zoom user | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user. | +| `userId` | string | Yes | The user ID or email address \(e.g., "me", "user@example.com", or "AbcDefGHi"\). Use "me" for the authenticated user with OAuth credentials. With a Zoom server-to-server service account, "me" is not supported — provide the target user ID or email address. | | `from` | string | No | Start date in yyyy-mm-dd format \(within last 6 months\) | | `to` | string | No | End date in yyyy-mm-dd format | | `pageSize` | number | No | Number of records per page, 1-300 \(e.g., 30, 50, 100\) | @@ -431,7 +431,7 @@ Trigger workflow when a Zoom meeting ends | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output @@ -454,7 +454,7 @@ Trigger workflow when a Zoom meeting starts | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output @@ -477,7 +477,7 @@ Trigger workflow when a participant joins a Zoom meeting | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output @@ -500,7 +500,7 @@ Trigger workflow when a participant leaves a Zoom meeting | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output @@ -523,7 +523,7 @@ Trigger workflow when a Zoom cloud recording is completed | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output @@ -546,7 +546,7 @@ Trigger workflow on any Zoom webhook event | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `secretToken` | string | Yes | Found in your Zoom app | +| `secretToken` | string | Yes | Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification. | #### Output diff --git a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx index fe91e17e83e..5c7464b6090 100644 --- a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx @@ -16,7 +16,7 @@ Verified Domains let organization owners and admins on Enterprise plans prove th ## Verify a domain -Go to **Settings → Security → Verified domains** in your organization settings. +Go to **Settings → Security → Single sign-on** in your organization settings. Domains are managed in the **Verified domains** section at the top of that page, directly above the identity provider configuration. 1. Enter the domain, for example `acme.com`, and click **Add domain**. 2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). diff --git a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx index 7ea7e503648..aa14fdbc1e6 100644 --- a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx @@ -12,21 +12,51 @@ Agent blocks can emit more than answer text while they run: **provider-exposed t Sim does **not** invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired). -## Protocol opt-in vs. event gates (public chat / simple SSE) +## Two independent switches -Sending the header is a statement about the **client**: it understands v1 framing, so answer text can stream live and be retracted with `chunk_reset`. That alone does not expose anything — it only changes cadence, and it applies even when both event policies are off. +Agent events are governed by two things that do not depend on each other. -Thinking and tool frames need the header **plus** their own deployment policy: +**Policy decides which frames exist.** `includeThinking` turns on `thinking` frames, `includeToolCalls` turns on `tool` frames, and both default to **off**. -1. Thinking frames require chat `includeThinking` (default **off**). -2. Tool lifecycle frames require chat `includeToolCalls` (default **off**). -3. Both require the request to opt in with: +| Surface | Policy source | +|---------|---------------| +| Deployed chat (`/api/chat/{identifier}`) | The chat deployment's **Thinking** and **Tool calls** toggles | +| Workflow API (`/api/workflows/{id}/execute`) | Per-request `includeThinking` / `includeToolCalls` in the body | + +**The header declares the protocol version.** Sending it says the client understands v1 framing: ```http X-Sim-Stream-Protocol: agent-events-v1 ``` -Legacy clients that omit the header keep today’s text-only SSE even if either deployment policy is enabled. The hosted chat UI always sends the header for its own deployments, so hosted chats stream token by token regardless of the toggles. +It does two things. It switches answer text to live token-by-token `chunk` frames that `chunk_reset` can retract, and it is **required** for any `thinking` or `tool` frame — a client that never declared a version has no contract for their shape, so it keeps the text-only stream it already understands. + +Omitting the header is always valid and always safe: you get settled final-turn text and no agent-event frames, which is what every pre-existing integration receives. The response echoes the header back when the protocol was negotiated. + +The header alone exposes nothing, so a chat with both policies off still streams token by token. + + +On the workflow API, setting `includeThinking` or `includeToolCalls` **without** the header is rejected with `400`. The flags would otherwise be a silent no-op, which is the failure mode this protocol exists to avoid. Deployed chat degrades instead of rejecting, because there the policy comes from the deployment rather than the request. + + +### Workflow API + +```bash +curl -N https://sim.ai/api/workflows/{id}/execute \ + -H "X-API-Key: $SIM_API_KEY" \ + -H "Content-Type: application/json" \ + -H "X-Sim-Stream-Protocol: agent-events-v1" \ + -d '{ + "stream": true, + "selectedOutputs": ["agent_1.content"], + "includeThinking": true, + "includeToolCalls": true + }' +``` + +Both flags default to `false`, so an existing integration receives exactly the frames it does today. The header is required whenever either flag is set; omit it and the request is rejected with `400` rather than silently downgraded. + +Two differences from deployed chat are worth knowing. Tool frames carry the name and status only on both surfaces, but the API's terminal `final` envelope keeps tool **arguments and results** — public chat redacts them. And thinking is delivered only as `thinking` frames; it is stripped from `providerTiming.timeSegments` in the envelope on every surface, so enabling the policy is the only way to receive it. ## Simple SSE frame shapes @@ -47,8 +77,8 @@ Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older c During a live tool loop, the model can’t be classified mid-turn: text it emits may turn out to be the final answer or preamble before a tool call (the stop reason arrives only at turn end). -- **Opted-in clients** (protocol header; no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. -- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered in one piece when the turn completes. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it. +- **Clients sending the protocol header** (no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. +- **Clients without the header** never see provisional text: only settled final-turn text is emitted as `chunk`, delivered in one piece when the turn completes. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it. Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted. @@ -62,7 +92,7 @@ Canvas execution-events `stream:chunk`, `stream:chunk_reset`, `stream:thinking`, ## Canvas (draft Run) -When you click **Run** in the builder, the execution-events SSE path forwards the same sink. The canvas is always opted in — it does not send (or need) the `X-Sim-Stream-Protocol` header; the dual gate applies only to the public chat / simple SSE surface: +When you click **Run** in the builder, the execution-events SSE path forwards the same sink. The canvas is always opted in — it does not send (or need) the `X-Sim-Stream-Protocol` header, and the policy switches do not apply to it: - `stream:thinking` — `{ blockId, text }` - `stream:tool` — `{ blockId, phase, id, name, status? }` @@ -73,7 +103,7 @@ The terminal output panel shows Thinking / Tools chrome above the block output w ## Chat deployment toggles -In **Deploy → Chat**, enable **Include thinking** for provider-exposed thinking and **Include tool calls** for tool names and lifecycle status. The switches are independent, and both event types still require the protocol header. Neither switch affects answer-text cadence. +In **Deploy → Chat**, enable **Include thinking** for provider-exposed thinking and **Include tool calls** for tool names and lifecycle status. The switches are independent of each other, and both still require the client to send the protocol header. Neither switch affects answer-text cadence. Tool arguments and results are never exposed to a public chat — not in lifecycle frames, and not through the terminal `final` envelope, where the block's own tool calls are reduced to the same name-and-lifecycle shape. The authenticated workflow API still returns full tool results. Redeploy or update the chat after changing Agent models or tools. @@ -89,6 +119,68 @@ Per-model support is generated from the model registry on the [Agent block page] | OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) | | Bedrock | Not invented | Yes when streaming tool loop is used | +## Consuming the stream + +A conforming client owes the stream four things: + +1. **Discriminate before appending.** Only a frame with **no** `event` field is answer text. Checking `event === undefined` rather than "has a `chunk` field" is what keeps future frame types from leaking into the answer. +2. **Accumulate per `blockId`.** A workflow can stream more than one block; frames interleave. +3. **Honor `chunk_reset`** if you sent the protocol header. Clear that block's accumulated text — it belonged to a turn that resolved to tool calls, and the final turn re-streams. +4. **Stop at the terminal frame.** Exactly one of `final` or `error` arrives, followed by the literal `"[DONE]"` sentinel. `stream_error` is *not* terminal. + +### Reference client + +```ts +type Frame = Record + +async function consume(response: Response) { + const reader = response.body!.getReader() + const decoder = new TextDecoder() + const answers = new Map() + const thinking = new Map() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + // SSE frames are newline-delimited; keep the trailing partial line. + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const payload = line.slice(6) + + const frame = JSON.parse(payload) as Frame | string + if (frame === '[DONE]') return { answers, thinking } + + const { blockId, event } = frame as { blockId?: string; event?: string } + + if (event === undefined && typeof frame.chunk === 'string') { + answers.set(blockId!, (answers.get(blockId!) ?? '') + frame.chunk) + } else if (event === 'chunk_reset') { + answers.set(blockId!, '') + } else if (event === 'thinking') { + thinking.set(blockId!, (thinking.get(blockId!) ?? '') + String(frame.data)) + } else if (event === 'tool') { + // frame.phase is 'start' | 'end'; frame.status is set on 'end'. + renderToolChip(frame) + } else if (event === 'final') { + // Terminal. frame.data.success may be false with frame.data.error. + } else if (event === 'error') { + throw new Error(String(frame.error)) + } else if (event === 'stream_error') { + // Non-terminal: log and keep reading. + } + } + } +} +``` + +Unknown `event` values should be ignored rather than treated as errors — that is what lets new frame types ship without breaking existing clients. + ## Example (public chat) diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index b2f713bed40..1e3821d5776 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -237,6 +237,28 @@ while (true) { +#### Streaming agent thinking and tool calls + +By default a streaming run carries answer text only. To also receive the Agent block's reasoning and its tool-call lifecycle, set `includeThinking` / `includeToolCalls`: + +```bash +curl -N -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ + -H "Content-Type: application/json" \ + -H "x-api-key: $SIM_API_KEY" \ + -H "X-Sim-Stream-Protocol: agent-events-v1" \ + -d '{ + "input": "Research this topic", + "stream": true, + "selectedOutputs": ["agent_1.content"], + "includeThinking": true, + "includeToolCalls": true + }' +``` + +The `X-Sim-Stream-Protocol` header is **required** whenever either flag is set — it declares that your client understands agent-event framing. Setting a flag without it returns `400` rather than silently ignoring the flag. The header also switches answer text to live token-by-token delivery that `chunk_reset` can retract. + +Both flags default to `false` and the header is optional on its own, so existing integrations keep the exact frames they receive today. Tool frames carry the tool name and status only; arguments and results arrive in the terminal `final` envelope. Not every model exposes thinking. See [Agent stream events](/docs/workflows/deployment/agent-events) for the frame shapes, a reference client, and per-provider support. + #### Oversized outputs Workflow execution responses are capped by platform request and response limits. When an internal output, log field, streamed field, or async status payload contains a value that is too large to inline, Sim may replace that nested value with a versioned reference: @@ -378,6 +400,7 @@ For detailed rate limit information and the logs/webhooks API, see [External API { question: "Can I deploy the same workflow as both an API and a chat?", answer: "Yes. A workflow can be simultaneously deployed as an API, chat, MCP tool, and more. Each deployment type runs against the same active snapshot." }, { question: "How do I choose between sync, streaming, and async?", answer: "Use sync for quick workflows that finish in seconds. Use streaming when you want to show progressive output to users as it's generated. Use async for long-running workflows where holding a connection open isn't practical." }, { question: "How do I select multiple outputs for streaming?", answer: "Open the Select outputs dropdown in the API tab and check each output field you want to stream. You can choose fields from multiple blocks. The selected fields are reflected as an array in the selectedOutputs request body parameter." }, + { question: "Can I stream an Agent block's thinking and tool calls over the API?", answer: "Yes. Set includeThinking and/or includeToolCalls to true in the request body and send the X-Sim-Stream-Protocol: agent-events-v1 header, which declares that your client understands agent-event framing. Setting a flag without the header returns 400 rather than silently ignoring it. Both flags default to false, so existing integrations are unaffected. Thinking arrives as thinking frames and tool lifecycle as tool frames carrying name and status only — tool arguments and results stay in the final envelope." }, { question: "How does Promote to live work?", answer: "Promote to live sets an older version as the active deployment without creating a new version. Subsequent API calls immediately run against the promoted snapshot. This is the fastest way to roll back to a previous state." }, { question: "How long are async job results available?", answer: "Completed and failed job results are retained for 24 hours. After that, the status endpoint returns 404. Retrieve and store results on your end if you need them longer." }, { question: "What happens if my API key is compromised?", answer: "Revoke the key immediately in Settings → Sim Keys and generate a new one. Revoked keys stop working instantly." }, diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts new file mode 100644 index 00000000000..75657ebb57e --- /dev/null +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -0,0 +1,30 @@ +/** + * Where the user goes once authentication finishes, carried across the login → + * signup → verify hops. Written only after `validateCallbackUrl` accepts it, and + * re-validated on read. + */ +export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' + +interface AuthCrossLinkParams { + /** Validated post-auth destination to carry over, or null to drop it. */ + callbackUrl: string | null + isInviteFlow: boolean +} + +/** + * Builds the login ⇄ signup cross-link, preserving the post-auth destination so + * a visitor who signs up instead of signing in still lands where they were + * headed. `URLSearchParams` does the encoding — a destination that carries its + * own query string (`/cli/auth?callback=…&state=…`) must survive intact. + */ +export function buildAuthCrossLink( + path: '/login' | '/signup', + { callbackUrl, isInviteFlow }: AuthCrossLinkParams +): string { + const params = new URLSearchParams() + if (isInviteFlow) params.set('invite_flow', 'true') + if (callbackUrl) params.set('callbackUrl', callbackUrl) + + const query = params.toString() + return query ? `${path}?${query}` : path +} diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 9fd5adb148c..359dd2f2671 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -21,6 +21,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -107,6 +108,10 @@ export default function LoginPage({ } const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace' const isInviteFlow = searchParams?.get('invite_flow') === 'true' + const signupHref = buildAuthCrossLink('/signup', { + callbackUrl: isValidCallbackUrl ? callbackUrl : null, + isInviteFlow, + }) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') @@ -431,11 +436,7 @@ export default function LoginPage({ )} {emailEnabled && ( - + )} diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index e7aa86ea1f9..f55bdfac5c9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -10,6 +10,7 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' +import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -343,9 +344,12 @@ function SignupFormContent({ if (typeof window !== 'undefined') { sessionStorage.setItem('verificationEmail', emailValue) - if (isInviteFlow && redirectUrl) { - sessionStorage.setItem('inviteRedirectUrl', redirectUrl) - sessionStorage.setItem('isInviteFlow', 'true') + if (redirectUrl) { + sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) + } else { + // Clear any leftover from an earlier signup in this tab — otherwise a + // signup with no callbackUrl inherits the previous CLI/invite destination. + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) } } @@ -468,7 +472,7 @@ function SignupFormContent({ diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 74c07ceae30..cd88a3d32c6 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -6,9 +6,39 @@ import { normalizeEmail } from '@sim/utils/string' import { useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { validateCallbackUrl } from '@/lib/core/security/input-validation' +import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' const logger = createLogger('useVerification') +/** + * Resolves the post-auth destination at the moment of redirect rather than + * caching it in state. + * + * Both redirect sites run in the same commit as the effect that reads session + * storage, so a cached value is still `null` when they fire and the stored + * destination is silently replaced by `/workspace`. Reading here removes that + * race. `redirectAfter` wins over the stored URL; anything failing callback + * validation is discarded, and an unsafe stored value is evicted. + */ +function resolveRedirectUrl(redirectParam: string | null): string | null { + let resolved: string | null = null + + const stored = sessionStorage.getItem(POST_AUTH_REDIRECT_STORAGE_KEY) + if (stored && validateCallbackUrl(stored)) { + resolved = stored + } else if (stored) { + logger.warn('Ignoring unsafe stored post-auth redirect URL', { url: stored }) + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + } + + if (redirectParam) { + if (validateCallbackUrl(redirectParam)) resolved = redirectParam + else logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam }) + } + + return resolved +} + /** * Mutually-exclusive phases of the email-OTP verification machine. * - `idle`: awaiting input @@ -53,44 +83,11 @@ export function useVerification({ const [isResending, setIsResending] = useState(false) const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [redirectUrl, setRedirectUrl] = useState(null) - const [isInviteFlow, setIsInviteFlow] = useState(false) useEffect(() => { - if (typeof window !== 'undefined') { - const storedEmail = sessionStorage.getItem('verificationEmail') - if (storedEmail) { - setEmail(storedEmail) - } - - const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') - if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) { - setRedirectUrl(storedRedirectUrl) - } else if (storedRedirectUrl) { - logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl }) - sessionStorage.removeItem('inviteRedirectUrl') - } - - const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') - if (storedIsInviteFlow === 'true') { - setIsInviteFlow(true) - } - } - - const redirectParam = searchParams.get('redirectAfter') - if (redirectParam) { - if (validateCallbackUrl(redirectParam)) { - setRedirectUrl(redirectParam) - } else { - logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam }) - } - } - - const inviteFlowParam = searchParams.get('invite_flow') - if (inviteFlowParam === 'true') { - setIsInviteFlow(true) - } - }, [searchParams]) + const storedEmail = sessionStorage.getItem('verificationEmail') + if (storedEmail) setEmail(storedEmail) + }, []) useEffect(() => { if (email && !isSendingInitialOtp && hasEmailService) { @@ -122,21 +119,12 @@ export function useVerification({ logger.warn('Failed to refetch session after verification', e) } - if (typeof window !== 'undefined') { - sessionStorage.removeItem('verificationEmail') - - if (isInviteFlow) { - sessionStorage.removeItem('inviteRedirectUrl') - sessionStorage.removeItem('isInviteFlow') - } - } + const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace' + sessionStorage.removeItem('verificationEmail') + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) setTimeout(() => { - if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl - } else { - window.location.href = '/workspace' - } + window.location.href = destination }, 1000) } else { logger.info('Setting invalid OTP state - API error response') @@ -217,28 +205,31 @@ export function useVerification({ }, [otp, email, status, isResending]) useEffect(() => { - if (typeof window !== 'undefined') { - if (!isEmailVerificationEnabled) { - setStatus('verified') + if (isEmailVerificationEnabled) return - const handleRedirect = async () => { - try { - await refetchSession() - } catch (error) { - logger.warn('Failed to refetch session during verification skip:', error) - } - - if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl - } else { - router.push('/workspace') - } - } + setStatus('verified') - handleRedirect() + const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) + // Single-use: consume the stored destination here too, or it survives this + // flow and reapplies to a later sign-in in the same tab. + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + + const handleRedirect = async () => { + try { + await refetchSession() + } catch (error) { + logger.warn('Failed to refetch session during verification skip:', error) + } + + if (destination) { + window.location.href = destination + } else { + router.push('/workspace') } } - }, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl]) + + handleRedirect() + }, [isEmailVerificationEnabled, router, searchParams]) return { otp, diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index aad098f2770..4fc9009a2f5 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useState } from 'react' import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' import { AuthFormMessage, AuthHeader, @@ -140,8 +141,7 @@ function VerificationForm({ onNavigate={() => { if (typeof window !== 'undefined') { sessionStorage.removeItem('verificationEmail') - sessionStorage.removeItem('inviteRedirectUrl') - sessionStorage.removeItem('isInviteFlow') + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) } }} /> diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 29e0ebb241f..71f10cbea0e 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -10,7 +10,7 @@ import { parseSettingsPathSection, } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isPlatformAdmin } from '@/lib/permissions/super-user' interface AccountSettingsSectionPageProps { @@ -46,7 +46,6 @@ export default async function AccountSettingsSectionPage({ }) if (!parsed) notFound() if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general')) - if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general')) if (parsed === 'admin' || parsed === 'mothership') { const isSuperUser = await isPlatformAdmin(session.user.id) if (!isSuperUser) notFound() diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 23f872c5077..0c78d76f216 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -148,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const domainNotVerifiedResponse = () => NextResponse.json( { - error: `Verify ownership of ${domain} under Settings → Verified domains before configuring SSO for it.`, + error: `Verify ownership of ${domain} under Verified domains above before configuring SSO for it.`, code: 'SSO_DOMAIN_NOT_VERIFIED', }, { status: 403 } diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 4ad1a4f0509..9f7fffd741f 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -212,7 +212,7 @@ export const PUT = withRouteHandler( authType: deployment.authType, outputConfigs: deployment.outputConfigs, includeThinking: deployment.includeThinking ?? false, - includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? false, }) setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 33a9bdd4a77..cd5834dcea0 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -413,7 +413,11 @@ describe('Chat Identifier API Route', () => { ) }, 10000) - it('preserves the legacy tool policy when includeToolCalls is null', async () => { + /** + * A row predating the column has no tool policy, so it has not opted in. + * Thinking must not drag tool frames along with it. + */ + it('reads a null tool policy as off rather than inheriting thinking', async () => { const thinkingChatResult = [ { ...mockChatResult[0], includeThinking: true, includeToolCalls: null }, ] @@ -447,7 +451,7 @@ describe('Chat Identifier API Route', () => { const options = vi.mocked(createStreamingResponse).mock.calls[0][0] expect(options.streamConfig).toMatchObject({ includeThinking: true, - includeToolCalls: true, + includeToolCalls: false, }) await options.executeFn({ @@ -458,7 +462,7 @@ describe('Chat Identifier API Route', () => { const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] expect(executeOptions).toMatchObject({ includeThinking: true, - includeToolCalls: true, + includeToolCalls: false, agentEvents: true, }) }, 10000) @@ -513,7 +517,11 @@ describe('Chat Identifier API Route', () => { }) }, 10000) - it('keeps agent events off when the protocol header is missing, even with policy on', async () => { + /** + * Chat degrades rather than rejecting: the policy comes from the + * deployment, so an un-negotiated client made no bad request. + */ + it('keeps agent events off without the protocol header, even with policy on', async () => { const thinkingChatResult = [ { ...mockChatResult[0], includeThinking: true, includeToolCalls: false }, ] diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 7402147449f..b39006615dd 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -40,7 +40,7 @@ function toChatConfigResponse(deployment: ChatConfigSource) { authType: deployment.authType, outputConfigs: deployment.outputConfigs, includeThinking: deployment.includeThinking ?? false, - includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? false, } } @@ -281,7 +281,7 @@ export const POST = withRouteHandler( } const includeThinking = deployment.includeThinking ?? false - const includeToolCalls = deployment.includeToolCalls ?? includeThinking + const includeToolCalls = deployment.includeToolCalls ?? false const agentEvents = shouldEmitAgentStreamEvents({ includeThinking, includeToolCalls, diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 4b021ae02f9..81ab09ae02e 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -160,7 +160,8 @@ describe('Chat Edit API Route', () => { expect(data.title).toBe('Test Chat') expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') expect(data.hasPassword).toBe(true) - expect(data.includeToolCalls).toBe(true) + // Stored null is not an opt-in. + expect(data.includeToolCalls).toBe(false) }) }) @@ -227,8 +228,9 @@ describe('Chat Edit API Route', () => { expect(response.status).toBe(200) expect(dbChainMockFns.update).toHaveBeenCalled() + // An unrelated field update materializes the stored null as false. expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ includeToolCalls: true }) + expect.objectContaining({ includeToolCalls: false }) ) const data = await response.json() expect(data.id).toBe('chat-123') @@ -236,10 +238,7 @@ describe('Chat Edit API Route', () => { expect(data.message).toBe('Chat deployment updated successfully') }) - it('turns grandfathered tool calls off when the same update disables thinking', async () => { - // Before includeToolCalls existed, includeThinking gated tool frames too. - // Resolving the fallback against the stored value would materialize the - // stale `true` and silently leave tool frames on. + it('leaves tool calls off when a row without a tool policy disables thinking', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) mockCheckChatAccess.mockResolvedValue({ diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index a79b6ed32d4..ec2a8ede3dc 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -62,7 +62,7 @@ export const GET = withRouteHandler( const result = { ...safeData, - includeToolCalls: safeData.includeToolCalls ?? safeData.includeThinking ?? false, + includeToolCalls: safeData.includeToolCalls ?? false, chatUrl, hasPassword: !!password, } @@ -257,16 +257,8 @@ export const PATCH = withRouteHandler( updateData.includeThinking = includeThinking } - /** - * Grandfathering must resolve against the thinking value this request is - * setting, not the stored one. Before `includeToolCalls` existed, - * `includeThinking` gated tool frames too, so a partial update turning - * thinking off has to turn them off as well rather than materializing the - * stale `true` and silently leaving tool frames enabled. - */ - const effectiveIncludeThinking = includeThinking ?? existingChatRecord.includeThinking - updateData.includeToolCalls = - includeToolCalls ?? existingChatRecord.includeToolCalls ?? effectiveIncludeThinking ?? false + // Partial updates keep the stored value; a row predating the column reads false. + updateData.includeToolCalls = includeToolCalls ?? existingChatRecord.includeToolCalls ?? false const emailCount = Array.isArray(updateData.allowedEmails) ? updateData.allowedEmails.length diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index e40afe0caee..9772b567188 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -99,7 +99,13 @@ describe('Chat API Route', () => { const response = await GET(req) expect(response.status).toBe(200) - expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ deployments: mockDeployments }) + // Each row is normalized so a missing tool policy reads as off. + expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ + deployments: mockDeployments.map((deployment) => ({ + ...deployment, + includeToolCalls: false, + })), + }) expect(dbChainMockFns.where).toHaveBeenCalled() }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 74091053c61..e916d48b2da 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -35,7 +35,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return createSuccessResponse({ deployments: deployments.map((deployment) => ({ ...deployment, - includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking, + includeToolCalls: deployment.includeToolCalls ?? false, })), }) } catch (error) { diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts new file mode 100644 index 00000000000..b270c7567cf --- /dev/null +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { createHash } from 'node:crypto' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/cli-auth/approval-store', () => ({ + createApproval: mockCreateApproval, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) + +import { POST } from '@/app/api/cli/auth/approve/route' + +const REQUEST = 'a'.repeat(43) +const CHALLENGE = createHash('sha256').update('b'.repeat(43)).digest('base64url') + +describe('POST /api/cli/auth/approve', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockEnforceUserRateLimit.mockResolvedValue(null) + mockCreateApproval.mockResolvedValue(undefined) + }) + + it('records the approval for the signed-in user', async () => { + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }) + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true }) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + }) + + it('rejects an unauthenticated caller', async () => { + mockGetSession.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }) + ) + expect(response.status).toBe(401) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('ignores a user id supplied in the body', async () => { + await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) + ) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + }) + + it('rejects a malformed challenge', async () => { + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: 'not-a-digest' }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts new file mode 100644 index 00000000000..8099914be91 --- /dev/null +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { approveCliAuthContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { createApproval } from '@/lib/cli-auth/approval-store' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CliAuthApproveAPI') + +/** + * Records a signed-in user's approval of a CLI handoff so the waiting terminal's + * poll can complete. + * + * The approving user comes from the session and nothing else — a client-supplied + * user id here would let any caller approve a request redeemable for someone + * else's key. No key is generated until the CLI polls. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const rateLimited = await enforceUserRateLimit('cli-auth-approve', session.user.id) + if (rateLimited) return rateLimited + + const parsed = await parseRequest(approveCliAuthContract, request, {}) + if (!parsed.success) return parsed.response + + await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) + logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + + return NextResponse.json({ ok: true }) +}) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts new file mode 100644 index 00000000000..89e7422a450 --- /dev/null +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockPollApproval, + mockCompleteApproval, + mockReleaseMint, + mockGenerateCopilotApiKey, + mockEnforceIpRateLimit, +} = vi.hoisted(() => ({ + mockPollApproval: vi.fn(), + mockCompleteApproval: vi.fn(), + mockReleaseMint: vi.fn(), + mockGenerateCopilotApiKey: vi.fn(), + mockEnforceIpRateLimit: vi.fn(), +})) + +vi.mock('@/lib/cli-auth/approval-store', () => ({ + pollApproval: mockPollApproval, + completeApproval: mockCompleteApproval, + releaseMint: mockReleaseMint, +})) + +vi.mock('@/lib/copilot/server/api-keys', () => ({ + generateCopilotApiKey: mockGenerateCopilotApiKey, + CopilotApiKeyError: class extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceIpRateLimit: mockEnforceIpRateLimit, +})) + +import { POST } from '@/app/api/cli/auth/poll/route' + +const REQUEST = 'a'.repeat(43) +const VERIFIER = 'b'.repeat(43) + +function pollRequest(body: Record) { + return createMockRequest('POST', body) +} + +describe('POST /api/cli/auth/poll', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnforceIpRateLimit.mockResolvedValue(null) + mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCompleteApproval.mockResolvedValue(undefined) + mockReleaseMint.mockResolvedValue(undefined) + }) + + it('returns pending without minting while unapproved', async () => { + mockPollApproval.mockResolvedValue({ status: 'pending' }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'pending' }) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints, then consumes the approval, once approved', async () => { + mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-1', apiKey: 'sk-test' }, + }) + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) + expect(mockReleaseMint).not.toHaveBeenCalled() + }) + + it('releases the reservation (keeps the approval) when minting fails', async () => { + mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(500) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { + mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockCompleteApproval.mockRejectedValue(new Error('redis blip')) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-1', apiKey: 'sk-test' }, + }) + // A cleanup failure must not release the mint lock — that would allow a re-mint. + expect(mockReleaseMint).not.toHaveBeenCalled() + }) + + it('rejects a malformed verifier before touching the store', async () => { + const response = await POST(pollRequest({ request: REQUEST, verifier: 'too-short' })) + expect(response.status).toBe(400) + expect(mockPollApproval).not.toHaveBeenCalled() + }) + + it('honors the IP rate limiter', async () => { + mockEnforceIpRateLimit.mockResolvedValue( + new Response(null, { status: 429 }) as unknown as never + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(429) + expect(mockPollApproval).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts new file mode 100644 index 00000000000..c5a7610f9de --- /dev/null +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { pollCliAuthContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' +import { enforceIpRateLimit } from '@/lib/core/rate-limiter' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter/storage' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CliAuthPollAPI') + +/** + * The CLI polls every 2s (30/min) for up to 15 minutes. The default public-IP + * bucket (10 burst, 5/min) would 429 within ~20s, so size the limit to the poll + * cadence with headroom. The endpoint is not a brute-force surface — an unknown + * request id just returns `pending`, and minting needs the 256-bit verifier — so + * a generous per-IP limit is safe. + */ +const POLL_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 60, + refillRate: 60, + refillIntervalMs: 60_000, +} + +/** Keys are named for the day they were issued, matching what the CLI prints. */ +function cliKeyName(): string { + return `CLI (${new Date().toISOString().slice(0, 10)})` +} + +/** + * The CLI's poll endpoint. Unauthenticated by necessity — the CLI has no + * session — but the request id is only a rendezvous handle and minting requires + * the poll secret, which never leaves the CLI. Returns `pending` until the user + * approves; on an authorized poll it mints, then consumes the approval — a + * failed mint releases the reservation so a later poll can retry. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimited = await enforceIpRateLimit('cli-auth-poll', request, POLL_RATE_LIMIT) + if (rateLimited) return rateLimited + + const parsed = await parseRequest(pollCliAuthContract, request, {}) + if (!parsed.success) return parsed.response + + const { request: requestId, verifier } = parsed.data.body + + const result = await pollApproval(requestId, verifier) + if (result.status === 'pending') { + return NextResponse.json({ status: 'pending' }) + } + + let key: Awaited> + try { + key = await generateCopilotApiKey(result.userId, cliKeyName()) + } catch (error) { + // Mint failed — release the reservation so a later poll can retry. + await releaseMint(requestId) + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return NextResponse.json( + { error: 'Failed to generate copilot API key' }, + { status: status ?? 500 } + ) + } + + // Mint succeeded — the key exists. Consuming the approval is best-effort: a + // cleanup failure must NOT release the lock (that would let a later poll mint + // a second, orphaned key). The record and lock share a TTL and expire together. + await completeApproval(requestId).catch((error) => { + logger.error('Failed to consume CLI approval after minting', { + error, + userId: result.userId, + }) + }) + logger.info('Minted CLI key on approved poll', { userId: result.userId }) + return NextResponse.json({ status: 'complete', key }) +}) diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 014b3de8849..3fe5e1d7db8 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -2,57 +2,26 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateCopilotApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const POST = withRouteHandler(async (req: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - - const parsed = await parseRequest(generateCopilotApiKeyContract, req, {}) - if (!parsed.success) return parsed.response - - const { name } = parsed.data.body - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId, name }), - spanName: 'sim → go /api/validate-key/generate', - operation: 'generate_api_key', - attributes: { [TraceAttr.UserId]: userId }, - }) - - if (!res.ok) { - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: res.status || 500 } - ) - } - - const data = (await res.json().catch(() => null)) as { apiKey?: string; id?: string } | null + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!data?.apiKey) { - return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 }) - } + const parsed = await parseRequest(generateCopilotApiKeyContract, req, {}) + if (!parsed.success) return parsed.response + try { + const key = await generateCopilotApiKey(session.user.id, parsed.data.body.name) + return NextResponse.json({ success: true, key }, { status: 201 }) + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined return NextResponse.json( - { success: true, key: { id: data?.id || 'new', apiKey: data.apiKey } }, - { status: 201 } + { error: 'Failed to generate copilot API key' }, + { status: status ?? 500 } ) - } catch (error) { - return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index b2c0399a531..a26b4cad06b 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,105 +1,50 @@ import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' +import { + CopilotApiKeyError, + deleteCopilotApiKey, + listCopilotApiKeys, +} from '@/lib/copilot/server/api-keys' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId }), - spanName: 'sim → go /api/validate-key/get-api-keys', - operation: 'get_api_keys', - attributes: { [TraceAttr.UserId]: userId }, - }) - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 }) - } +function errorResponse(error: unknown, fallback: string): NextResponse { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + const message = error instanceof CopilotApiKeyError ? error.message : fallback + return NextResponse.json({ error: message }, { status: status ?? 500 }) +} - const apiKeys = (await res.json().catch(() => null)) as - | { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[] - | null - - if (!Array.isArray(apiKeys)) { - return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 }) - } - - const keys = apiKeys.map((k) => { - const value = typeof k.apiKey === 'string' ? k.apiKey : '' - const last6 = value.slice(-6) - const displayKey = `•••••${last6}` - return { - id: k.id, - displayKey, - name: k.name || null, - createdAt: k.createdAt || null, - lastUsed: k.lastUsed || null, - } - }) +export const GET = withRouteHandler(async () => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + try { + const keys = await listCopilotApiKeys(session.user.id) return NextResponse.json({ keys }, { status: 200 }) } catch (error) { - return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 }) + return errorResponse(error, 'Failed to get keys') } }) export const DELETE = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const queryResult = deleteCopilotApiKeyQuerySchema.safeParse( - Object.fromEntries(new URL(request.url).searchParams) - ) - if (!queryResult.success) { - return NextResponse.json({ error: 'id is required' }, { status: 400 }) - } - const { id } = queryResult.data - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId, apiKeyId: id }), - spanName: 'sim → go /api/validate-key/delete', - operation: 'delete_api_key', - attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ApiKeyId]: id }, - }) - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 }) - } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const data = (await res.json().catch(() => null)) as { success?: boolean } | null - if (!data?.success) { - return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 }) - } + const queryResult = deleteCopilotApiKeyQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams) + ) + if (!queryResult.success) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + try { + await deleteCopilotApiKey(session.user.id, queryResult.data.id) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { - return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 }) + return errorResponse(error, 'Failed to delete key') } }) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 992dcd3dc47..fc87a1237da 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -254,7 +254,7 @@ export const POST = withRouteHandler( ? (persistedSnapshot.metadata.executionMode ?? 'sync') : undefined const includeThinking = persistedSnapshot.metadata.includeThinking === true - const includeToolCalls = persistedSnapshot.metadata.includeToolCalls ?? includeThinking + const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true if (isApiCaller && executionMode === 'stream') { const stream = await createStreamingResponse({ diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 7eecb5ee466..227c1422b04 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -15,9 +15,17 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' +import { columnMatchesRef } from '@/lib/table/column-keys' +import { + accessError, + checkAccess, + normalizeColumn, + rootErrorMessage, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -59,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } @@ -68,7 +78,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum msg.includes('already exists') || msg.includes('maximum column') || msg.includes('Invalid column') || - msg.includes('exceeds maximum') + msg.includes('exceeds maximum') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } @@ -116,9 +127,50 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } - if (updates.type) { + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any `options` alongside it. Only a real type change routes there; an + // unchanged type with options routes to the options-only update. + const currentColumn = table.schema.columns.find((c) => + columnMatchesRef(c, validated.columnName) + ) + const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type + + // Every write below is its own locked transaction, so any of them paired + // with a constraint write that is going to fail commits and then errors. + // Gate on the type the column ENDS UP with, not on whether the type is + // changing: an options-only update on an existing select column carries the + // same hazard as a conversion does. + const resultingType = updates.type ?? currentColumn?.type + if (updates.unique === true && resultingType === 'select') { + return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 }) + } + + if (typeChanging) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type as NonNullable, + ...(updates.options !== undefined ? { options: updates.options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + // Forwarded so the conversion validates against the constraint this + // same request is about to set, not the column's current one. + ...(updates.required !== undefined ? { required: updates.required } : {}), + }, + requestId + ) + } else if (updates.options !== undefined || updates.multiple !== undefined) { + updatedTable = await updateColumnOptions( + { + tableId, + columnName: updates.name ?? validated.columnName, + options: updates.options ?? currentColumn?.options ?? [], + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + // Forwarded so the removal guard validates against the constraint this + // same request is about to set, not the column's current one. + ...(updates.required !== undefined ? { required: updates.required } : {}), + }, requestId ) } @@ -146,6 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } @@ -162,7 +216,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu msg.includes('Invalid column') || msg.includes('exceeds maximum') || msg.includes('incompatible') || - msg.includes('duplicate') + msg.includes('duplicate') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } @@ -210,6 +265,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 9a525eb54fe..236eb556240 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete } from '@/lib/table/mutation-locks' import type { TableDeleteJobPayload } from '@/lib/table/types' import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' @@ -55,6 +56,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 }) } + // Gate the delete lock at enqueue: an admitted job runs to completion, so the + // lock must be checked before the job is created (the worker is a trusted + // continuation and does not re-check). + assertRowDelete(table) + // Validate the filter up front so the caller gets immediate feedback (the worker reuses it). const filterError = tableFilterError(filter, table.schema.columns) if (filterError) return filterError diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index df217ec7d31..6d717bdcdc3 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -8,7 +8,9 @@ import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' +import { namedRowMapper } from '@/lib/table/cell-format' +import { getColumnId } from '@/lib/table/column-keys' +import { formatCsvCell } from '@/lib/table/export-format' import { queryRows } from '@/lib/table/rows/service' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -52,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const columns = table.schema.columns // Stored row data is id-keyed; CSV headers and JSON keys are display names, so // translate id → name on the way out (export is a name-friendly boundary). - const nameById = buildNameById(table.schema) + const toNamedRow = namedRowMapper(columns) const safeName = sanitizeFilename(table.name) const filename = `${safeName}.${format}` @@ -100,14 +102,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou for (const row of result.rows) { if (format === 'csv') { - const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)])) + const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) } else { const prefix = firstJsonRow ? '' : ',' firstJsonRow = false - controller.enqueue( - encoder.encode(prefix + JSON.stringify(rowDataIdToName(row.data, nameById))) - ) + controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) } } @@ -144,18 +144,6 @@ function sanitizeFilename(name: string): string { return cleaned || 'table' } -/** - * Serializes a cell for CSV. Only string cells are formula-neutralized; numbers, - * booleans, dates, and JSON objects can never form a trigger and pass through verbatim. - */ -function formatCsvValue(value: unknown): string { - if (value === null || value === undefined) return '' - if (value instanceof Date) return value.toISOString() - if (typeof value === 'object') return JSON.stringify(value) - if (typeof value === 'string') return neutralizeCsvFormula(value) - return String(value) -} - function toCsvRow(values: string[]): string { return values.map(escapeCsvField).join(',') } diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 84aecb13c0c..8b5de09bee0 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -15,7 +15,12 @@ import { deleteWorkflowGroup, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableWorkflowGroupsAPI') @@ -47,6 +52,8 @@ async function validateWorkflowInWorkspace( * share this mapper instead of repeating the if-chain three times. */ function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (error instanceof Error) { const msg = error.message if (msg === 'Table not found' || msg.includes('not found')) { diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index b440dfaf680..900e97d3a4d 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -53,6 +54,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } + // Gate the locks before claiming the single write-job slot, so a locked table + // reports 423 here instead of holding the slot and failing inside the worker. + assertRowInsert(table) + if (mode === 'replace') assertRowDelete(table) + if (createColumns && createColumns.length > 0) assertSchemaMutable(table) + const ext = fileName.split('.').pop()?.toLowerCase() if (ext !== 'csv' && ext !== 'tsv') { return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index 0333d7fb4df..baf8c313a4f 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/utils/id', () => ({ vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') + const { TableLockedError } = await import('@/lib/table/mutation-locks') return { checkAccess: mockCheckAccess, accessError: (result: { status: number }) => { @@ -38,6 +39,10 @@ vi.mock('@/app/api/table/utils', async () => { return NextResponse.json({ error: message }, { status: result.status }) }, csvProxyBodyCapResponse: () => null, + tableLockErrorResponse: (error: unknown) => + error instanceof TableLockedError + ? NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 }) + : null, multipartErrorResponse: (error: { code: string; message: string }) => NextResponse.json( { error: error.message }, @@ -74,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({ limit >= 0 && current + added > limit, })) +import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/[tableId]/import/route' function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File { @@ -377,6 +383,21 @@ describe('POST /api/table/[tableId]/import', () => { expect(data.data?.insertedCount).toBe(0) }) + it('maps a lock violation from importAppendRows to 423, not 500', async () => { + // The append branch returns instead of rethrowing, so it must map the lock + // error itself — the outer catch's mapper never sees it. + mockImportAppendRows.mockRejectedValueOnce(new TableLockedError('insert')) + const response = await callPost( + createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) + ) + expect(response.status).toBe(423) + const data = await response.json() + expect(data.lock).toBe('insert') + // A `details` array would make the client treat it as a validation error + // and swallow the toast. + expect(data.details).toBeUndefined() + }) + it('accepts TSV files', async () => { const response = await callPost( createFormData( diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 1d6482390fd..7a9802442cc 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -44,6 +44,7 @@ import { checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, + tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -336,6 +337,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro }, }) } catch (err) { + // This branch returns rather than rethrowing, so the outer catch's + // mapper is unreachable from here — map the lock error first or a 423 + // degrades into a generic 500 (replace mode rethrows and maps fine). + const lockError = tableLockErrorResponse(err) + if (lockError) return lockError + const message = toError(err).message logger.warn(`[${requestId}] Append failed for table ${tableId}`, { total: coerced.length, @@ -408,6 +415,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw err } } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) const message = toError(error).message diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 5d0069fa570..ec78330932a 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -1,15 +1,30 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables' +import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table' +import { + deleteTable, + getTableById, + renameTable, + TableConflictError, + type TableSchema, + updateTableLocks, +} from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') @@ -65,6 +80,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab metadata: table.metadata ?? null, rowCount: table.rowCount, maxRows: maxRowsPerTable, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -104,7 +120,7 @@ export const PATCH = withRouteHandler( } const parsed = await parseRequest( - renameTableContract, + updateTableContract, request, { params }, { @@ -116,6 +132,8 @@ export const PATCH = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body + // `write` is the floor for either operation; a `locks` change additionally + // requires `admin` (checked below), matching the workflow-lock precedent. const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -125,20 +143,68 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const updated = await renameTable(tableId, validated.name, requestId, authResult.userId) + if (validated.locks !== undefined) { + // With the flag off you may still CLEAR locks — otherwise flipping the + // kill switch would strand an already-locked table with no way to + // unlock it, while enforcement of those stored locks keeps running. + // Only a lock actually transitioning off→on needs the feature enabled; + // comparing against the stored state (rather than "every value is + // false") is what lets the settings UI, which always submits the full + // four-flag draft, clear one lock while another stays on. + const enablesALock = TABLE_LOCK_KINDS.some((kind) => { + const flag = TABLE_LOCK_FLAGS[kind] + return validated.locks?.[flag] === true && !table.locks[flag] + }) + if (enablesALock) { + // Resolve with the same context the page uses to decide whether to + // show the panel — keyed on the workspace's host organization, not + // the viewer's active one. Without it an org- or user-targeted + // rollout would open the panel and then 403 on save. Looked up only + // on the enabling path, so an unlock never pays for it. + const workspace = await getWorkspaceWithOwner(table.workspaceId) + const enabled = await isFeatureEnabled('table-locks', { + userId: authResult.userId, + orgId: workspace?.organizationId ?? undefined, + }) + if (!enabled) { + return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) + } + } + const adminResult = await checkAccess(tableId, authResult.userId, 'admin') + if (!adminResult.ok) { + return NextResponse.json( + { error: 'Admin access required to change table locks' }, + { status: 403 } + ) + } + await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request) + } + + if (validated.name !== undefined) { + await renameTable(tableId, validated.name, requestId, authResult.userId) + } + + // Re-read so the response reflects both a rename and a lock change. + const updated = await getTableById(tableId) + if (!updated) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } return NextResponse.json({ success: true, data: { table: updated }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + if (error instanceof TableConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }) } - logger.error(`[${requestId}] Error renaming table:`, error) + logger.error(`[${requestId}] Error updating table:`, error) return NextResponse.json( - { error: getErrorMessage(error, 'Failed to rename table') }, + { error: getErrorMessage(error, 'Failed to update table') }, { status: 500 } ) } @@ -188,6 +254,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error) } diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index b1865223f83..82ec048ca84 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -21,6 +21,7 @@ import { checkAccess, rootErrorMessage, rowWriteErrorResponse, + tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') @@ -211,7 +212,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteRow(tableId, rowId, validated.workspaceId, requestId) + await deleteRow(table, rowId, requestId) return NextResponse.json({ success: true, @@ -221,6 +222,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + const errorMessage = toError(error).message if (errorMessage === 'Row not found') { diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index e200220ea11..5c73dd8dff1 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -433,6 +433,7 @@ export const DELETE = withRouteHandler( if (validated.rowIds) { const result = await deleteRowsByIds( + table, { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 45b530db29a..7506a3f521d 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -127,6 +127,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -206,6 +207,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, rowCount: t.rowCount, maxRows: t.maxRows, + locks: t.locks, workspaceId: t.workspaceId, createdBy: t.createdBy, createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), diff --git a/apps/sim/app/api/table/row-wire.ts b/apps/sim/app/api/table/row-wire.ts index 89c4cb93af7..d8458e56ec5 100644 --- a/apps/sim/app/api/table/row-wire.ts +++ b/apps/sim/app/api/table/row-wire.ts @@ -1,13 +1,13 @@ import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, - buildNameById, filterNamesToIds, - rowDataIdToName, rowDataNameToId, sortNamesToIds, -} from '@/lib/table' +} from '@/lib/table/column-keys' +import { resolveFilterSelectValues } from '@/lib/table/select-values' export interface RowWireTranslators { /** Inbound row data: wire keys → storage column ids. */ @@ -36,11 +36,12 @@ export function rowWireTranslators( return { dataIn: identity, dataOut: identity, filterIn: identity, sortIn: identity } } const idByName = buildIdByName(schema) - const nameById = buildNameById(schema) return { + dataOut: namedRowMapper(schema.columns), dataIn: (data) => rowDataNameToId(data, idByName), - dataOut: (data) => rowDataIdToName(data, nameById), - filterIn: (filter) => filterNamesToIds(filter, idByName), + // Rekey field refs name → id, then resolve select operand names → ids. + filterIn: (filter) => + resolveFilterSelectValues(filterNamesToIds(filter, idByName), schema.columns), sortIn: (sort) => sortNamesToIds(sort, idByName), } } diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 7424258ad0e..f3c62d56022 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -11,8 +11,27 @@ import type { MultipartError } from '@/lib/core/utils/multipart' import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' +import { TableLockedError } from '@/lib/table/mutation-locks' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** + * Maps a {@link TableLockedError} thrown by the service layer to a 423 response + * carrying `{ error, lock }`; returns `null` for any other error so the caller + * falls through to its existing handling. Call this as the FIRST statement of a + * table route's catch block — otherwise `rowWriteErrorResponse` (and the other + * substring funnels) turn the lock error into a generic 500. + * + * The body deliberately omits a `details` array: the client's `isValidationError` + * treats any `ApiClientError` with array-valued `details` as a field-validation + * error and swallows its toast, so a lock rejection must not carry one. + */ +export function tableLockErrorResponse(error: unknown): NextResponse | null { + if (error instanceof TableLockedError) { + return NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 }) + } + return null +} + /** * Validates a `filter` against the table's column schema, returning a 400 response on a bad field * (or `null` when the filter is valid or absent). Shared by the routes that accept a filter @@ -78,6 +97,11 @@ const ROW_WRITE_ERROR_PATTERNS = [ * unrecognized and the caller should log it and return its generic 500. */ export function rowWriteErrorResponse(error: unknown): NextResponse | null { + // A lock violation is a 423, not a 400/500 — check before the pattern match, + // which would otherwise let it fall through to the caller's generic 500. + const lockResponse = tableLockErrorResponse(error) + if (lockResponse) return lockResponse + const message = rootErrorMessage(error) if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { @@ -279,5 +303,7 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { required: col.required ?? false, unique: col.unique ?? false, ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), + ...(col.options ? { options: col.options } : {}), + ...(col.multiple ? { multiple: true } : {}), } } diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 6b3632c19f7..248002656ee 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -41,6 +41,7 @@ import { downloadServableFileFromStorage, } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { assertActiveWorkspaceAccess, @@ -175,9 +176,8 @@ const stripExtension = (name: string): string => { /** * Reduce an arbitrary name to a safe, flat file name: takes the final path * segment, drops directory and traversal components, and falls back when the - * result would be empty or a dot segment. Used for zip entry names and the - * compress archive name so untrusted input cannot introduce nested or - * zip-slip-style paths. + * result would be empty or a dot segment. Used for the compress archive name so + * untrusted input cannot introduce nested or zip-slip-style paths. */ const toFlatFileName = (name: string, fallback: string): string => { const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim() @@ -185,27 +185,10 @@ const toFlatFileName = (name: string, fallback: string): string => { return leaf } -/** - * Return a zip entry name unique within `usedNames`, appending a numeric suffix - * before the extension on collision (e.g., "data.csv" -> "data (1).csv"). - */ -const uniqueZipEntryName = (name: string, usedNames: Set): string => { - if (!usedNames.has(name)) { - usedNames.add(name) - return name - } - - const dot = name.lastIndexOf('.') - const base = dot > 0 ? name.slice(0, dot) : name - const ext = dot > 0 ? name.slice(dot) : '' - let counter = 1 - let candidate = `${base} (${counter})${ext}` - while (usedNames.has(candidate)) { - counter += 1 - candidate = `${base} (${counter})${ext}` - } - usedNames.add(candidate) - return candidate +/** A file bound for a compress archive, paired with the workspace folder it lives in. */ +interface ArchiveEntry { + file: UserFile + folderPath: string | null } const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0) @@ -678,17 +661,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const userFiles: UserFile[] = workspaceFiles - .map((file) => workspaceFileToUserFile(file)) - .filter((file): file is NonNullable> => - Boolean(file) - ) - .concat(selectedInputFiles) + const workspaceEntries: ArchiveEntry[] = workspaceFiles.flatMap((file) => { + const userFile = workspaceFileToUserFile(file) + return userFile ? [{ file: userFile, folderPath: file?.folderPath ?? null }] : [] + }) + + // Picker/upload values carry no workspace folder, so they archive at the root. + const archiveEntries = workspaceEntries.concat( + selectedInputFiles.map((file) => ({ file, folderPath: null })) + ) + const userFiles: UserFile[] = archiveEntries.map((entry) => entry.file) + + // Mirror the workspace folder layout, dropping the ancestor chain the whole + // selection shares so archiving one folder does not nest it under its parents. + const entryPaths = buildZipEntryPaths( + archiveEntries.map((entry) => ({ name: entry.file.name, folderPath: entry.folderPath })), + { rebaseOnCommonFolder: true } + ) const zip = new JSZip() - const usedNames = new Set() let totalBytes = 0 - for (const userFile of userFiles) { + for (const [index, userFile] of userFiles.entries()) { const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) if (denied) return denied @@ -707,7 +700,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } - zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer) + zip.file(entryPaths[index], buffer) } const zipBuffer = await zip.generateAsync({ diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 0eeebfb99ce..46723538c7c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -14,9 +14,16 @@ import { deleteColumn, renameColumn, updateColumnConstraints, + updateColumnOptions, updateColumnType, } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { columnMatchesRef } from '@/lib/table/column-keys' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -82,11 +89,21 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { + // Same caller-error set the internal columns route maps — an invalid + // select option set is a bad request, not a server fault. + if ( + error.message.includes('already exists') || + error.message.includes('maximum column') || + error.message.includes('Invalid column') || + error.message.includes('exceeds maximum') || + error.message.includes('option') + ) { return NextResponse.json({ error: error.message }, { status: 400 }) } if (error.message === 'Table not found') { @@ -138,9 +155,50 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu ) } - if (updates.type) { + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any `options` alongside it. Only a real type change routes there; an + // unchanged type with options routes to the options-only update. + const currentColumn = table.schema.columns.find((c) => + columnMatchesRef(c, validated.columnName) + ) + const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type + + // Every write below is its own locked transaction, so any of them paired + // with a constraint write that is going to fail commits and then errors. + // Gate on the type the column ENDS UP with, not on whether the type is + // changing: an options-only update on an existing select column carries the + // same hazard as a conversion does. + const resultingType = updates.type ?? currentColumn?.type + if (updates.unique === true && resultingType === 'select') { + return NextResponse.json({ error: 'Cannot set a select column as unique' }, { status: 400 }) + } + + if (typeChanging) { updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + { + tableId, + columnName: updates.name ?? validated.columnName, + newType: updates.type as NonNullable, + ...(updates.options !== undefined ? { options: updates.options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + // Forwarded so the conversion validates against the constraint this + // same request is about to set, not the column's current one. + ...(updates.required !== undefined ? { required: updates.required } : {}), + }, + requestId + ) + } else if (updates.options !== undefined || updates.multiple !== undefined) { + updatedTable = await updateColumnOptions( + { + tableId, + columnName: updates.name ?? validated.columnName, + options: updates.options ?? currentColumn?.options ?? [], + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + // Forwarded so the removal guard validates against the constraint this + // same request is about to set, not the column's current one. + ...(updates.required !== undefined ? { required: updates.required } : {}), + }, requestId ) } @@ -180,6 +238,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -195,7 +255,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu msg.includes('Invalid column') || msg.includes('exceeds maximum') || msg.includes('incompatible') || - msg.includes('duplicate') + msg.includes('duplicate') || + msg.includes('option') ) { return NextResponse.json({ error: msg }, { status: 400 }) } @@ -260,6 +321,8 @@ export const DELETE = withRouteHandler( }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index a64831c857c..c06492d02b7 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -6,7 +6,12 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable, type TableSchema } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + normalizeColumn, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -77,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() @@ -153,6 +159,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError logger.error(`[${requestId}] Error deleting table:`, error) return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 419da80ad35..2a7ea2fe7a5 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -13,14 +13,10 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { - buildIdByName, - buildNameById, - rowDataIdToName, - rowDataNameToId, - updateRow, -} from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { deleteRow, updateRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -88,13 +84,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - const nameById = buildNameById(result.table.schema as TableSchema) + const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) return NextResponse.json({ success: true, data: { row: { id: row.id, - data: rowDataIdToName(row.data as RowData, nameById), + data: toNamedRow(row.data as RowData), position: row.position, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), @@ -142,7 +138,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const updatedRow = await updateRow( { tableId, @@ -168,7 +164,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR data: { row: { id: updatedRow.id, - data: rowDataIdToName(updatedRow.data, nameById), + data: toNamedRow(updatedRow.data), position: updatedRow.position, createdAt: updatedRow.createdAt instanceof Date @@ -183,6 +179,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse @@ -236,20 +234,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const [deletedRow] = await db - .delete(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .returning({ id: userTableRows.id }) - - if (!deletedRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } + // Route through the service (not a raw `db.delete`) so the delete lock is + // enforced — the raw path would return 200 on a locked table. + await deleteRow(result.table, rowId, requestId) return NextResponse.json({ success: true, @@ -259,6 +246,11 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError + if (error instanceof Error && error.message === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index c1ffacf2218..d0e37376cca 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -17,21 +17,23 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Filter, RowData, TableSchema } from '@/lib/table' import { batchInsertRows, - buildIdByName, - buildNameById, deleteRowsByFilter, deleteRowsByIds, - filterNamesToIds, insertRow, - rowDataIdToName, - rowDataNameToId, - sortNamesToIds, updateRowsByFilter, validateBatchRows, validateRowData, validateRowSize, } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { + buildIdByName, + filterNamesToIds, + rowDataNameToId, + sortNamesToIds, +} from '@/lib/table/column-keys' import { queryRows } from '@/lib/table/rows/service' +import { resolveFilterSelectValues } from '@/lib/table/select-values' import { TableQueryValidationError } from '@/lib/table/sql' import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' import { @@ -68,7 +70,7 @@ async function handleBatchInsert( // External callers key row data by column name; storage keys by id. const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) const validation = await validateBatchRows({ @@ -95,7 +97,7 @@ async function handleBatchInsert( data: { rows: insertedRows.map((r) => ({ id: r.id, - data: rowDataIdToName(r.data, nameById), + data: toNamedRow(r.data), position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt, updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt, @@ -154,9 +156,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR // Translate name-keyed filter/sort fields → column ids; translate rows back. const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const filter = validated.filter - ? filterNamesToIds(validated.filter as Filter, idByName) + ? resolveFilterSelectValues( + filterNamesToIds(validated.filter as Filter, idByName), + (table.schema as TableSchema).columns + ) : undefined const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined @@ -178,7 +183,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR data: { rows: result.rows.map((r) => ({ id: r.id, - data: rowDataIdToName(r.data, nameById), + data: toNamedRow(r.data), position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), @@ -253,7 +258,7 @@ export const POST = withRouteHandler( } const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const rowData = rowDataNameToId(validated.data as RowData, idByName) const validation = await validateRowData({ @@ -279,7 +284,7 @@ export const POST = withRouteHandler( data: { row: { id: row.id, - data: rowDataIdToName(row.data, nameById), + data: toNamedRow(row.data), position: row.position, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, @@ -346,7 +351,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const result = await updateRowsByFilter( table, { - filter: filterNamesToIds(validated.filter as Filter, idByName), + filter: resolveFilterSelectValues( + filterNamesToIds(validated.filter as Filter, idByName), + (table.schema as TableSchema).columns + ), data: patchData, limit: validated.limit, actorUserId, @@ -419,6 +427,7 @@ export const DELETE = withRouteHandler( if (validated.rowIds) { const result = await deleteRowsByIds( + table, { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) @@ -442,7 +451,10 @@ export const DELETE = withRouteHandler( const result = await deleteRowsByFilter( table, { - filter: filterNamesToIds(validated.filter as Filter, idByName), + filter: resolveFilterSelectValues( + filterNamesToIds(validated.filter as Filter, idByName), + (table.schema as TableSchema).columns + ), limit: validated.limit, }, requestId diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index caf0e0a6df2..1df6b4b2384 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -6,14 +6,10 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { - buildIdByName, - buildNameById, - rowDataIdToName, - rowDataNameToId, - upsertRow, -} from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { upsertRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -63,7 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } const idByName = buildIdByName(table.schema as TableSchema) - const nameById = buildNameById(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const upsertResult = await upsertRow( { tableId, @@ -81,7 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser data: { row: { id: upsertResult.row.id, - data: rowDataIdToName(upsertResult.row.data, nameById), + data: toNamedRow(upsertResult.row.data), createdAt: upsertResult.row.createdAt instanceof Date ? upsertResult.row.createdAt.toISOString() @@ -96,6 +92,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser }, }) } catch (error) { + const lockError = tableLockErrorResponse(error) + if (lockError) return lockError const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index aaf113a3762..441f4fc1413 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -53,6 +53,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, rowCount: t.rowCount, maxRows: t.maxRows, + locks: t.locks, createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), updatedAt: @@ -137,6 +138,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, rowCount: table.rowCount, maxRows: table.maxRows, + locks: table.locks, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index fdfe2fdc166..64268ae2995 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -91,6 +91,7 @@ describe('Workflow Chat Status Route', () => { expect(data.deployment.hasPassword).toBe(true) expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }]) expect(data.deployment.includeThinking).toBe(true) - expect(data.deployment.includeToolCalls).toBe(true) + // Independent of thinking: a row without a tool policy reads as off. + expect(data.deployment.includeToolCalls).toBe(false) }) }) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index afa5f6ce6a7..ac79bc10fc9 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -74,10 +74,7 @@ export const GET = withRouteHandler( allowedEmails: deploymentResults[0].allowedEmails, outputConfigs: deploymentResults[0].outputConfigs, includeThinking: deploymentResults[0].includeThinking ?? false, - includeToolCalls: - deploymentResults[0].includeToolCalls ?? - deploymentResults[0].includeThinking ?? - false, + includeToolCalls: deploymentResults[0].includeToolCalls ?? false, hasPassword: Boolean(deploymentResults[0].password), } : null diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 0ba83187d81..f56a89504e6 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -88,11 +88,21 @@ import { loadWorkflowDeploymentVersionState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' +import { + AGENT_STREAM_PROTOCOL_HEADER_LABEL, + AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, + hasAgentStreamPolicy, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' import { forwardAgentStreamToExecutionEvents, shouldForwardAnswerTextFromSink, } from '@/lib/workflows/streaming/forward-agent-stream-events' -import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' @@ -668,6 +678,8 @@ async function handleExecutePost( selectedOutputs, triggerType = defaultTriggerType, stream: streamParam, + includeThinking: requestedIncludeThinking, + includeToolCalls: requestedIncludeToolCalls, useDraftState, input: validatedInput, isClientSession = false, @@ -1436,6 +1448,32 @@ async function handleExecutePost( isDeployed: workflow.isDeployed, variables: streamVariables, } + /** + * The caller asked for frames whose shape is defined by a protocol + * version they never declared. Rejecting beats silently downgrading: + * the flags would otherwise be a no-op with no way to notice. + */ + if ( + hasAgentStreamPolicy({ + includeThinking: requestedIncludeThinking, + includeToolCalls: requestedIncludeToolCalls, + }) && + !clientAcceptsAgentStreamProtocol(req.headers) + ) { + return NextResponse.json( + { + error: `includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.`, + }, + { status: 400 } + ) + } + + const agentEvents = shouldEmitAgentStreamEvents({ + includeThinking: requestedIncludeThinking, + includeToolCalls: requestedIncludeToolCalls, + requestHeaders: req.headers, + }) + const stream = await createStreamingResponse({ requestId, streamConfig: { @@ -1445,9 +1483,8 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, timeoutMs: preprocessResult.executionTimeout?.sync, - /** Workflow API has no deployed-chat event policies, so agent-event frames stay off. */ - includeThinking: false, - includeToolCalls: false, + includeThinking: requestedIncludeThinking, + includeToolCalls: requestedIncludeToolCalls, }, executionId, largeValueExecutionIds, @@ -1482,6 +1519,9 @@ async function handleExecutePost( fileKeys, stopAfterBlockId, runFromBlock: resolvedRunFromBlock, + includeThinking: requestedIncludeThinking, + includeToolCalls: requestedIncludeToolCalls, + agentEvents, }, executionId ), @@ -1490,7 +1530,11 @@ async function handleExecutePost( executionIdClaimCommitted = true return new NextResponse(stream, { status: 200, - headers: SSE_HEADERS, + headers: { + ...SSE_HEADERS, + // Echo the negotiated stream protocol (same as the chat and resume routes). + ...agentStreamProtocolResponseHeaders({ requestHeaders: req.headers }), + }, }) } diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index b6220308e55..577f0e7b2dc 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -14,34 +14,13 @@ import { listWorkspaceFiles, } from '@/lib/uploads/contexts/workspace' import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkspaceFilesDownloadAPI') const MAX_ZIP_DOWNLOAD_FILES = 100 const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 -function safeZipPath(path: string): string { - return path - .split('/') - .map((segment) => { - const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_') - return cleaned === '.' || cleaned === '..' ? '_' : cleaned - }) - .filter(Boolean) - .join('/') -} - -function withZipPathSuffix(path: string, suffix: number): string { - const slashIndex = path.lastIndexOf('/') - const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : '' - const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path - const dotIndex = filename.lastIndexOf('.') - - return dotIndex > 0 - ? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}` - : `${directory}${filename} (${suffix})` -} - function collectDescendantFolderIds( selectedFolderIds: string[], folders: Array<{ id: string; parentId: string | null }> @@ -115,25 +94,18 @@ export const GET = withRouteHandler( const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file))) - // Assemble zip synchronously so path deduplication is deterministic. + // Entry paths stay workspace-root-relative so a mixed selection of folders and + // loose files keeps the layout the user sees in the files list. + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + })) + ) + const zip = new JSZip() - const usedPaths = new Set() - for (let i = 0; i < filesToZip.length; i++) { - const file = filesToZip[i] - const buffer = buffers[i] - const folderPath = file.folderId ? folderPaths.get(file.folderId) : null - const basePath = - safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) || - safeZipPath(file.name) || - file.id - let zipPath = basePath - let suffix = 2 - while (usedPaths.has(zipPath)) { - zipPath = withZipPathSuffix(basePath, suffix) - suffix++ - } - usedPaths.add(zipPath) - zip.file(zipPath, buffer) + for (const [index, buffer] of buffers.entries()) { + zip.file(entryPaths[index], buffer) } const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts new file mode 100644 index 00000000000..f13d1e0cffa --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -0,0 +1,49 @@ +/** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ +const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ + +/** `XXXX-XXXX` over an alphabet with no look-alike characters. */ +const PAIRING_PATTERN = + /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/ + +export interface CliAuthRequest { + /** Rendezvous handle the CLI polls on; echoed back on approval. */ + request: string + /** SHA-256 challenge; the CLI proves the matching secret when it polls. */ + challenge: string + /** Printed by the CLI, rendered for eyeball comparison. Never sent to the API. */ + pairing: string +} + +export type CliAuthRequestResolution = + | { valid: true; request: CliAuthRequest } + | { valid: false; reason: string } + +interface RawCliAuthParams { + request: string | null + challenge: string | null + pairing: string | null +} + +/** + * Shared by the server page (which refuses to bounce an invalid request through + * login) and the client view (which renders the reason). + */ +export function resolveCliAuthRequest({ + request, + challenge, + pairing, +}: RawCliAuthParams): CliAuthRequestResolution { + if (!request || !challenge || !pairing) { + return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } + } + + if (!BASE64URL_43.test(request) || !BASE64URL_43.test(challenge)) { + return { valid: false, reason: 'This link is malformed.' } + } + + if (!PAIRING_PATTERN.test(pairing)) { + return { valid: false, reason: 'The pairing code is malformed.' } + } + + return { valid: true, request: { request, challenge, pairing } } +} diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx new file mode 100644 index 00000000000..96ad0648ffc --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -0,0 +1,78 @@ +'use client' + +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/components' +import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' +import { cliAuthParsers } from '@/app/cli/auth/search-params' +import { useApproveCliAuth } from '@/hooks/queries/cli-auth' + +/** + * The signed-in half of the CLI key handoff: a consent card that records the + * user's approval so the terminal's poll can complete. No key passes through + * the browser. + * + * The pairing code leads the card because it is the only signal that separates + * the visitor's own terminal from a link someone sent them — an attacker who + * opened the page supplies the request id and challenge, but not the code the + * victim's terminal printed. + */ +export function CliAuthView() { + const router = useRouter() + const [params] = useQueryStates(cliAuthParsers) + const approve = useApproveCliAuth() + + const resolution = resolveCliAuthRequest(params) + + if (!resolution.valid) { + return ( +
+ + + {resolution.reason} + +
+ ) + } + + const { request } = resolution + + return ( +
+ +
+
+ {/* `pl` offsets the trailing letter-space `tracking` adds after the last glyph, which would otherwise pull the code left of optical center. */} + + {request.pairing} + +
+ + approve.mutate( + { request: request.request, challenge: request.challenge }, + { onSuccess: () => router.push('/cli/auth/done') } + ) + } + > + Connect + + {approve.isError && ( + + {getErrorMessage(approve.error, 'Failed to connect. Please try again.')} + + )} +
+
+ ) +} diff --git a/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx b/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx new file mode 100644 index 00000000000..31a9610ccbe --- /dev/null +++ b/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx @@ -0,0 +1,15 @@ +import { AuthHeader } from '@/app/(auth)/components' + +/** + * Where the CLI's listener sends the browser once it has the authorization + * code. Static by design: the key is minted server-side during the CLI's + * exchange and never passes through this page. + */ +export function CliAuthDoneView() { + return ( + + ) +} diff --git a/apps/sim/app/cli/auth/done/page.tsx b/apps/sim/app/cli/auth/done/page.tsx new file mode 100644 index 00000000000..db3772dad3d --- /dev/null +++ b/apps/sim/app/cli/auth/done/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next' +import { AuthShell } from '@/app/(auth)/components' +import { CliAuthDoneView } from '@/app/cli/auth/done/cli-auth-done-view' + +export const metadata: Metadata = { + title: 'Terminal connected', + robots: { index: false, follow: false }, +} + +/** + * The CLI's loopback listener redirects here, so the flow ends on Sim's own + * chrome instead of a page served by the wizard. Public and sessionless on + * purpose — it renders a static confirmation and never touches the API. + */ +export default function CliAuthDonePage() { + return ( + + + + ) +} diff --git a/apps/sim/app/cli/auth/loading.tsx b/apps/sim/app/cli/auth/loading.tsx new file mode 100644 index 00000000000..e6a96d20127 --- /dev/null +++ b/apps/sim/app/cli/auth/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from '@sim/emcn' +import { AuthShell } from '@/app/(auth)/components' + +/** + * Consent-card skeleton, shared by the route fallback and `page.tsx`'s Suspense. + * + * Bars mirror the card's boxes so hydration doesn't shift the layout: a + * one-line heading, a description that wraps to two in the 400px column, then + * the 70px pairing panel (28px type + `py-5` + 1px border) and the `h-9` button. + */ +export function CliAuthLoading() { + return ( +
+ + + + + +
+ ) +} + +export default function CliAuthRouteLoading() { + return ( + + + + ) +} diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx new file mode 100644 index 00000000000..d36de9f8083 --- /dev/null +++ b/apps/sim/app/cli/auth/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import type { SearchParams } from 'nuqs/server' +import { getSession } from '@/lib/auth' +import { AuthShell } from '@/app/(auth)/components' +import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' +import { CliAuthLoading } from '@/app/cli/auth/loading' +import { cliAuthSearchParamsCache } from '@/app/cli/auth/search-params' + +export const metadata: Metadata = { + title: 'Connect your terminal', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +/** + * Browser half of the CLI key handoff. + * + * Signed-out visitors bounce through login carrying a *re-serialized* + * `callbackUrl` — only the params the handoff understands survive, so the round + * trip cannot be used to smuggle anything else back into this page. The request + * is validated before that bounce: a bogus callback is rejected here rather + * than after making the user sign in for nothing. + */ +export default async function CliAuthPage({ + searchParams, +}: { + searchParams: Promise +}) { + const [session, params] = await Promise.all([ + getSession(), + cliAuthSearchParamsCache.parse(searchParams), + ]) + + const resolution = resolveCliAuthRequest(params) + + if (resolution.valid && !session?.user) { + const query = new URLSearchParams({ + request: resolution.request.request, + challenge: resolution.request.challenge, + pairing: resolution.request.pairing, + }) + redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) + } + + return ( + + }> + + + + ) +} diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts new file mode 100644 index 00000000000..e62b286e594 --- /dev/null +++ b/apps/sim/app/cli/auth/search-params.ts @@ -0,0 +1,20 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query params for the CLI key handoff. Read-only for the + * life of the page, so there is no `urlKeys` companion. + * + * Nullable with no defaults: a missing value is an invalid request, not a state + * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + */ +export const cliAuthParsers = { + request: parseAsString, + challenge: parseAsString, + pairing: parseAsString, +} as const + +/** + * Server-side reader for the same parser map, so `page.tsx` decides on the + * login bounce from exactly the values the client component will re-read. + */ +export const cliAuthSearchParamsCache = createSearchParamsCache(cliAuthParsers) diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx new file mode 100644 index 00000000000..eacc4d54c24 --- /dev/null +++ b/apps/sim/app/selfhost/settings/[section]/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { + getSelfHostSettingsHref, + getSettingsSectionMeta, + parseSettingsPathSection, + SELFHOST_SETTINGS_ITEMS, +} from '@/components/settings/navigation' +import { SelfHostSettingsRenderer } from '@/components/settings/selfhost-settings-renderer' +import { getSession } from '@/lib/auth' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' + +interface SelfHostSettingsSectionPageProps { + params: Promise<{ section: string }> +} + +export async function generateMetadata({ + params, +}: SelfHostSettingsSectionPageProps): Promise { + const { section } = await params + const parsed = parseSettingsPathSection({ + path: section, + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: null, + }) + const meta = parsed ? getSettingsSectionMeta('selfhost', parsed) : null + return { title: meta ? `${meta.label} - Self-host settings` : 'Self-host settings' } +} + +export default async function SelfHostSettingsSectionPage({ + params, +}: SelfHostSettingsSectionPageProps) { + const session = await getSession() + if (!session?.user) redirect('/login') + + const { section } = await params + const parsed = parseSettingsPathSection({ + path: section, + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: null, + }) + if (!parsed) notFound() + if (parsed === 'billing' && !isBillingEnabled) redirect(getSelfHostSettingsHref('general')) + if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general')) + + /** + * Sections read URL query params via nuqs (which uses `useSearchParams` + * internally), so the renderer must sit under a Suspense boundary. + */ + return ( + + + + ) +} diff --git a/apps/sim/app/selfhost/settings/layout.tsx b/apps/sim/app/selfhost/settings/layout.tsx new file mode 100644 index 00000000000..ad705241995 --- /dev/null +++ b/apps/sim/app/selfhost/settings/layout.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation' +import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' +import { getSession } from '@/lib/auth' + +export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) { + const session = await getSession() + if (!session?.user) redirect('/login') + + return {children} +} diff --git a/apps/sim/app/selfhost/settings/page.tsx b/apps/sim/app/selfhost/settings/page.tsx new file mode 100644 index 00000000000..8224aa43280 --- /dev/null +++ b/apps/sim/app/selfhost/settings/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation' +import { getSelfHostSettingsHref } from '@/components/settings/navigation' + +export default function SelfHostSettingsPage() { + redirect(getSelfHostSettingsHref('general')) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts index 7e3ac184867..1dd0bb241bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts @@ -26,9 +26,22 @@ interface UseUnsavedChangesGuardParams { export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) { const router = useRouter() const [showUnsavedAlert, setShowUnsavedAlert] = useState(false) + const [isReleased, setIsReleased] = useState(false) const hasSentinelRef = useRef(false) useEffect(() => { + // The caller is navigating away — popping the seeded entry would cancel it. But + // Back during that window consumes the entry with no listener left to re-push + // it, so track that: a later rearm() must seed a fresh one rather than trust a + // stale ref and leave the surface unguarded. + if (isReleased) { + if (!hasSentinelRef.current) return + const handleSentinelConsumed = () => { + hasSentinelRef.current = false + } + window.addEventListener('popstate', handleSentinelConsumed) + return () => window.removeEventListener('popstate', handleSentinelConsumed) + } if (!isDirty) { // Clean again while still mounted (saved/reverted): pop the seeded entry so // it can't pile up across edit/save cycles. This runs in the effect body, @@ -58,16 +71,16 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('popstate', handlePopState) } - }, [isDirty]) + }, [isDirty, isReleased]) const handleBackClick = useCallback( (event: MouseEvent) => { - if (isDirty) { + if (isDirty && !isReleased) { event.preventDefault() setShowUnsavedAlert(true) } }, - [isDirty] + [isDirty, isReleased] ) const confirmDiscard = useCallback(() => { @@ -75,5 +88,25 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG router.push(backHref) }, [router, backHref]) - return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard } + /** + * Retires the guard: no unload warning, no Back trap (browser or the in-app back + * link), and no pop of the seeded entry when the form goes clean. Call it before + * navigating away on a successful save, and navigate with `router.replace` so the + * seeded entry is the one consumed. An operation that goes clean before it + * resolves (an optimistic delete) must release up front and {@link rearm} if it + * fails. + */ + const release = useCallback(() => setIsReleased(true), []) + + /** Restores guarding after a released operation failed and the surface stays. */ + const rearm = useCallback(() => setIsReleased(false), []) + + return { + showUnsavedAlert, + setShowUnsavedAlert, + handleBackClick, + confirmDiscard, + release, + rearm, + } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts index 507fe07a2f8..035cc143cdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -1 +1,5 @@ -export { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' +export { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, + ResourceTile, +} from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx index 90907001430..91340e06517 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx @@ -1,20 +1,30 @@ import type { ComponentType } from 'react' +import { cn } from '@sim/emcn' interface ResourceTileProps { icon: ComponentType<{ className?: string }> } +/** + * Geometry and border of the square resource tile — the single source for that + * chrome, shared by {@link ResourceTile} and `SettingsResourceRow` so the skills, + * custom tools, and settings surfaces cannot drift apart. Pair with a fill. Sizing + * the glyph is the tile's job: the descendant rule outranks an icon's own class. + */ +export const RESOURCE_TILE_BASE = + 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' + +/** Filled treatment worn by the skills and custom tools resource tiles. */ +export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' + /** * Square glyph tile identifying a workspace resource — the leading visual on a - * resource's row and on its detail heading. Single source for that chrome so - * the skills and custom tools surfaces cannot drift apart. + * resource's row and on its detail heading. */ export function ResourceTile({ icon: Icon }: ResourceTileProps) { return ( -
-
- -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 7f67d25704f..9fc500f6c84 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -42,17 +42,51 @@ export interface AddResourceDropdownProps { existingKeys: Set onAdd: (resource: MothershipResource) => void onSwitch?: (resourceId: string) => void - /** Resource types to hide from the dropdown (e.g. `['folder', 'task']`). */ + /** + * Resource types to hide from the dropdown. Must be referentially stable + * (a module constant) — it keys the underlying group memo. + */ excludeTypes?: readonly MothershipResourceType[] } -export type AvailableItem = { id: string; name: string; isOpen?: boolean; [key: string]: unknown } +export type AvailableItem = { id: string; name: string; [key: string]: unknown } interface AvailableItemsByType { type: MothershipResourceType items: AvailableItem[] } +interface AvailableResources { + groups: AvailableItemsByType[] + /** + * True while enabled and at least one list has yet to produce data. Callers + * that act on "no candidates" must check this first — an empty result during + * hydration means "not known yet", not "no match". + */ + isHydrating: boolean +} + +interface UseAvailableResourcesOptions { + /** + * Skips the underlying list queries and the group construction they feed + * while `false`, returning a stable empty result. Menus pass their own open + * state so a closed one costs nothing; the lists fetch on first open. + * + * Note this only defers the lists nothing else on the surface already needs — + * the mothership tab bar independently resolves tab names from the workflow, + * table, file, knowledge-base, and folder lists, so those stay warm there. + */ + enabled?: boolean + /** + * Resource types to omit from the result. Must be referentially stable + * (a module constant) — it keys the group memo. + */ + excludeTypes?: readonly MothershipResourceType[] +} + +/** Stable identity for the disabled result, so downstream memos never bust. */ +const NO_RESOURCE_GROUPS: AvailableItemsByType[] = [] + const LOG_DROPDOWN_LIMIT = 50 const LOG_DROPDOWN_FILTERS = { @@ -69,76 +103,99 @@ const LOG_DROPDOWN_FILTERS = { export function useAvailableResources( workspaceId: string, - existingKeys: Set, - excludeTypes?: readonly MothershipResourceType[] -): AvailableItemsByType[] { - const { data: workflows = [] } = useWorkflows(workspaceId) - const { data: tables = [] } = useTablesList(workspaceId) - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) - const { data: folders = [] } = useFolders(workspaceId) - const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId) - const { data: tasks = [] } = useMothershipChats(workspaceId) - const { data: schedules = [] } = useWorkspaceSchedules(workspaceId) - const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS) + options?: UseAvailableResourcesOptions +): AvailableResources { + const enabled = options?.enabled ?? true + const excludeTypes = options?.excludeTypes + // Destructured without `= []` defaults on purpose: a literal default allocates a + // fresh array every render while `data` is undefined (exactly the disabled state), + // which would bust the group memo below on every render. Undefined is stable. + const { data: workflows, isPending: workflowsPending } = useWorkflows(workspaceId, { enabled }) + const { data: tables, isPending: tablesPending } = useTablesList(workspaceId, 'active', { + enabled, + }) + const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', { + enabled, + }) + const { data: knowledgeBases, isPending: knowledgeBasesPending } = useKnowledgeBasesQuery( + workspaceId, + { enabled } + ) + const { data: folders, isPending: foldersPending } = useFolders(workspaceId, { enabled }) + const { data: fileFolders, isPending: fileFoldersPending } = useWorkspaceFileFolders( + workspaceId, + 'active', + { enabled } + ) + const { data: tasks, isPending: tasksPending } = useMothershipChats(workspaceId, { enabled }) + const { data: schedules, isPending: schedulesPending } = useWorkspaceSchedules(workspaceId, { + enabled, + }) + const { data: logsData, isPending: logsPending } = useLogsList( + workspaceId, + LOG_DROPDOWN_FILTERS, + { enabled } + ) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) - return useMemo(() => { + /** + * Keyed off `isPending` rather than `data === undefined` so a failed list + * settles to "not hydrating" — an errored query must not block the caller + * forever. + */ + const isHydrating = + enabled && + (workflowsPending || + tablesPending || + filesPending || + knowledgeBasesPending || + foldersPending || + fileFoldersPending || + tasksPending || + schedulesPending || + logsPending) + + const groups = useMemo(() => { + if (!enabled) return NO_RESOURCE_GROUPS const excluded = new Set(excludeTypes ?? []) const groups: AvailableItemsByType[] = [ { type: 'workflow' as const, - items: workflows.map((w) => ({ + items: (workflows ?? []).map((w) => ({ id: w.id, name: w.name, folderId: w.folderId ?? null, sortOrder: w.sortOrder, - isOpen: existingKeys.has(`workflow:${w.id}`), })), }, { type: 'folder' as const, - items: folders.map((f) => ({ + items: (folders ?? []).map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, sortOrder: f.sortOrder, - isOpen: existingKeys.has(`folder:${f.id}`), })), }, { type: 'table' as const, - items: tables.map((t) => ({ - id: t.id, - name: t.name, - isOpen: existingKeys.has(`table:${t.id}`), - })), + items: (tables ?? []).map((t) => ({ id: t.id, name: t.name })), }, { type: 'file' as const, - items: files.map((f) => ({ - id: f.id, - name: f.name, - folderId: f.folderId ?? null, - isOpen: existingKeys.has(`file:${f.id}`), - })), + items: (files ?? []).map((f) => ({ id: f.id, name: f.name, folderId: f.folderId ?? null })), }, { type: 'filefolder' as const, - items: fileFolders.map((f) => ({ + items: (fileFolders ?? []).map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, - isOpen: existingKeys.has(`filefolder:${f.id}`), })), }, { type: 'knowledgebase' as const, - items: (knowledgeBases ?? []).map((kb) => ({ - id: kb.id, - name: kb.name, - isOpen: existingKeys.has(`knowledgebase:${kb.id}`), - })), + items: (knowledgeBases ?? []).map((kb) => ({ id: kb.id, name: kb.name })), }, { type: 'integration' as const, @@ -147,25 +204,19 @@ export function useAvailableResources( name: integration.name, iconComponent: integration.icon, bgColor: integration.bgColor, - isOpen: existingKeys.has(`integration:${integration.blockType}`), })), }, { type: 'task' as const, - items: tasks.map((t) => ({ - id: t.id, - name: t.name, - isOpen: existingKeys.has(`task:${t.id}`), - })), + items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })), }, { type: 'scheduledtask' as const, - items: schedules + items: (schedules ?? []) .filter((s) => s.sourceType === 'job') .map((s) => ({ id: s.id, name: s.jobTitle || truncate(s.prompt ?? '', 40) || 'Scheduled Task', - isOpen: existingKeys.has(`scheduledtask:${s.id}`), })), }, { @@ -173,18 +224,13 @@ export function useAvailableResources( items: logs.map((log) => { const workflowName = log.workflow?.name ?? log.workflowId ?? 'Unknown' const time = formatDate(log.createdAt).compact - return { - id: log.id, - name: `${workflowName} · ${time}`, - workflowName, - time, - isOpen: existingKeys.has(`log:${log.id}`), - } + return { id: log.id, name: `${workflowName} · ${time}`, workflowName, time } }), }, ] return groups.filter((g) => !excluded.has(g.type)) }, [ + enabled, workflows, folders, fileFolders, @@ -194,13 +240,16 @@ export function useAvailableResources( tasks, schedules, logs, - existingKeys, excludeTypes, ]) + + // `groups` keeps its own stable identity so the consumers' downstream memos + // still key on it; only this wrapper changes when hydration settles. + return useMemo(() => ({ groups, isHydrating }), [groups, isHydrating]) } export type WorkflowTreeNode = - | { kind: 'workflow'; id: string; name: string; isOpen?: boolean } + | { kind: 'workflow'; id: string; name: string } | { kind: 'folder'; id: string; name: string; children: WorkflowTreeNode[] } export function buildWorkflowFolderTree( @@ -222,7 +271,6 @@ export function buildWorkflowFolderTree( kind: 'workflow', id: w.id, name: w.name, - isOpen: w.isOpen, }) const buildLevel = (parentId: string | null): WorkflowTreeNode[] => { @@ -262,7 +310,7 @@ export function buildWorkflowFolderTree( interface WorkflowFolderTreeItemsProps { nodes: WorkflowTreeNode[] - onSelect: (resource: MothershipResource, isOpen?: boolean) => void + onSelect: (resource: MothershipResource) => void } export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) { @@ -272,9 +320,7 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI node.kind === 'workflow' ? ( - onSelect({ type: 'workflow', id: node.id, title: node.name }, node.isOpen) - } + onClick={() => onSelect({ type: 'workflow', id: node.id, title: node.name })} > {getResourceConfig('workflow').renderDropdownItem({ item: { id: node.id, name: node.name }, @@ -297,8 +343,8 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI } export type FileFolderTreeNode = - | { kind: 'file'; id: string; name: string; isOpen?: boolean } - | { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] } + | { kind: 'file'; id: string; name: string } + | { kind: 'folder'; id: string; name: string; children: FileFolderTreeNode[] } export function buildFileFolderTree( fileItems: AvailableItem[], @@ -319,17 +365,15 @@ export function buildFileFolderTree( const childFiles = byFolder.get(parentId) ?? [] const nodes: FileFolderTreeNode[] = [] for (const folder of childFolders) { - const children = buildLevel(folder.id) nodes.push({ kind: 'folder', id: folder.id, name: folder.name, - isOpen: folder.isOpen, - children, + children: buildLevel(folder.id), }) } for (const file of childFiles) { - nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen }) + nodes.push({ kind: 'file', id: file.id, name: file.name }) } return nodes } @@ -339,7 +383,7 @@ export function buildFileFolderTree( interface FileFolderTreeItemsProps { nodes: FileFolderTreeNode[] - onSelect: (resource: MothershipResource, isOpen?: boolean) => void + onSelect: (resource: MothershipResource) => void } export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) { @@ -349,7 +393,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp node.kind === 'file' ? ( onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)} + onClick={() => onSelect({ type: 'file', id: node.id, title: node.name })} > {getResourceConfig('file').renderDropdownItem({ item: { id: node.id, name: node.name }, @@ -363,9 +407,7 @@ export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProp - onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen) - } + onClick={() => onSelect({ type: 'filefolder', id: node.id, title: node.name })} > {node.name} @@ -391,10 +433,8 @@ export function AddResourceDropdown({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) - const available = useAvailableResources(workspaceId, existingKeys, [ - ...(excludeTypes ?? []), - 'integration', - ]) + // Gated on `open` so an idle tab bar never fetches the workspace lists. + const { groups: available } = useAvailableResources(workspaceId, { enabled: open, excludeTypes }) const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { @@ -403,8 +443,8 @@ export function AddResourceDropdown({ } } - const select = (resource: MothershipResource, isOpen?: boolean) => { - if (isOpen && onSwitch) { + const select = (resource: MothershipResource) => { + if (onSwitch && existingKeys.has(`${resource.type}:${resource.id}`)) { onSwitch(resource.id) } else { onAdd(resource) @@ -446,7 +486,7 @@ export function AddResourceDropdown({ if (filtered.length > 0 && filtered[activeIndex]) { e.preventDefault() const { type, item } = filtered[activeIndex] - select({ type, id: item.id, title: item.name }, item.isOpen) + select({ type, id: item.id, title: item.name }) } } } @@ -494,7 +534,7 @@ export function AddResourceDropdown({ key={`${type}:${item.id}`} className={cn(index === activeIndex && 'bg-[var(--surface-active)]')} onMouseEnter={() => setActiveIndex(index)} - onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)} + onClick={() => select({ type, id: item.id, title: item.name })} > {config.renderDropdownItem({ item })} @@ -553,9 +593,7 @@ export function AddResourceDropdown({ {items.map((item) => ( - select({ type, id: item.id, title: item.name }, item.isOpen) - } + onClick={() => select({ type, id: item.id, title: item.name })} > {config.renderDropdownItem({ item })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index 8a266363937..7d0e88042b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -1,9 +1,3 @@ -export type { - AddResourceDropdownProps, - AvailableItem, - FileFolderTreeNode, - WorkflowTreeNode, -} from './add-resource-dropdown' export { AddResourceDropdown, buildFileFolderTree, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index e2035dbb6ec..47d3ad87474 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -1,4 +1,3 @@ -export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown' export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown' export { ResourceActions, ResourceContent } from './resource-content' export type { ResourceTypeConfig } from './resource-registry' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index a99416db8de..94881cfa3b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -41,7 +41,18 @@ import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' const EDGE_ZONE = 40 const SCROLL_SPEED = 8 -const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const +/** + * Types that cannot be opened as a resource tab. Folders and chats have no tab + * surface; integrations are `@`-mention-only (see `MENTION_ONLY_RESOURCE_TYPES` + * in `plus-menu-dropdown`), so they are never offered here. + * + * Module-scope by contract — `useAvailableResources` keys its group memo on this. + */ +const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = [ + 'folder', + 'task', + 'integration', +] as const /** * Returns the id of the nearest resource to `idx` that is in `filter` @@ -111,26 +122,37 @@ const PREVIEW_MODE_LABELS: Record = { preview: 'Edit Mode', } +/** + * Stable identity for the empty lookup across `enabled` toggles. Unlike + * `NO_RESOURCE_GROUPS`, nothing downstream keys on this identity — tab rows + * receive the derived `displayName` string — so it is cheap insurance rather + * than a guard against busting a downstream memo. + */ +const NO_RESOURCE_NAMES = new Map() + /** * Builds a `type:id` -> current name lookup from live query data so resource - * tabs always reflect the latest name even after a rename. + * tabs always reflect the latest name even after a rename. Skipped entirely + * when there are no tabs to label — a chat with no open resources must not + * fetch five workspace-wide lists. */ -function useResourceNameLookup(workspaceId: string): Map { - const { data: workflows = [] } = useWorkflows(workspaceId) - const { data: tables = [] } = useTablesList(workspaceId) - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) - const { data: folders = [] } = useFolders(workspaceId) +function useResourceNameLookup(workspaceId: string, enabled: boolean): Map { + const { data: workflows } = useWorkflows(workspaceId, { enabled }) + const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) + const { data: files } = useWorkspaceFiles(workspaceId, 'active', { enabled }) + const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId, { enabled }) + const { data: folders } = useFolders(workspaceId, { enabled }) return useMemo(() => { + if (!enabled) return NO_RESOURCE_NAMES const map = new Map() - for (const w of workflows) map.set(`workflow:${w.id}`, w.name) - for (const t of tables) map.set(`table:${t.id}`, t.name) - for (const f of files) map.set(`file:${f.id}`, f.name) + for (const w of workflows ?? []) map.set(`workflow:${w.id}`, w.name) + for (const t of tables ?? []) map.set(`table:${t.id}`, t.name) + for (const f of files ?? []) map.set(`file:${f.id}`, f.name) for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) - for (const folder of folders) map.set(`folder:${folder.id}`, folder.name) + for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name) return map - }, [workflows, tables, files, knowledgeBases, folders]) + }, [enabled, workflows, tables, files, knowledgeBases, folders]) } interface ResourceTabItemProps { @@ -256,7 +278,7 @@ export function ResourceTabs({ actions, }: ResourceTabsProps) { const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split'] - const nameLookup = useResourceNameLookup(workspaceId) + const nameLookup = useResourceNameLookup(workspaceId, resources.length > 0) const { selectResource, addResource: onAddResource, @@ -320,10 +342,7 @@ export function ResourceTabs({ anchorIdRef.current = null } - const existingKeys = useMemo( - () => new Set(resources.map((r) => `${r.type}:${r.id}`)), - [resources] - ) + const existingKeys = new Set(resources.map((r) => `${r.type}:${r.id}`)) const handleAdd = useCallback( (resource: MothershipResource) => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 1e7ee30ce22..875915775bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -41,7 +41,17 @@ export interface PlusMenuHandle { open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void close: () => void moveActive: (delta: number) => void - selectActive: () => boolean + /** + * Confirms the highlighted candidate. + * + * - `selected` — a candidate was inserted. + * - `empty` — the lists are loaded and nothing matches, so the caller should + * let the key through (Enter submits, Tab does its default). + * - `hydrating` — the lists are still loading, so "nothing matches" is not yet + * knowable. The caller must swallow the key rather than submit a message + * with the mention left as raw text. + */ + selectActive: () => 'selected' | 'empty' | 'hydrating' } /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index bf00d079201..321b685c908 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -23,7 +23,6 @@ export { } from './constants' export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' -export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' export type { PromptEditorInstance, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts index 00d1cf03946..5a9b0e5cfd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts @@ -1 +1 @@ -export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown' +export { PlusMenuDropdown } from './plus-menu-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index a29c59b28b3..963b98bf5aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -17,7 +17,7 @@ import { buildFileFolderTree, buildWorkflowFolderTree, FileFolderTreeItems, - type useAvailableResources, + useAvailableResources, WorkflowFolderTreeItems, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' @@ -27,17 +27,28 @@ import type { MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -export type AvailableResourceGroup = ReturnType[number] - /** * Resource types that are only offered via `@`-mention autocomplete and hidden * from the `+` browse menu. Integrations are searchable inline (e.g. typing * `@sla` surfaces Slack) but should not clutter the explicit attach menu. + * + * Filtered here rather than via the hook's `excludeTypes` because the exclusion + * is mode-dependent (`isMention`) — one fetch serves both modes. The resource + * tab bar, whose exclusion is static, uses `excludeTypes` instead + * (`ADD_RESOURCE_EXCLUDED_TYPES` in `resource-tabs`). */ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) interface PlusMenuDropdownProps { - availableResources: AvailableResourceGroup[] + workspaceId: string + /** + * Starts hydrating the resource lists before the menu opens. The editor sets + * this on focus: `@`-mention confirmation reads the candidate list + * synchronously on Enter, and an empty list falls through to submitting the + * message with the mention unresolved. Focus is the earliest reliable signal + * that a mention may be coming, and still keeps these lists off page load. + */ + warm?: boolean onResourceSelect: (resource: MothershipResource) => void onClose: () => void textareaRef: React.RefObject @@ -48,7 +59,7 @@ interface PlusMenuDropdownProps { export const PlusMenuDropdown = React.memo( React.forwardRef(function PlusMenuDropdown( - { availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, + { workspaceId, warm, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, ref ) { const [open, setOpen] = useState(false) @@ -59,6 +70,11 @@ export const PlusMenuDropdown = React.memo( const searchRef = useRef(null) const contentRef = useRef(null) + // Gated so an idle chat surface never fetches the workspace lists. + const { groups: availableResources, isHydrating } = useAvailableResources(workspaceId, { + enabled: open || !!warm, + }) + const doOpen = useCallback( (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { setAnchorPos(anchor) @@ -74,8 +90,6 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) - // The `+` browse menu hides mention-only resource types; `@`-mention mode - // exposes the full catalog so integrations remain searchable inline. const visibleResources = useMemo( () => isMention @@ -115,6 +129,8 @@ export const PlusMenuDropdown = React.memo( activeIndexRef.current = activeIndex const isMentionRef = useRef(isMention) isMentionRef.current = isMention + const isHydratingRef = useRef(isHydrating) + isHydratingRef.current = isHydrating // Reset highlight to the top whenever the mention query changes so the user always // sees the best match selected as they type. @@ -149,15 +165,14 @@ export const PlusMenuDropdown = React.memo( }, selectActive: () => { const items = filteredItemsRef.current - if (!items || items.length === 0) return false - const target = items[activeIndexRef.current] ?? items[0] - if (!target) return false + const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined + if (!target) return isHydratingRef.current ? 'hydrating' : 'empty' handleSelectRef.current({ type: target.type, id: target.item.id, title: target.item.name, }) - return true + return 'selected' }, }), [doOpen, doClose] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index f971a60e332..94be2fc3628 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -72,6 +72,12 @@ export function PromptEditor({ }: PromptEditorProps) { const { textareaRef, value } = editor const scrollerRef = useRef(null) + /** + * Latched on first focus and never cleared: it only starts the resource + * lists early so an `@`-mention has candidates by the time Enter is pressed. + * Un-warming on blur would just re-open the race on the next focus. + */ + const [hasFocused, setHasFocused] = useState(false) /** * Autosize: grow the textarea to its full content height; the scroller caps @@ -203,6 +209,7 @@ export function PromptEditor({ onKeyDown={ readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty }) } + onFocus={readOnly ? undefined : () => setHasFocused(true)} onPaste={readOnly ? undefined : editor.handlePaste} onCopy={editor.handleCopy} onCut={readOnly ? undefined : editor.handleCut} @@ -219,7 +226,8 @@ export function PromptEditor({ <> ({ useSkills: () => ({ data: [] }) })) vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/workspace-file-folders', () => ({ - useWorkspaceFileFolders: () => ({ data: [] }), -})) -vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) })) vi.mock('@/blocks/integration-matcher', () => ({ getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), - listIntegrations: () => [], })) import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' @@ -91,7 +79,7 @@ describe('usePromptEditor mention menu dismissal', () => { open: vi.fn(), close: vi.fn(), moveActive: vi.fn(), - selectActive: vi.fn(() => false), + selectActive: vi.fn(() => 'empty' as const), } }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 3cc377bd39a..a969e39b741 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import { chipDisplayToken, @@ -282,21 +281,6 @@ export function usePromptEditor({ seedRef.current = skills.length > 0 || mcpServers.length > 0 ? null : converted }, [skills.length, mcpServers.length, applyAutoMentions]) - const existingResourceKeys = useMemo(() => { - const keys = new Set() - for (const ctx of contextManagement.selectedContexts) { - if (ctx.kind === 'workflow' && ctx.workflowId) keys.add(`workflow:${ctx.workflowId}`) - if (ctx.kind === 'knowledge' && ctx.knowledgeId) keys.add(`knowledgebase:${ctx.knowledgeId}`) - if (ctx.kind === 'table' && ctx.tableId) keys.add(`table:${ctx.tableId}`) - if (ctx.kind === 'file' && ctx.fileId) keys.add(`file:${ctx.fileId}`) - if (ctx.kind === 'folder' && ctx.folderId) keys.add(`folder:${ctx.folderId}`) - if (ctx.kind === 'past_chat' && ctx.chatId) keys.add(`task:${ctx.chatId}`) - } - return keys - }, [contextManagement.selectedContexts]) - - const availableResources = useAvailableResources(workspaceId, existingResourceKeys) - /** * Programmatically replaces the editor text. Chipifies by default so any * seeded prose (template, transcript, queued message) registers its @@ -689,9 +673,13 @@ export function usePromptEditor({ return } if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) { - // Confirm the highlighted match if there is one. If no items match, fall - // through so Enter still submits and Tab still does its default thing. - if (plusMenuRef.current?.selectActive()) { + // Confirm the highlighted match if there is one. If the lists are still + // loading, swallow the key — "no match" isn't knowable yet, and falling + // through would submit the message with the mention left as raw text. + // Only once they are loaded does an empty result mean a genuine no-match, + // where Enter should submit and Tab should do its default thing. + const result = plusMenuRef.current?.selectActive() + if (result === 'selected' || result === 'hydrating') { e.preventDefault() return } @@ -1041,12 +1029,12 @@ export function usePromptEditor({ textareaRef, /** @internal Wiring consumed by the {@link PromptEditor} view. */ + workspaceId, + /** @internal */ skills, /** @internal */ mcpServers, /** @internal */ - availableResources, - /** @internal */ mentionQuery, /** @internal */ slashQuery, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 924ecc95aba..6303933ab1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -36,6 +36,8 @@ const SECTION_ALIASES: Readonly> = { subscription: 'billing', team: 'organization', 'api-keys': 'apikeys', + // Verified domains moved into the SSO page; keep old links working. + domains: 'sso', } const TOP_LEVEL_REDIRECTS: Readonly string>> = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 94aae591694..bde90b3f029 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -80,6 +80,57 @@ export const groupIdUrlKeys = { clearOnDefault: true, } as const +/** + * `group-tab` is the active tab inside the deep-linked permission-group detail + * view, so a shared `group-id` link can land on the same tab (mirrors + * `server-tab` on the workflow MCP server detail). + */ +export const groupTabParam = { + key: 'group-tab', + parser: parseAsStringLiteral(['general', 'providers', 'blocks', 'platform'] as const).withDefault( + 'general' + ), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const groupTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-search` is the search box inside the permission-group detail view. The + * provider/block/platform tabs never render together, so they share one param + * rather than carrying three mutually-exclusive keys; the tab handler clears it + * so a query cannot bleed across tabs. Distinct from the list's shared + * `?search=` (`useSettingsSearch`), which belongs to the group list behind it. + */ +export const groupSearchParam = { + key: 'group-search', + parser: parseAsString.withDefault(''), +} as const + +/** Search view-state: clean URLs, no back-stack churn. */ +export const groupSearchUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-status` filters the permission-group detail's toggle lists by enabled + * state. Shared across the tabs for the same reason as `group-search`. + */ +export const groupStatusParam = { + key: 'group-status', + parser: parseAsStringLiteral(['all', 'enabled', 'disabled'] as const).withDefault('all'), +} as const + +/** Filter view-state: clean URLs, no back-stack churn. */ +export const groupStatusUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `custom-block-id` deep-links the Custom Blocks settings tab to a specific * block's detail sub-view. The "create new" flow stays in local state — only @@ -112,6 +163,22 @@ export const customToolIdUrlKeys = { clearOnDefault: true, } as const +/** + * `data-drain-id` deep-links the Data Drains settings tab to a specific drain's + * detail sub-view. The "create new" flow stays in local state — only existing + * entities are deep-linkable. + */ +export const dataDrainIdParam = { + key: 'data-drain-id', + parser: parseAsString, +} as const + +/** Opening a drain's detail is a destination → push to history; clear on close. */ +export const dataDrainIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 5d2a80a52e6..d87692eb1d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -25,9 +25,6 @@ const ApiKeys = dynamic(() => const BYOK = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) ) -const Copilot = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then((m) => m.Copilot) -) const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) @@ -81,9 +78,6 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) -const DomainSettings = dynamic(() => - import('@/ee/sso/components/domain-settings').then((m) => m.DomainSettings) -) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (m) => m.SessionPolicySettings @@ -166,9 +160,6 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } - {effectiveSection === 'domains' && organizationId && ( - - )} {effectiveSection === 'sessions' && organizationId && ( )} @@ -182,7 +173,6 @@ export function SettingsPage({ section }: SettingsPageProps) { )} {effectiveSection === 'byok' && } - {effectiveSection === 'copilot' && } {effectiveSection === 'mcp' && } {effectiveSection === 'forks' && } {effectiveSection === 'custom-tools' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx index 940969b0ced..12a3adccf4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx @@ -52,6 +52,7 @@ function ActivityLogRow({ >
{isLoadingSettings ? null : ( { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 58c041482a2..05da1f82dbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -43,6 +43,9 @@ vi.mock('@sim/emcn', () => ({ {children} ), Credit: () => , + Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => ( + + ), Switch: ({ checked, disabled, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 56de071b7e5..851bc668d9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -7,6 +7,7 @@ import { Credit, chipVariants, cn, + Label, Switch, Tooltip, toast, @@ -494,14 +495,17 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps {showOnDemand && (
- - Allow usage to go past included usage - + {onDemandLockedOn ? ( - + @@ -514,6 +518,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps ) : (
- - Email me when I reach 80% usage - + { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index ba957ffde7e..51d3bd630a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -270,7 +270,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { if (props.multiKey) { const keyCount = getProviderKeys(provider.id).length return ( -
+
{keyCount} {keyCount === 1 ? 'key' : 'keys'} @@ -283,7 +283,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { if (readOnly) return null return ( -
+
openEditModal(provider.id)}>Update openDeleteConfirm(provider.id)}>Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/index.ts deleted file mode 100644 index 5ce203d3404..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Copilot } from './copilot' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 95ce91a808f..1253c36e865 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -26,7 +26,10 @@ export function CustomTools() { const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions) - const { data: tools = [], isLoading, error } = useCustomTools(workspaceId) + const { data: tools = [], isPending, isPlaceholderData, error } = useCustomTools(workspaceId) + // Placeholder data is another workspace's tools reading as success — treat it as + // loading, or a deep-linked id resolves against the workspace the user just left. + const isLoading = isPending || isPlaceholderData const [searchTerm, setSearchTerm] = useSettingsSearch() const [selectedToolId, setSelectedToolId] = useQueryState(customToolIdParam.key, { @@ -119,7 +122,7 @@ export function CustomTools() { key={tool.id} type='button' onClick={() => void setSelectedToolId(tool.id)} - className='w-full cursor-pointer rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' + className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 0c0aa46005c..f68afab6b72 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -403,9 +403,10 @@ export function General() {
- +
Auto-connect on drop - +

Automatically connect blocks when dropped near each other

@@ -466,7 +473,13 @@ export function General() { - +

Show error popups on blocks when a workflow run fails

@@ -485,9 +498,10 @@ export function General() {
- +
(null) const [removeSenderError, setRemoveSenderError] = useState(null) - const [copiedAddress, setCopiedAddress] = useState(false) + const { copied: copiedAddress, copy } = useCopyToClipboard() const handleCopyAddress = useCallback(() => { - if (config?.address) { - navigator.clipboard.writeText(config.address) - setCopiedAddress(true) - setTimeout(() => setCopiedAddress(false), 2000) - } - }, [config?.address]) + if (config?.address) void copy(config.address) + }, [config?.address, copy]) const handleEditAddress = useCallback(async () => { if (!newUsername.trim()) return @@ -172,7 +169,7 @@ export function InboxSettingsTab() { >
{member.email} - + member
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 5d00c3afa5c..df0a8c8dc1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -171,7 +171,7 @@ export function InboxTaskList() { {formatRelativeTime(task.createdAt)} - + {task.status === 'processing' && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index 7e995a26fff..b980b83c048 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -37,32 +37,28 @@ export function Inbox() { ) } return ( -
-
-
-
-
-

- Sim Mailer requires an active Max plan -

-

- Upgrade to Max and ensure billing is active to receive tasks via email and let Sim - work on your behalf. -

-
- {canAdmin && ( - navigateToSettings({ section: 'billing' })} - > - Upgrade to Max - - )} -
+ +
+
+

+ Sim Mailer requires an active Max plan +

+

+ Upgrade to Max and ensure billing is active to receive tasks via email and let Sim + work on your behalf. +

+ {canAdmin && ( + navigateToSettings({ section: 'billing' })} + > + Upgrade to Max + + )}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index b61f8353a24..86ba575ca2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -1,7 +1,16 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Badge, Button, ChipInput, ChipModalTabs, ChipSelect, Label, Skeleton } from '@sim/emcn' +import { + Badge, + Button, + ChipCopyInput, + ChipInput, + ChipModalTabs, + ChipSelect, + Label, + Skeleton, +} from '@sim/emcn' import { formatDateTime } from '@sim/utils/formatting' import { useQueryStates } from 'nuqs' import { AnthropicIcon, OpenAIIcon } from '@/components/icons' @@ -377,7 +386,7 @@ function OverviewTab({ } function LicensesTab({ environment }: { environment: MothershipEnv }) { - const { data, isLoading, refetch } = useMothershipLicenses(environment) + const { data, isLoading } = useMothershipLicenses(environment) const generateLicense = useGenerateLicense(environment) const [newName, setNewName] = useState('') const [newExpiry, setNewExpiry] = useState('') @@ -395,11 +404,10 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { setGeneratedKey(result.license_key) setNewName('') setNewExpiry('') - refetch() }, } ) - }, [newName, newExpiry, generateLicense, refetch]) + }, [newName, newExpiry, generateLicense.mutate]) return (
@@ -437,13 +445,11 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
{generatedKey && ( -
-

+

+

License key (only shown once):

- - {generatedKey} - +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 134f008f92e..0f1c50b4695 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -21,6 +21,7 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useMothershipChats, useRestoreMothershipChat } from '@/hooks/queries/mothership-chats' @@ -31,7 +32,6 @@ import { useWorkspaceFileFolders, } from '@/hooks/queries/workspace-file-folders' import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useUrlSort } from '@/hooks/use-url-sort' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' @@ -142,7 +142,7 @@ export function RecentlyDeleted() { const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) - const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates( + const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates( recentlyDeletedParsers, recentlyDeletedUrlKeys ) @@ -160,9 +160,7 @@ export function RecentlyDeleted() { * write is debounced. Filtering below is cheap in-memory over a small list, so * it reads the instant value too. */ - const setSearchTerm = useDebouncedSearchSetter((value, options) => - setRecentlyDeletedFilters({ search: value }, options) - ) + const [urlSearchTerm, setSearchTerm] = useSettingsSearch() const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) @@ -464,22 +462,18 @@ export function RecentlyDeleted() { } trailing={ !canRestore ? null : isRestoring ? ( - + Restoring... ) : isRestored ? ( -
+
Restored handleView(resource)}> View
) : ( - void handleRestore(resource)} - className='shrink-0' - > + void handleRestore(resource)}> Restore ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index a7e8435eed9..6379d8f145e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,4 +1,4 @@ -import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' /** Selectable resource-type tabs in the Recently Deleted view. */ @@ -34,12 +34,12 @@ export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_ * - `tab` is the active resource-type filter. * - `sort` / `dir` live in {@link recentlyDeletedSortParams} (shared sort * convention). - * - `search` is the name filter. The input is controlled directly by the nuqs - * value; only its URL write is debounced via `useDebouncedSearchSetter`. + * - The name filter is the settings-wide `?search=` key, owned by + * `settingsSearchParam` and consumed through `useSettingsSearch` — it is + * deliberately not redeclared here (two definitions of one wire key drift). */ export const recentlyDeletedParsers = { tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'), - search: parseAsString.withDefault(''), } as const /** Tab/filter/sort view-state: clean URLs, no back-stack churn. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 3e18f61ff1e..3566336a680 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -1,9 +1,8 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { ChipInput, cn, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -217,6 +216,15 @@ function WorkspaceVariableRow({ onDelete, onViewDetails, }: WorkspaceVariableRowProps) { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + return (
onRenameEnd(envKey, value)} - name={`workspace_env_key_${envKey}_${generateShortId()}`} + name={`workspace_env_key_${envKey}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -241,7 +249,7 @@ function WorkspaceVariableRow({ value={value} onChange={(next) => onValueChange(envKey, next)} canEdit={canEdit} - name={`workspace_env_value_${envKey}_${generateShortId()}`} + name={`workspace_env_value_${envKey}_${autofillSalt}`} /> copyName(envKey)} @@ -265,6 +273,15 @@ function NewWorkspaceVariableRow({ onUpdate, onPaste, }: NewWorkspaceVariableRowProps) { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + const keyError = validateEnvVarKey(envVar.key) const hasContent = Boolean(envVar.key || envVar.value) @@ -277,7 +294,7 @@ function NewWorkspaceVariableRow({ onChange={(e) => onUpdate(index, 'key', e.target.value)} onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='API_KEY' - name={`new_workspace_key_${envVar.id || index}_${generateShortId()}`} + name={`new_workspace_key_${envVar.id || index}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -291,7 +308,7 @@ function NewWorkspaceVariableRow({ onChange={(next) => onUpdate(index, 'value', next)} onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='Enter value' - name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`} + name={`new_workspace_value_${envVar.id || index}_${autofillSalt}`} className='ml-0' /> {hasContent ? ( @@ -320,6 +337,15 @@ function NewWorkspaceVariableRow({ } export function SecretsManager() { + /** + * Salts the generated `name` attributes so password managers can't match them + * against a known field. `useId` is stable across SSR and hydration and across + * re-renders — a fresh `generateShortId()` per render would patch six DOM + * attributes on every keystroke, and a module-scope one would differ between + * the server and browser bundles. + */ + const autofillSalt = useId() + const params = useParams() const router = useRouter() const workspaceId = (params?.workspaceId as string) || '' @@ -865,7 +891,7 @@ export function SecretsManager() { onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder='API_KEY' - name={`env_variable_name_${envVar.id || originalIndex}_${generateShortId()}`} + name={`env_variable_name_${envVar.id || originalIndex}_${autofillSalt}`} autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -881,7 +907,7 @@ export function SecretsManager() { unmasked={isConflicted} readOnly={isConflicted} placeholder={isConflicted ? 'Workspace override active' : 'Enter value'} - name={`env_variable_value_${envVar.id || originalIndex}_${generateShortId()}`} + name={`env_variable_value_${envVar.id || originalIndex}_${autofillSalt}`} className={cn(isConflicted && 'cursor-not-allowed opacity-50')} /> {hasContent ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index 7e31036010d..f5e9ea6c73b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,5 +1,9 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, +} from '@/app/workspace/[workspaceId]/components/resource-tile' /** * The canonical settings "resource row": a rounded-bordered icon tile, a @@ -29,13 +33,13 @@ interface SettingsResourceRowProps { title: ReactNode /** Secondary muted line — truncates. */ description?: ReactNode - /** Trailing element pinned to the row's end (chips, actions menu, status). */ + /** + * Trailing element pinned to the row's end (chips, actions menu, status). The row + * keeps it at its natural size — callers never need their own `flex-shrink-0`. + */ trailing?: ReactNode } -const TILE_BASE = - 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' - export function SettingsResourceRow({ icon, iconFill = false, @@ -49,8 +53,8 @@ export function SettingsResourceRow({
@@ -63,7 +67,7 @@ export function SettingsResourceRow({ )}
- {trailing} + {trailing ?
{trailing}
: null}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 5b91277a2aa..ace340514fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -44,7 +44,6 @@ export function NoOrganizationView({ return (
- {/* Header - matching settings page style */}

Create Your Team Workspace @@ -55,23 +54,20 @@ export function NoOrganizationView({

- {/* Form fields - clean layout without card */}
- {/* Hidden decoy field to prevent browser autofill */} + {/* Decoy field: absorbs browser autofill so it can't target the real inputs. */}
- +
- +
sim.ai/team/ @@ -131,12 +125,12 @@ export function NoOrganizationView({ Create Team Organization - {/* Hidden decoy field to prevent browser autofill */} + {/* Decoy field: absorbs browser autofill so it can't target the real inputs. */} { setEmails([]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 3d81108fbe2..5a435d644f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn' +import { ChipDropdown, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' @@ -73,6 +73,12 @@ interface OrganizationMemberListsProps { roster: OrganizationRoster | null | undefined isLoadingRoster: boolean currentUserId: string + /** + * The roster filter, owned by the page so it can live in the URL — this + * component renders the shared `SettingsPanel` search box's results, it does + * not own the box. + */ + query: string onRemoveMember: (member: Member) => void onTransferOwnership?: () => void } @@ -89,10 +95,10 @@ export function OrganizationMemberLists({ roster, isLoadingRoster, currentUserId, + query, onRemoveMember, onTransferOwnership, }: OrganizationMemberListsProps) { - const [query, setQuery] = useState('') const [creditsTarget, setCreditsTarget] = useState(null) const updateMemberRole = useUpdateOrganizationMemberRole() @@ -380,48 +386,51 @@ export function OrganizationMemberLists({ /** * Group each workspace's members and pending invites once per roster change. - * This is O(workspaces × members) and independent of the search query, so - * hoisting it out of render keeps keystroke filtering cheap on large orgs. + * Indexed by a single pass over the roster rather than a `.find` per + * workspace × member — that inner scan made this O(workspaces × members × + * access-entries). Members are appended in roster order, so each group keeps + * the same ordering the per-workspace scan produced. */ - const workspaceGroups = useMemo( - () => - workspaces.map((workspace) => { - const workspaceMembers = members - .map((member) => ({ - member, - access: member.workspaces.find((w) => w.workspaceId === workspace.id), - })) - .filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } => - Boolean(entry.access) - ) - const workspaceInvites = pendingInvitations - .map((invitation) => ({ - invitation, - access: invitation.workspaces.find((w) => w.workspaceId === workspace.id), - })) - .filter( - ( - entry - ): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } => - Boolean(entry.access) - ) - return { workspace, workspaceMembers, workspaceInvites } - }), - [workspaces, members, pendingInvitations] - ) + const workspaceGroups = useMemo(() => { + const membersByWorkspace = new Map< + string, + { member: RosterMember; access: RosterWorkspaceAccess }[] + >() + for (const member of members) { + const seen = new Set() + for (const access of member.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + const entries = membersByWorkspace.get(access.workspaceId) + if (entries) entries.push({ member, access }) + else membersByWorkspace.set(access.workspaceId, [{ member, access }]) + } + } + + const invitesByWorkspace = new Map< + string, + { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess }[] + >() + for (const invitation of pendingInvitations) { + const seen = new Set() + for (const access of invitation.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + const entries = invitesByWorkspace.get(access.workspaceId) + if (entries) entries.push({ invitation, access }) + else invitesByWorkspace.set(access.workspaceId, [{ invitation, access }]) + } + } + + return workspaces.map((workspace) => ({ + workspace, + workspaceMembers: membersByWorkspace.get(workspace.id) ?? [], + workspaceInvites: invitesByWorkspace.get(workspace.id) ?? [], + })) + }, [workspaces, members, pendingInvitations]) return ( <> -
- setQuery(e.target.value)} - className='flex-1' - /> -
- {showMembersSection && ( - {error instanceof Error && error.message ? error.message : String(error)} + {getErrorMessage(error) || 'Failed to transfer ownership'}

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 1f43f1cc594..3a80877faab 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -16,6 +16,7 @@ import { TeamSeatsOverview, TransferOwnershipDialog, } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useCreateOrganization, useOrganization, @@ -40,6 +41,7 @@ export function TeamManagement({ }: TeamManagementProps) { const { data: session } = useSession() const { isInvitationsDisabled } = usePermissionConfig() + const [memberQuery, setMemberQuery] = useSettingsSearch() const { data: userSubscriptionData } = useSubscriptionData() const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data) @@ -307,6 +309,11 @@ export function TeamManagement({ return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index ebbfa9883cd..69fed161b7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -19,6 +19,7 @@ import { Code, type ComboboxOption, Label, + useCopyToClipboard, } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -97,7 +98,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe } }, []) - const [copiedConfig, setCopiedConfig] = useState(false) + const { copied: copiedConfig, copy: copyConfig } = useCopyToClipboard() const [activeConfigTab, setActiveConfigTab] = useState('cursor') const [toolToDelete, setToolToDelete] = useState(null) const [toolToView, setToolToView] = useState(null) @@ -308,12 +309,9 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe const handleCopyConfig = useCallback( (isPublic: boolean, serverName: string) => { - const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName) - navigator.clipboard.writeText(snippet) - setCopiedConfig(true) - setTimeout(() => setCopiedConfig(false), 2000) + void copyConfig(getConfigSnippet(activeConfigTab, isPublic, serverName)) }, - [activeConfigTab, getConfigSnippet] + [activeConfigTab, getConfigSnippet, copyConfig] ) const handleOpenEditServer = useCallback(() => { @@ -579,6 +577,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe + ) : ( + + ) + + if (blocked) { + return trigger === 'inline-header' ? ( + {triggerButton} + ) : ( + triggerButton + ) + } + const menu = ( - - {trigger === 'header' ? ( - - ) : ( - - )} - + {triggerButton} <> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 28c971bda9f..efee32d04e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -27,6 +27,7 @@ import { localPartsToDateValue, todayLocalCalendarDate, } from '../../utils' +import { SelectValueEditor } from '../select-field' const logger = createLogger('RowModal') @@ -275,6 +276,14 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) { ) } + if (column.type === 'select') { + return ( + + + + ) + } + return ( void +} + +/** + * Add/remove/rename the options of a `select` column. Option ids are stable + * across edits so existing cell data survives renames. New options are added by + * typing into the trailing empty row — the first keystroke materializes the + * option and focus jumps into it so typing flows straight through. + */ +export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) { + const inputRefs = useRef>(new Map()) + const trailingRef = useRef(null) + const [pendingFocusId, setPendingFocusId] = useState(null) + + // The new row and `pendingFocusId` land in the same commit, so the ref is + // registered by the time this effect runs. + useEffect(() => { + if (!pendingFocusId) return + const el = inputRefs.current.get(pendingFocusId) + if (el) { + el.focus() + const end = el.value.length + el.setSelectionRange(end, end) + } + setPendingFocusId(null) + }, [pendingFocusId]) + + const update = (id: string, patch: Partial) => { + onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o))) + } + + const remove = (id: string) => { + inputRefs.current.delete(id) + onChange(options.filter((o) => o.id !== id)) + } + + /** Typing into the trailing row promotes it to a real option and keeps focus. */ + const materialize = (name: string) => { + const id = generateShortId() + onChange([...options, { id, name }]) + setPendingFocusId(id) + } + + return ( +
+ {options.map((option) => ( +
+ { + if (el) inputRefs.current.set(option.id, el) + else inputRefs.current.delete(option.id) + }} + value={option.name} + onChange={(e) => update(option.id, { name: e.target.value })} + onKeyDown={(e) => { + // Enter jumps to the trailing row so options can be added in a row. + if (e.key === 'Enter') { + e.preventDefault() + trailingRef.current?.focus() + } + }} + placeholder='Option name' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+ ))} +
+ { + if (e.target.value) materialize(e.target.value) + }} + placeholder='Add option' + spellCheck={false} + autoComplete='off' + className='min-w-0 flex-1' + /> + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx new file mode 100644 index 00000000000..699d6d2b5f4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx @@ -0,0 +1,44 @@ +'use client' + +import { Badge, cn } from '@sim/emcn' +import type { ColumnDefinition, SelectOption } from '@/lib/table' + +/** Reads the raw stored option ids from a cell value (single string or array). */ +export function toSelectedIds(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string') + if (typeof value === 'string' && value !== '') return [value] + return [] +} + +/** + * Resolves a `select` cell's stored ids to their declared options, preserving + * selection order. An id with no matching option — stale after that option was + * deleted — is dropped, so the cell falls back to empty ("None") rather than + * showing an orphaned reference. + */ +export function resolveSelectOptions(column: ColumnDefinition, value: unknown): SelectOption[] { + const byId = new Map((column.options ?? []).map((o) => [o.id, o])) + return toSelectedIds(value) + .map((id) => byId.get(id)) + .filter((o): o is SelectOption => o != null) +} + +/** The still-valid option ids of a cell (orphaned/removed ids dropped). */ +export function selectedOptionIds(column: ColumnDefinition, value: unknown): string[] { + return resolveSelectOptions(column, value).map((o) => o.id) +} + +interface SelectPillProps { + option: SelectOption + size?: 'sm' | 'md' + className?: string +} + +/** A single option pill, rendered through the shared neutral `Badge`. */ +export function SelectPill({ option, size = 'sm', className }: SelectPillProps) { + return ( + + {option.name} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx new file mode 100644 index 00000000000..cdd89e8e821 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-value-editor.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useMemo } from 'react' +import { ChipDropdown } from '@sim/emcn' +import type { ColumnDefinition } from '@/lib/table' +import { SelectPill, selectedOptionIds } from './select-pill' + +interface SelectValueEditorProps { + column: ColumnDefinition + value: unknown + /** Single columns emit a string id or null; multiselect emits a string[]. */ + onChange: (next: string | string[] | null) => void + fullWidth?: boolean + align?: 'start' | 'center' | 'end' +} + +const CLEAR_VALUE = '' + +/** + * Option picker for `select`/`multiselect` cells in a form context (the row + * modal) — a `ChipDropdown` pill that lists each option as its colored pill and + * writes option ids back through `onChange`. Inline grid editing uses a bare + * `DropdownMenu` instead (see `InlineSelectEditor`). + */ +export function SelectValueEditor({ + column, + value, + onChange, + fullWidth, + align = 'start', +}: SelectValueEditorProps) { + const isMulti = !!column.multiple + const options = useMemo( + () => + (column.options ?? []).map((option) => ({ + value: option.id, + label: , + })), + [column.options] + ) + + if (isMulti) { + return ( + { + if (column.required && ids.length === 0) return + onChange(ids) + }} + options={options} + showAllOption={false} + // In multiple mode ChipDropdown ignores `placeholder` and renders + // `allLabel` when nothing is selected — which would read as if every + // option were chosen. There is no "All" entry here, so this is the + // empty label. + allLabel='Select options' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) + } + + // Offer a "None" entry to clear the cell — except on a required column, where + // clearing to null can never be committed (required validation rejects it). + const singleOptions = column.required + ? options + : [ + { value: CLEAR_VALUE, label: None }, + ...options, + ] + + return ( + onChange(id === CLEAR_VALUE ? null : id)} + options={singleOptions} + placeholder='Select an option' + align={align} + fullWidth={fullWidth} + matchTriggerWidth={false} + /> + ) +} 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 4073f24e098..a0af627d296 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 @@ -6,9 +6,25 @@ import { Plus, X } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' +import { + COMPARISON_OPERATORS, + MULTI_SELECT_FILTER_OPERATORS, + SINGLE_SELECT_FILTER_OPERATORS, + VALUELESS_OPERATORS, +} from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' +const SINGLE_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => + SINGLE_SELECT_FILTER_OPERATORS.has(o.value) +) +const MULTI_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) => + MULTI_SELECT_FILTER_OPERATORS.has(o.value) +) + +function selectFilterOperators(column: ColumnDefinition | undefined): Set { + return column?.multiple ? MULTI_SELECT_FILTER_OPERATORS : SINGLE_SELECT_FILTER_OPERATORS +} + interface TableFilterProps { columns: ColumnDefinition[] filter: Filter | null @@ -31,6 +47,11 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr [columns] ) + const columnById = useMemo( + () => new Map(columns.map((col) => [getColumnId(col), col])), + [columns] + ) + const handleAdd = useCallback(() => { setRules((prev) => [...prev, createRule(columns)]) }, [columns]) @@ -53,6 +74,32 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))) }, []) + // Switching a rule's column across the select boundary changes what values and + // operators are valid, so clear the value and coerce an unsupported operator + // back to `eq` — otherwise a stale free-text value or a range operator would + // apply against a select column and be rejected server-side. + const handleColumnChange = useCallback( + (id: string, columnId: string) => { + setRules((prev) => + prev.map((r) => { + if (r.id !== id) return r + const previous = columnById.get(r.column) + const next = columnById.get(columnId) + const wasSelect = previous?.type === 'select' + const isSelect = next?.type === 'select' + if (!wasSelect && !isSelect) return { ...r, column: columnId } + // Single- and multi-select take different operators, so a switch + // between them has to fall back too, not just select ↔ non-select. + const allowed = selectFilterOperators(next) + const fallback = next?.multiple ? 'contains' : 'eq' + const operator = isSelect && !allowed.has(r.operator) ? fallback : r.operator + return { ...r, column: columnId, operator, value: '' } + }) + ) + }, + [columnById] + ) + const handleToggleLogical = useCallback((id: string) => { setRules((prev) => prev.map((r) => @@ -65,8 +112,8 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr const validRules = rulesRef.current.filter( (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) ) - onApply(filterRulesToFilter(validRules)) - }, [onApply]) + onApply(filterRulesToFilter(validRules, columns)) + }, [columns, onApply]) const handleClear = useCallback(() => { setRules([createRule(columns)]) @@ -82,7 +129,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr rule={rule} isFirst={index === 0} columns={columnOptions} + columnById={columnById} onUpdate={handleUpdate} + onColumnChange={handleColumnChange} onRemove={handleRemove} onApply={handleApply} onToggleLogical={handleToggleLogical} @@ -124,7 +173,9 @@ interface FilterRuleRowProps { rule: FilterRule isFirst: boolean columns: Array<{ value: string; label: string }> + columnById: ReadonlyMap onUpdate: (id: string, field: keyof FilterRule, value: string) => void + onColumnChange: (id: string, columnId: string) => void onRemove: (id: string) => void onApply: () => void onToggleLogical: (id: string) => void @@ -134,7 +185,9 @@ const FilterRuleRow = memo(function FilterRuleRow({ rule, isFirst, columns, + columnById, onUpdate, + onColumnChange, onRemove, onApply, onToggleLogical, @@ -147,6 +200,24 @@ const FilterRuleRow = memo(function FilterRuleRow({ ? [...columns, { value: rule.column, label: rule.column }] : columns + const selectedColumn = columnById.get(rule.column) + const isSelect = selectedColumn?.type === 'select' + const operatorOptions = !isSelect + ? COMPARISON_OPERATORS + : selectedColumn?.multiple + ? MULTI_SELECT_COMPARISON_OPERATORS + : SINGLE_SELECT_COMPARISON_OPERATORS + + // A stale id (option since deleted) stays selectable so the rule still shows. + const selectValueOptions = isSelect + ? (() => { + const opts = (selectedColumn.options ?? []).map((o) => ({ value: o.id, label: o.name })) + return rule.value && !opts.some((o) => o.value === rule.value) + ? [...opts, { value: rule.value, label: rule.value }] + : opts + })() + : [] + return (
{isFirst ? ( @@ -163,7 +234,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ onUpdate(rule.id, 'column', value)} + onChange={(value) => onColumnChange(rule.id, value)} placeholder='Column' align='start' matchTriggerWidth={false} @@ -171,7 +242,7 @@ const FilterRuleRow = memo(function FilterRuleRow({ /> onUpdate(rule.id, 'operator', value)} placeholder='Operator' @@ -182,6 +253,16 @@ const FilterRuleRow = memo(function FilterRuleRow({ {VALUELESS_OPERATORS.has(rule.operator) ? (
+ ) : isSelect ? ( + onUpdate(rule.id, 'value', value)} + placeholder='Select a value' + align='start' + matchTriggerWidth={false} + className='min-w-[100px] flex-1' + /> ) : ( ) + case 'select': + // Chip-only view: just the option pills. Pills stay visible while editing — + // the inline editor overlays an invisible trigger and portals its menu + // below, so the cell keeps showing the current selection. + return ( + + {kind.options.length > 0 ? ( + kind.options.map((option) => ) + ) : ( + None + )} + + ) + case 'json': return ( (() => selectedOptionIds(column, value)) + const [open, setOpen] = useState(true) + const latestRef = useRef(draft) + const doneRef = useRef(false) + const cancelledRef = useRef(false) + + const setDraftAnd = (next: string[]) => { + latestRef.current = next + setDraft(next) + } + + const commit = useCallback(() => { + if (doneRef.current) return + doneRef.current = true + if (cancelledRef.current) { + onCancel() + return + } + const ids = latestRef.current + onSave(isMulti ? ids : (ids[0] ?? null), 'enter') + }, [isMulti, onSave, onCancel]) + + // Escape closes the Radix menu (firing `onOpenChange(false)`); capture it + // first so the close handler discards instead of committing. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancelledRef.current = true + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, []) + + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (!next) commit() + } + + const handleSelectOption = (event: Event, id: string) => { + if (!isMulti) { + // Picking closes the menu → `handleOpenChange` commits the new value. + setDraftAnd([id]) + return + } + // Keep the menu open across toggles; commit the set on close. + event.preventDefault() + const has = latestRef.current.includes(id) + const next = has ? latestRef.current.filter((v) => v !== id) : [...latestRef.current, id] + // A required multiselect can't be emptied — ignore removing the last option. + if (column.required && next.length === 0) return + setDraftAnd(next) + } + + return ( + + +
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( - + )}
@@ -3944,9 +4193,10 @@ export function TableGrid({ runningInSelectionCount={runningInContextSelection} hasWorkflowColumns={hasWorkflowColumns} workflowCellScoped={Boolean(contextMenuGroupId)} - disableEdit={!userPermissions.canEdit} - disableInsert={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableEdit={!canEditCell} + disableInsert={!canManualAddRow} + disableDuplicate={!canInsertFullRow} + disableDelete={!canDeleteRow} />
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 65416e113d6..67f8a2b54c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -379,6 +379,15 @@ export function useTableEventStream({ else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event) else if (entry.event?.kind === 'job') applyJob(entry.event) else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event) + else if (entry.event?.kind === 'definition') { + // A lock/schema change on the definition — re-read it so every open + // viewer's gating updates. `exact` avoids refetching every rows page + // (rowsRoot nests under detail); no row data changed. + void queryClient.invalidateQueries({ + queryKey: tableKeys.detail(tableId), + exact: true, + }) + } } catch (err) { logger.warn('Failed to parse table event', { tableId, err }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 6995f7819e8..9fcf4dd7db5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -2,8 +2,15 @@ import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' -import type { ColumnDefinition, TableDefinition, TableRow, WorkflowGroup } from '@/lib/table' +import type { + ColumnDefinition, + Filter, + TableDefinition, + TableRow, + WorkflowGroup, +} from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' +import { pruneFilterForColumns } from '@/lib/table/query-builder/converters' import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs' import { getBlock } from '@/blocks' import { @@ -38,6 +45,13 @@ export interface UseTableReturn { rows: TableRow[] /** Filter-scoped total row count (server COUNT(*) for the active filter); null until loaded. */ rowTotal: number | null + /** + * The filter actually sent to the server: `queryOptions.filter` minus any + * condition the current schema makes invalid. Anything server-bound — + * select-all run/stop/delete — must scope with THIS, not the raw filter, or + * the action targets a predicate the grid isn't displaying. + */ + filter: Filter | null isLoadingRows: boolean refetchRows: () => void /** @@ -78,6 +92,16 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) const queryClient = useQueryClient() const { data: tableData, isLoading: isLoadingTable } = useTableQuery(workspaceId, tableId) + // Applied filters outlive the schema they were built against: converting a + // column to `select`, or toggling its `multiple`, can strand an operator the + // server rejects outright, which would fail every subsequent rows query. Prune + // here, above every consumer of the rows query key, so the paged helpers below + // can't rebuild the key from the unpruned filter and drift. + const filter = useMemo( + () => pruneFilterForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []), + [queryOptions.filter, tableData?.schema?.columns] + ) + const { data: rowsData, isLoading: isLoadingRows, @@ -89,7 +113,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) workspaceId, tableId, pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, - filter: queryOptions.filter, + filter, sort: queryOptions.sort, enabled: Boolean(workspaceId && tableId), }) @@ -118,7 +142,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) workspaceId, tableId, pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, - filter: queryOptions.filter, + filter, sort: queryOptions.sort, }) @@ -140,7 +164,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) } return queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? [] - }, [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage]) + }, [workspaceId, tableId, filter, queryOptions.sort, queryClient, fetchNextPage]) const ensureRowsLoadedUpTo = useCallback( async (maxRows: number): Promise<{ rows: TableRow[]; hasMore: boolean }> => { @@ -150,7 +174,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) workspaceId, tableId, pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, - filter: queryOptions.filter, + filter, sort: queryOptions.sort, }) @@ -176,7 +200,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) hasMore: all.length > maxRows || hasMoreTableRows(pages), } }, - [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage] + [workspaceId, tableId, filter, queryOptions.sort, queryClient, fetchNextPage] ) const fetchNextPageWrapped = useCallback(async () => { @@ -237,6 +261,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) isLoadingTable, rows, rowTotal, + filter, isLoadingRows, refetchRows, fetchNextPage: fetchNextPageWrapped, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts new file mode 100644 index 00000000000..f87a5bdbcd1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -0,0 +1,138 @@ +/** + * Single source of truth for lock vocabulary shared by the lock settings modal, + * the blocked-action toast, and the table header chip. Kept out of + * `lib/table/mutation-locks.ts` — that module is server-tainted (importing it + * from a client component pulls `next/headers` into the browser bundle). + */ + +import type { TableLockKind, TableLocks } from '@/lib/table/types' + +export interface LockField { + /** The `TableLocks` flag this row toggles. */ + key: keyof TableLocks + kind: TableLockKind + /** The action being locked, phrased to read after "Lock " and inside a list. */ + noun: string + hint: string +} + +export const LOCK_FIELDS: LockField[] = [ + { + key: 'insertLocked', + kind: 'insert', + noun: 'adding rows', + hint: 'On: no new rows can be added — by anyone, including CSV import, the API, workflow blocks, and Sim.', + }, + { + key: 'updateLocked', + kind: 'update', + noun: 'editing rows', + hint: 'On: existing cell values cannot be changed. Workflow and enrichment columns still populate.', + }, + { + key: 'deleteLocked', + kind: 'delete', + noun: 'deleting rows', + hint: 'On: rows cannot be deleted, and the table cannot be archived.', + }, + { + key: 'schemaLocked', + kind: 'schema', + noun: 'changing columns', + hint: 'On: columns cannot be added, renamed, retyped, or removed.', + }, +] + +/** The locked verbs' nouns, in display order. Empty when nothing is locked. */ +export function lockedNouns(locks: TableLocks): string[] { + return LOCK_FIELDS.filter((f) => locks[f.key]).map((f) => f.noun) +} + +/** + * Plain-language summary of a lock set — the named mode when the combination + * matches one, otherwise a list of what is locked. + */ +export function describeLocks(locks: TableLocks): { name: string; detail: string } { + const locked = lockedNouns(locks) + if (locked.length === 0) { + return { name: 'Unlocked', detail: 'anyone with edit access can change this table.' } + } + if (locked.length === LOCK_FIELDS.length) { + return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' } + } + // Append-only describes the row semantics — adding is the only thing left. + // A schema lock on top doesn't change that, so it keeps the name and is + // called out in the detail rather than demoted to the generic case. + if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) { + return { + name: 'Append-only', + detail: locks.schemaLocked + ? 'rows can be added, but not edited or deleted, and columns are locked.' + : 'rows can be added, but not edited or deleted.', + } + } + return { name: 'Locked', detail: `${locked.join(', ')} locked.` } +} + +/** + * Why a locked-table notice was raised. `'status'` is the informational case + * (a non-admin clicking the header lock chip); the rest are actions the user + * just tried and couldn't do. + */ +export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' + +/** + * Copy for the action the user attempted. Explains what is blocked and — for + * the append-only manual-entry case — what to do instead, since that one is + * blocked by the *update* lock rather than the insert lock. + */ +export function describeBlockedAction( + action: BlockedTableAction, + locks: TableLocks +): { title: string; text: string } { + switch (action) { + case 'add-row': + if (locks.insertLocked) { + return { + title: 'Adding rows is locked', + text: 'No new rows can be added until an admin unlocks this table.', + } + } + return { + title: 'This table is append-only', + text: 'Rows can’t be edited once added, so typing one into the grid is unavailable. Import a CSV, or add rows from the API, a workflow, or Sim.', + } + case 'add-column': + return { + title: 'Changing columns is locked', + text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + } + case 'delete-column': + // Reachable with the schema lock off but the delete lock on — removing a + // column clears its value from every row, so it needs both. + return locks.schemaLocked + ? { + title: 'Changing columns is locked', + text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + } + : { + title: 'Deleting columns is locked', + text: 'Removing a column deletes its value from every row, so it’s blocked while deleting is locked.', + } + case 'edit-cell': + return { + title: 'Editing rows is locked', + text: 'Existing cell values can’t be changed until an admin unlocks this table.', + } + case 'status': { + const nouns = lockedNouns(locks) + return { + title: 'Table locks', + text: + nouns.length > 0 + ? `An admin has locked ${nouns.join(', ')} on this table.` + : 'Nothing is locked on this table.', + } + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9f3c382c3ff..c35d081c954 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' import { Table } from './table' @@ -7,15 +10,35 @@ export const metadata: Metadata = { title: 'Table', } +interface TablePageProps { + params: Promise<{ workspaceId: string }> +} + /** * Table-detail page entry. `Table` reads URL query params via nuqs (which uses * `useSearchParams` internally), so it must sit under a Suspense boundary. The * fallback renders the real chrome so a suspend never shows a blank frame. + * + * The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var: + * gating lives in AppConfig, which has no client counterpart, so resolving it + * server-side is the only way its org/user clauses reach the UI. The org is the + * workspace's host organization, not the viewer's active one — the same key the + * PATCH gate uses, so the panel can't open onto a Save that 403s. Both + * `getSession` and the host context are request-memoized, so this reuses the + * layout's reads. */ -export default function TablePage() { +export default async function TablePage({ params }: TablePageProps) { + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) + const userId = session?.user?.id + const host = userId ? await getWorkspaceHostContextForViewer(workspaceId, userId) : null + const tableLocksEnabled = await isFeatureEnabled('table-locks', { + userId, + orgId: host?.hostOrganizationId ?? undefined, + }) + return ( }> - +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 0f7485636bb..1c0daa5fed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useRef, useState } from 'react' import { Chip, ChipConfirmModal, toast } from '@sim/emcn' -import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons' +import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -47,6 +47,7 @@ import { ColumnConfigSidebar, EnrichmentDetails, EnrichmentsSidebar, + LockSettingsModal, NewColumnDropdown, RowModal, RunStatusControl, @@ -60,6 +61,12 @@ import { import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { COLUMN_TYPE_ICONS } from './components/table-grid/headers' import { useTable, useTableEventStream } from './hooks' +import { + type BlockedTableAction, + describeBlockedAction, + describeLocks, + lockedNouns, +} from './lock-copy' import { DEFAULT_TABLE_DETAIL_SORT_DIRECTION, tableDetailParsers, @@ -70,6 +77,9 @@ import { generateColumnName } from './utils' const logger = createLogger('Table') +/** Blocked-action toasts carry a button, so they linger past the 5s default. */ +const BLOCKED_TOAST_MS = 8000 + interface TableProps { /** When set, the table renders without its page header / breadcrumbs / page-level * options bar. Used by the mothership chat panel to embed a table inline. */ @@ -77,6 +87,13 @@ interface TableProps { /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ workspaceId?: string tableId?: string + /** + * Whether an admin may CHANGE locks, resolved server-side by the page (the + * flag's gating lives in AppConfig and has no client counterpart). Defaults + * to false so embedded renders, which have no server resolution, fail closed + * — enforcement of stored locks is unaffected either way. + */ + tableLocksEnabled?: boolean } /** @@ -133,6 +150,7 @@ export function Table({ embedded, workspaceId: propWorkspaceId, tableId: propTableId, + tableLocksEnabled = false, }: TableProps = {}) { const params = useParams() const router = useRouter() @@ -155,6 +173,10 @@ export function Table({ const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' }) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) + const [showLockSettings, setShowLockSettings] = useState(false) + // Id of the last blocked-action toast, so a user who keeps typing into a + // locked cell replaces one notice rather than stacking a column of them. + const blockedToastIdRef = useRef(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) const [deletingRows, setDeletingRows] = useState([]) @@ -272,7 +294,16 @@ export function Table({ // Single source of truth for `useTable` — drives both the grid render and // the wrapper's slideouts/modals. The grid receives the bundle as props. - const { tableData, columns, tableWorkflowGroups, workflows } = useTable({ + const { + tableData, + columns, + tableWorkflowGroups, + workflows, + // Server-bound scopes use this: a filter condition the current schema + // invalidated is pruned from the rows query, so the delete must target the + // same predicate the grid is displaying. + filter: effectiveFilter, + } = useTable({ workspaceId, tableId, queryOptions, @@ -541,10 +572,23 @@ export function Table({ icon: Pencil, onClick: handleStartTableRename, }, + // Reachable with the flag off when something is locked, so an + // admin can always clear locks (the route allows clearing). + ...(userPermissions.canAdmin && + (tableLocksEnabled || lockedNouns(tableData.locks).length > 0) + ? [ + { + label: 'Lock settings', + icon: Lock, + onClick: () => setShowLockSettings(true), + }, + ] + : []), { label: 'Delete', icon: Trash, onClick: onRequestDeleteTable, + disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked, }, ], } @@ -552,6 +596,8 @@ export function Table({ ], [ handleNavigateBack, + userPermissions.canAdmin, + userPermissions.canEdit, tableData, tableHeaderRename.editingId, tableHeaderRename.editValue, @@ -563,31 +609,88 @@ export function Table({ ] ) - const headerActions = useMemo( - () => - tableData + // An admin can always reach the settings on a locked table — clearing locks + // stays allowed with the flag off, so the kill switch can't strand one. With + // the flag off and nothing locked there is nothing to change, so the toast is + // a plain notice with no action. + const canOpenLockSettings = + userPermissions.canAdmin === true && + (tableLocksEnabled || (tableData ? lockedNouns(tableData.locks).length > 0 : false)) + + /** + * Explains why a table mutation is unavailable. A toast rather than a modal: + * being told you can't edit shouldn't cost a dismiss click, and admins still + * get a direct route to the settings via the action button. + */ + const showBlockedToast = useCallback( + (action: BlockedTableAction) => { + if (!tableData) return + if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current) + const { title, text } = describeBlockedAction(action, tableData.locks) + blockedToastIdRef.current = toast.warning(title, { + description: text, + ...(canOpenLockSettings + ? { + action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) }, + // An action would otherwise pin the toast open until dismissed. + duration: BLOCKED_TOAST_MS, + } + : {}), + }) + }, + [tableData, canOpenLockSettings] + ) + + const headerActions = useMemo(() => { + if (!tableData) return undefined + // Header space is for state, not for settings: the chip appears only once + // something is actually locked, and names the mode so it reads at a glance. + // Reaching the panel on an unlocked table is the dropdown's job. + const anyLocked = lockedNouns(tableData.locks).length > 0 + return [ + ...(anyLocked ? [ { - label: 'Import CSV', - icon: Upload, - onClick: onRequestImportCsv, - disabled: userPermissions.canEdit !== true, - }, - { - label: 'Export CSV', - icon: Download, - onClick: () => void handleExportCsv(), - disabled: tableData.rowCount === 0, + label: describeLocks(tableData.locks).name, + icon: Lock, + onClick: () => + userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'), }, ] - : undefined, - [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv] - ) + : []), + { + label: 'Import CSV', + icon: Upload, + onClick: onRequestImportCsv, + // An import always inserts, so the insert lock disables it outright + // rather than letting the dialog run to a server-side 423. + disabled: userPermissions.canEdit !== true || tableData.locks.insertLocked, + }, + { + label: 'Export CSV', + icon: Download, + onClick: () => void handleExportCsv(), + disabled: tableData.rowCount === 0, + }, + ] + }, [ + tableData, + userPermissions.canEdit, + userPermissions.canAdmin, + handleExportCsv, + onRequestImportCsv, + showBlockedToast, + ]) + // Adding a column is a schema change. The trigger stays visible when the + // table is schema-locked and explains itself instead of disappearing. + const canMutateSchema = userPermissions.canEdit && !tableData?.locks.schemaLocked const createTrigger = userPermissions.canEdit ? ( showBlockedToast('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -640,10 +743,13 @@ export function Table({ const filterConfig = useMemo( () => ({ mode: 'toggle' as const, - active: filterOpen || !!queryOptions.filter, + // The pruned filter, not the raw one: a condition the current schema + // invalidated is not applied to the grid, so showing the chip as active + // (and reopening that rule) would claim a filter the rows do not reflect. + active: filterOpen || !!effectiveFilter, onToggle: handleToggleFilter, }), - [filterOpen, queryOptions.filter, handleToggleFilter] + [filterOpen, effectiveFilter, handleToggleFilter] ) return ( @@ -698,7 +804,7 @@ export function Table({ {filterOpen && ( setFilterOpen(false)} /> @@ -707,6 +813,8 @@ export function Table({ workspaceId={workspaceId} tableId={tableId} embedded={embedded} + locks={tableData?.locks} + onBlockedAction={showBlockedToast} sidebarReservedPx={sidebarReservedPx} onOpenColumnConfig={onOpenColumnConfig} onOpenWorkflowConfig={onOpenWorkflowConfig} @@ -878,7 +986,7 @@ export function Table({ title='Delete rows' text={`Delete ${deletingAll ? deletingAll.estimatedCount.toLocaleString() : 0} ${ deletingAll?.estimatedCount === 1 ? 'row' : 'rows' - }${queryOptions.filter ? ' matching the current filter' : ''}? This can't be undone.`} + }${effectiveFilter ? ' matching the current filter' : ''}? This can't be undone.`} confirm={{ label: 'Delete', pending: deleteRowsAsyncMutation.isPending, @@ -887,7 +995,7 @@ export function Table({ if (!deletingAll) return const { excludeRowIds, estimatedCount } = deletingAll deleteRowsAsyncMutation.mutate({ - filter: queryOptions.filter ?? undefined, + filter: effectiveFilter ?? undefined, sort: queryOptions.sort, excludeRowIds: excludeRowIds.length > 0 ? excludeRowIds : undefined, estimatedCount, @@ -961,6 +1069,15 @@ export function Table({ }} /> )} + {tableData && userPermissions.canAdmin && ( + setShowLockSettings(false)} + workspaceId={workspaceId} + tableId={tableData.id} + locks={tableData.locks} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index eace4541f1f..5945be6c388 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -125,6 +125,38 @@ describe('cleanCellValue', () => { expect(cleanCellValue('2024', { name: 'n', type: 'number' } as const)).toBe(2024) expect(cleanCellValue('true', { name: 'b', type: 'boolean' } as const)).toBe(true) }) + + it('resolves select names to option ids so the optimistic cache holds ids', () => { + const column = { + name: 'status', + type: 'select', + options: [ + { id: 'opt_a', name: 'Open' }, + { id: 'opt_b', name: 'Closed' }, + ], + } as const + expect(cleanCellValue('Open', column)).toBe('opt_a') + expect(cleanCellValue('open', column)).toBe('opt_a') + expect(cleanCellValue('opt_b', column)).toBe('opt_b') + expect(cleanCellValue('Archived', column)).toBeNull() + expect(cleanCellValue('', column)).toBeNull() + }) + + it('round-trips a multiselect through its comma-joined clipboard form', () => { + const column = { + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Bug' }, + { id: 'opt_b', name: 'Docs' }, + ], + } as const + expect(cleanCellValue('Bug, Docs', column)).toEqual(['opt_a', 'opt_b']) + expect(cleanCellValue(['opt_b'], column)).toEqual(['opt_b']) + expect(cleanCellValue('Bug, Bug', column)).toEqual(['opt_a']) + expect(cleanCellValue('Nope', column)).toEqual([]) + }) }) describe('formatValueForInput', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index 84e03eefaf0..cbeb8c122d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -71,9 +71,51 @@ export function cleanCellValue( if (value === '' || value === null || value === undefined) return null return displayToStorage(String(value), timeZone) } + if (column.type === 'select') { + return cleanSelectValue(value, column) + } return value || null } +/** + * Client-side mirror of the server's `select` coercion: a cell stores option + * ids, but pasted or imported text carries names. Resolving here — not only on + * the server — is what keeps the optimistic cache holding ids, so the pasted + * cell renders its pill immediately instead of blanking until the refetch. + */ +function cleanSelectValue(value: unknown, column: ColumnDefinition): unknown { + const options = column.options ?? [] + const resolve = (raw: unknown): string | null => { + if (typeof raw !== 'string') return null + const match = + options.find((o) => o.id === raw) ?? + options.find((o) => o.name === raw) ?? + options.find((o) => o.name.toLowerCase() === raw.toLowerCase()) + return match ? match.id : null + } + + if (column.multiple) { + // Comma-delimited is the multi cell's own clipboard/CSV format, so a paste + // of one round-trips. Option names containing commas are a known ambiguity. + const raw = Array.isArray(value) + ? value + : typeof value === 'string' + ? value + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + : [] + const ids: string[] = [] + for (const entry of raw) { + const id = resolve(entry) + if (id !== null && !ids.includes(id)) ids.push(id) + } + return ids + } + + return resolve(Array.isArray(value) ? value[0] : value) +} + /** * Format a stored value for display in an input field. Defensive against * shape drift: a column whose declared type lags its actual data (e.g. a diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 6ea241ab22f..e306aeac52d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -175,10 +175,16 @@ export function ImportCsvDialog({ resetState() } + // Replace deletes every existing row and creating columns is a schema change, + // so each needs its own lock clear. Withholding them here keeps the dialog + // from offering a configuration the server can only answer with a 423. + const canReplace = !table.locks?.deleteLocked + const canCreateColumns = !table.locks?.schemaLocked + const columnOptions: ComboboxOption[] = useMemo(() => { const options: ComboboxOption[] = [ { label: 'Do not import', value: SKIP_VALUE }, - { label: '+ Create new column', value: CREATE_VALUE }, + ...(canCreateColumns ? [{ label: '+ Create new column', value: CREATE_VALUE }] : []), ] for (const col of table.schema.columns) { options.push({ @@ -187,7 +193,7 @@ export function ImportCsvDialog({ }) } return options - }, [table.schema.columns]) + }, [table.schema.columns, canCreateColumns]) async function handleFileSelected(file: File) { const ext = file.name.split('.').pop()?.toLowerCase() @@ -257,6 +263,7 @@ export function ImportCsvDialog({ function handleModeChange(value: string) { setSubmitError(null) + if (value === 'replace' && !canReplace) return setMode(value as CsvImportMode) } @@ -307,7 +314,11 @@ export function ImportCsvDialog({ async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) - const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined + // Hiding the Replace control doesn't clear an already-selected `mode`, so a + // delete lock landing while the dialog is open would still submit replace. + const effectiveMode: CsvImportMode = canReplace ? mode : 'append' + const createColumns = + canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined // Large files can't be POSTed through the server (request-body cap) — upload them // straight to storage and import in the background instead. Seed the header tray and @@ -326,7 +337,7 @@ export function ImportCsvDialog({ workspaceId, tableId: table.id, file: parsed.file, - mode, + mode: effectiveMode, mapping, createColumns, onProgress: (percent) => { @@ -357,12 +368,12 @@ export function ImportCsvDialog({ workspaceId, tableId: table.id, file: parsed.file, - mode, + mode: effectiveMode, mapping, createColumns, }) const data = result.data - if (mode === 'append') { + if (effectiveMode === 'append') { toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`) } else { toast.success( @@ -426,14 +437,19 @@ export function ImportCsvDialog({ Append - Replace all rows + {canReplace && Replace all rows} {skipCount > 0 && (
-
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 d7e0ad2ec7c..c468acb2a3d 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 @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, ButtonGroup, @@ -12,7 +12,12 @@ import { Tooltip, } from '@sim/emcn' import { Check, Clipboard } from 'lucide-react' +import { + AGENT_STREAM_PROTOCOL_HEADER_LABEL, + AGENT_STREAM_PROTOCOL_V1, +} from '@/lib/workflows/streaming/agent-stream-protocol' import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface WorkflowDeploymentInfo { isDeployed: boolean @@ -76,6 +81,17 @@ export function ApiDeploy({ const info = deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null + /** + * Thinking and tool frames come off the block's stream sink, independent of + * `selectedOutputs`, so the presence of an Agent block is what decides + * whether these flags can do anything. + */ + const blocks = useWorkflowStore((state) => state.blocks) + const hasAgentBlock = useMemo( + () => Object.values(blocks).some((block) => block.type === 'agent'), + [blocks] + ) + const getBaseEndpoint = () => { if (!info) return '' return info.endpoint.replace(info.apiKey, '$SIM_API_KEY') @@ -99,6 +115,11 @@ export function ApiDeploy({ if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) { payload.selectedOutputs = selectedStreamingOutputs } + /** Paired with the protocol header, which the API requires alongside them. */ + if (hasAgentBlock) { + payload.includeThinking = true + payload.includeToolCalls = true + } return payload } @@ -163,11 +184,19 @@ console.log(data);` const endpoint = getBaseEndpoint() const payload = getStreamPayloadObject() const isPublic = info.isPublicApi + /** Required whenever the payload asks for agent-event frames. */ + const protocol = hasAgentBlock + ? { + curl: ` -H "${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1}" \\\n`, + python: ` "${AGENT_STREAM_PROTOCOL_HEADER_LABEL}": "${AGENT_STREAM_PROTOCOL_V1}",\n`, + js: ` "${AGENT_STREAM_PROTOCOL_HEADER_LABEL}": "${AGENT_STREAM_PROTOCOL_V1}",\n`, + } + : { curl: '', python: '', js: '' } switch (language) { case 'curl': return `curl -X POST \\ -${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ +${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'}${protocol.curl} -H "Content-Type: application/json" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -178,7 +207,7 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'}${protocol.python} "Content-Type": "application/json" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}, stream=True @@ -192,7 +221,7 @@ for line in response.iter_lines(): return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'}${protocol.js} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); @@ -210,7 +239,7 @@ while (true) { return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'}${protocol.js} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 4be07fe4eff..12b820efe5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -198,7 +198,7 @@ export function ChatDeploy({ ) : [], includeThinking: existingChat.includeThinking ?? false, - includeToolCalls: existingChat.includeToolCalls ?? existingChat.includeThinking ?? false, + includeToolCalls: existingChat.includeToolCalls ?? false, }) if (existingChat.customizations?.imageUrl) { diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 20c66c55d37..9f59db93f15 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -20,8 +20,9 @@ import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter' import { preprocessExecution } from '@/lib/execution/preprocessing' import { retryTableAdmission } from '@/lib/table/admission-retry' import { withCascadeLock } from '@/lib/table/cascade-lock' +import { fillMissingColumns, mapInputValues, namedRowMapper } from '@/lib/table/cell-format' import { getColumnId } from '@/lib/table/column-keys' -import { isExecCancelled } from '@/lib/table/deps' +import { isEmptyCellValue, isExecCancelled } from '@/lib/table/deps' import { getMaxTableDispatchConcurrency } from '@/lib/table/dispatch-concurrency' import { appendTableEvent } from '@/lib/table/events' import type { @@ -308,18 +309,19 @@ async function runWorkflowAndWriteTerminal( if (pickedUp === 'skipped') return 'error' // Map table columns → enrichment input ids (skip this group's own outputs). + // `columnName` holds a column id; the mapper resolves select ids to names. const ownOutputColumns = new Set(group.outputs.map((o) => o.columnName)) - const enrichInputs: Record = {} - for (const m of group.inputMappings ?? []) { - if (ownOutputColumns.has(m.columnName)) continue - enrichInputs[m.inputName] = row.data[m.columnName] - } + const enrichInputs = mapInputValues( + row.data, + table.schema.columns, + (group.inputMappings ?? []).filter((m) => !ownOutputColumns.has(m.columnName)) + ) // Skip (don't error) rows missing a required input — common when a table // is partially filled. Clear any prior output values so a stale result // doesn't linger (and doesn't mark the group `completed`-and-filled, which // would block the auto cascade from re-enriching once inputs return). - const isEmpty = (v: unknown) => v === undefined || v === null || v === '' + const isEmpty = isEmptyCellValue const missingRequired = enrichment.inputs.some( (i) => i.required && isEmpty(enrichInputs[i.id]) ) @@ -668,17 +670,17 @@ async function runWorkflowAndWriteTerminal( // `inputRow` is name-keyed: the workflow author references columns by name // in the Start block and downstream blocks, while stored `row.data` is // id-keyed. Translate, skipping this group's own output columns. + // NOTE: `outputs[].columnName` / `inputMappings[].columnName` hold column + // **ids**, not names — a known misnomer (renaming it is a schema migration). const ownOutputColumnIds = new Set(group.outputs.map((o) => o.columnName)) - const inputRow: Record = {} - for (const col of table.schema.columns) { - const id = getColumnId(col) - if (ownOutputColumnIds.has(id)) continue - inputRow[col.name] = row.data[id] - } - - const headers = table.schema.columns - .filter((c) => !ownOutputColumnIds.has(getColumnId(c))) - .map((c) => c.name) + const inputColumns = table.schema.columns.filter( + (c) => !ownOutputColumnIds.has(getColumnId(c)) + ) + // One column list drives both the row and its headers so they cannot drift. + // The mapper also resolves select option ids to names — the workflow author + // sees "Open", not `opt_a1b2`. + const inputRow = fillMissingColumns(namedRowMapper(inputColumns)(row.data), inputColumns) + const headers = inputColumns.map((c) => c.name) // When the group has explicit input mappings, feed the workflow's // Start-block fields from the mapped columns (`inputName ← row[columnId]`). @@ -686,10 +688,7 @@ async function runWorkflowAndWriteTerminal( // Start field still resolves when it matches a column name. `row`/`rawRow` // always carry the full (name-keyed) row for downstream reference. const inputMappings = group.inputMappings ?? [] - const mappedInputs: Record = {} - for (const m of inputMappings) { - mappedInputs[m.inputName] = row.data[m.columnName] - } + const mappedInputs = mapInputValues(row.data, table.schema.columns, inputMappings) const input = { ...(inputMappings.length > 0 ? mappedInputs : inputRow), diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 0cdb2266280..1792db96c52 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -7,25 +7,6 @@ import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' import { getTrigger } from '@/triggers' -/** - * Scopes added for the native Sim Slack app trigger (`slack_oauth`): the shared - * app's `app_mention`, assistant-thread, and DM events don't deliver without - * them. Advertised by `slack_v2` and the trigger only — the legacy block has no - * feature that needs them, and listing them there would flag every existing - * Slack credential as missing scopes and prompt a reconnect. - * - * This only controls what each picker *advertises* and treats as missing. The - * authorization request itself is provider-wide - * (`getCanonicalScopesForProvider('slack')`), so any reconnect grants the full - * set regardless of which block started it. - */ -const SLACK_V2_ONLY_SCOPES = new Set(['app_mentions:read', 'assistant:write', 'im:history']) - -/** Slack scopes the legacy v1 block advertises — the set from before the trigger expansion. */ -const SLACK_V1_ADVERTISED_SCOPES = getScopesForService('slack').filter( - (scope) => !SLACK_V2_ONLY_SCOPES.has(scope) -) - export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', @@ -126,7 +107,7 @@ export const SlackBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'slack', - requiredScopes: SLACK_V1_ADVERTISED_SCOPES, + requiredScopes: getScopesForService('slack'), placeholder: 'Select Slack workspace', dependsOn: ['authMethod'], condition: { @@ -2661,9 +2642,6 @@ function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { ...rest, credentialKind: 'any', placeholder: 'Select Slack account or bot', - // Full set, unlike v1: v2 hosts the native Sim app trigger, whose events - // need the mention/assistant/DM scopes. - requiredScopes: getScopesForService('slack'), credentialLabels: { oauthGroup: 'Sim app', oauthConnect: 'Connect the Sim app', diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index 14ce0dfdf8b..1b46e17af74 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -35,7 +35,7 @@ export const TikTokBlock: BlockConfig = { bgColor: '#000000', icon: TikTokIcon, triggerAllowed: true, - hideFromToolbar: true, + hideFromToolbar: false, subBlocks: [ { diff --git a/apps/sim/components/pii/custom-patterns-editor.tsx b/apps/sim/components/pii/custom-patterns-editor.tsx index b383fa4380d..23ba59f658e 100644 --- a/apps/sim/components/pii/custom-patterns-editor.tsx +++ b/apps/sim/components/pii/custom-patterns-editor.tsx @@ -56,7 +56,7 @@ export function CustomPatternsEditor({ patterns, onChange }: CustomPatternsEdito className='flex-1' /> updateRow(index, { replacement: e.target.value })} className='w-[26%]' diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 29e70d8cc4a..7cefeba5d47 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -17,11 +17,6 @@ const ApiKeys = dynamic(() => (module) => module.ApiKeys ) ) -const Copilot = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then( - (module) => module.Copilot - ) -) const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then( (module) => module.Admin @@ -47,7 +42,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return - if (section === 'copilot') return if (section === 'admin') return return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 15cd1c21ed4..0846cab14da 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -17,6 +17,7 @@ import { parseSettingsPathSection, resolveOrganizationSectionAccess, resolveWorkspaceNavigation, + SELFHOST_SETTINGS_ITEMS, SETTINGS_SECTION_REGISTRY, WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, @@ -38,11 +39,9 @@ describe('settings navigation boundaries', () => { 'apikeys', 'workflow-mcp-servers', 'byok', - 'copilot', 'inbox', 'recently-deleted', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -55,17 +54,16 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', - 'copilot', 'admin', 'mothership', ]) + expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys']) expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ 'members', 'billing', 'access-control', 'audit-logs', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -87,13 +85,18 @@ describe('settings navigation boundaries', () => { }) it('has one registry source for every unified and plane item', () => { - const unifiedIds = SETTINGS_SECTION_REGISTRY.map(({ unified }) => unified.id) + const unifiedIds = SETTINGS_SECTION_REGISTRY.flatMap(({ unified }) => + unified ? [unified.id] : [] + ) const accountIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.account ? [planes.account.id] : [] ) const organizationIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.organization ? [planes.organization.id] : [] ) + const selfHostIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => + planes?.selfhost ? [planes.selfhost.id] : [] + ) const workspaceIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.workspace ? [planes.workspace.id] : [] ) @@ -101,6 +104,7 @@ describe('settings navigation boundaries', () => { expect(new Set(unifiedIds).size).toBe(unifiedIds.length) expect(new Set(accountIds).size).toBe(accountIds.length) expect(new Set(organizationIds).size).toBe(organizationIds.length) + expect(new Set(selfHostIds).size).toBe(selfHostIds.length) expect(new Set(workspaceIds).size).toBe(workspaceIds.length) expect([...unifiedIds].sort()).toEqual( buildUnifiedSettingsNavigation() @@ -111,6 +115,7 @@ describe('settings navigation boundaries', () => { expect([...organizationIds].sort()).toEqual( ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id).sort() ) + expect([...selfHostIds].sort()).toEqual(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id).sort()) expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort()) }) @@ -121,7 +126,6 @@ describe('settings navigation boundaries', () => { 'billing', 'data-drains', 'data-retention', - 'domains', 'organization', 'sessions', 'sso', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c8eab37aea1..a567a79e317 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -6,7 +6,6 @@ import { HexSimple, Key, KeySquare, - Link, Lock, LogIn, Palette, @@ -27,15 +26,15 @@ import { McpIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/env-flags' -export type SettingsPlane = 'account' | 'organization' | 'workspace' +export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace' -export type AccountSettingsSection = - | 'general' - | 'billing' - | 'api-keys' - | 'copilot' - | 'admin' - | 'mothership' +export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' + +/** + * Settings a self-hoster needs from the managed service: their profile, what + * they pay for, and the Chat keys their own deployment authenticates with. + */ +export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys' export type OrganizationSettingsSection = | 'members' @@ -43,7 +42,6 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' - | 'domains' | 'sessions' | 'data-retention' | 'data-drains' @@ -65,6 +63,7 @@ export type WorkspaceSettingsSection = export type SettingsSection = | AccountSettingsSection | OrganizationSettingsSection + | SelfHostSettingsSection | WorkspaceSettingsSection export type OrganizationSettingsRouteSection = OrganizationSettingsSection | 'unavailable' @@ -90,9 +89,7 @@ export type UnifiedSettingsSection = | 'teammates' | 'organization' | 'sso' - | 'domains' | 'whitelabeling' - | 'copilot' | 'forks' | 'mcp' | 'custom-tools' @@ -142,6 +139,7 @@ interface UnifiedSettingsProjection interface SettingsPlaneSectionMap { account: AccountSettingsSection organization: OrganizationSettingsSection + selfhost: SelfHostSettingsSection workspace: WorkspaceSettingsSection } @@ -163,7 +161,8 @@ export interface SettingsSectionRegistryEntry { label: string icon: ComponentType<{ className?: string }> docsLink?: string - unified: UnifiedSettingsProjection + /** Omit for sections that exist only on a standalone plane. */ + unified?: UnifiedSettingsProjection planes?: SettingsPlaneProjections } @@ -198,6 +197,13 @@ export function getAccountSettingsHref( return withSettingsSearchParams(`/account/settings/${section}`, searchParams) } +export function getSelfHostSettingsHref( + section: SelfHostSettingsSection, + searchParams?: SettingsHrefSearchParams +): string { + return withSettingsSearchParams(`/selfhost/settings/${section}`, searchParams) +} + export function getOrganizationSettingsHref( organizationId: string, section: OrganizationSettingsRouteSection, @@ -223,6 +229,8 @@ export const ACCOUNT_SETTINGS_PATH_ALIASES = { export const ORGANIZATION_SETTINGS_PATH_ALIASES = { organization: 'members', + // Verified domains moved into the SSO page; keep old links working. + domains: 'sso', } as const satisfies Readonly> export const WORKSPACE_SETTINGS_PATH_ALIASES = { @@ -275,6 +283,28 @@ export const ACCOUNT_SETTINGS_GROUPS = [ { key: 'platform', title: 'Platform' }, ] as const +/** Planes with their own standalone shell; the workspace plane renders inside the editor. */ +export type StandaloneSettingsPlane = Exclude + +/** + * Per-plane sidebar chrome. Self-host is reached from outside the app (the CLI + * wizard, the README), so it leads with the brand mark rather than a Back link + * into a workspace the visitor may not even be using. + */ +export const SETTINGS_PLANE_CHROME: Record< + StandaloneSettingsPlane, + { label: string; showWordmark: boolean } +> = { + account: { label: 'Account', showWordmark: false }, + organization: { label: 'Organization', showWordmark: false }, + selfhost: { label: 'Self-host', showWordmark: true }, +} + +export const SELFHOST_SETTINGS_GROUPS = [ + { key: 'account', title: 'Account' }, + { key: 'developer', title: 'Developer' }, +] as const + export const ORGANIZATION_SETTINGS_GROUPS = [ { key: 'organization', title: 'Organization' }, { key: 'security', title: 'Security' }, @@ -299,6 +329,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, planes: { account: { id: 'general', group: 'account', order: 0 }, + selfhost: { id: 'general', group: 'account', order: 0 }, }, }, { @@ -362,6 +393,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'account', order: 1, }, + selfhost: { + id: 'billing', + description: 'Manage your personal plan, usage, and invoices.', + group: 'account', + order: 1, + }, organization: { id: 'billing', description: 'Manage the organization plan, usage, and invoices.', @@ -490,14 +527,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] { label: 'Chat keys', icon: HexSimple, - unified: { - id: 'copilot', - description: 'Manage the model-provider keys that power Chat.', - group: 'system', - requiresHosted: true, - }, planes: { - account: { id: 'copilot', group: 'developer', order: 3 }, + selfhost: { + id: 'chat-keys', + description: 'Manage the model-provider keys that power Chat.', + group: 'developer', + order: 2, + }, }, }, { @@ -544,22 +580,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, - { - label: 'Verified domains', - icon: Link, - docsLink: 'https://docs.sim.ai/platform/enterprise/verified-domains', - unified: { - id: 'domains', - description: 'Prove ownership of your email domains before configuring SSO.', - group: 'enterprise', - requiresHosted: true, - requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - }, - planes: { - organization: { id: 'domains', group: 'security', order: 5 }, - }, - }, { label: 'Session policies', icon: Clock, @@ -573,7 +593,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, }, planes: { - organization: { id: 'sessions', group: 'security', order: 6 }, + organization: { id: 'sessions', group: 'security', order: 5 }, }, }, { @@ -590,7 +610,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 7 }, + organization: { id: 'data-retention', group: 'enterprise', order: 6 }, }, }, { @@ -606,7 +626,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 8 }, + organization: { id: 'data-drains', group: 'enterprise', order: 7 }, }, }, { @@ -622,7 +642,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 9 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, }, }, { @@ -671,15 +691,18 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] ] export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { - return SETTINGS_SECTION_REGISTRY.map(({ label, icon, docsLink, unified }) => { + return SETTINGS_SECTION_REGISTRY.flatMap(({ label, icon, docsLink, unified }) => { + if (!unified) return [] const { group, ...item } = unified - return { - ...item, - label, - icon, - section: group, - ...(docsLink ? { docsLink } : {}), - } + return [ + { + ...item, + label, + icon, + section: group, + ...(docsLink ? { docsLink } : {}), + }, + ] }) } @@ -691,14 +714,22 @@ function buildPlaneSettingsItems( return projection ? [{ entry, projection }] : [] }) .sort((left, right) => left.projection.order - right.projection.order) - .map(({ entry, projection }) => ({ - id: projection.id, - label: projection.label ?? entry.label, - description: projection.description ?? entry.unified.description, - icon: entry.icon, - group: projection.group, - ...(entry.docsLink ? { docsLink: entry.docsLink } : {}), - })) + .map(({ entry, projection }) => { + // A plane-only section carries no unified projection to inherit from, so + // its own description is the only source — missing one is a registry bug. + const description = projection.description ?? entry.unified?.description + if (!description) { + throw new Error(`Settings section "${projection.id}" is missing a description`) + } + return { + id: projection.id, + label: projection.label ?? entry.label, + description, + icon: entry.icon, + group: projection.group, + ...(entry.docsLink ? { docsLink: entry.docsLink } : {}), + } + }) } export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem[] = @@ -707,6 +738,9 @@ export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('organization') +export const SELFHOST_SETTINGS_ITEMS: SettingsNavigationItem[] = + buildPlaneSettingsItems('selfhost') + export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('workspace') @@ -718,7 +752,7 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem = new Set( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.planes?.organization ? [entry.unified.id] : [] + entry.planes?.organization && entry.unified ? [entry.unified.id] : [] ) ) @@ -758,7 +792,6 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - domains: SETTINGS_SELF_HOSTED_OVERRIDES.sso, sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, @@ -872,7 +905,9 @@ export function getSettingsSectionMeta( ? ACCOUNT_SETTINGS_ITEMS : plane === 'organization' ? ORGANIZATION_SETTINGS_ITEMS - : WORKSPACE_SETTINGS_ITEMS + : plane === 'selfhost' + ? SELFHOST_SETTINGS_ITEMS + : WORKSPACE_SETTINGS_ITEMS const item = catalog.find((candidate) => candidate.id === section) return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null } diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index d2a3e0d6324..66ae7a1efc3 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,9 +23,6 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) -const DomainSettings = dynamic(() => - import('@/ee/sso/components/domain-settings').then((module) => module.DomainSettings) -) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (module) => module.SessionPolicySettings @@ -71,9 +68,6 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return - if (section === 'domains') { - return - } if (section === 'sessions') { return } diff --git a/apps/sim/components/settings/selfhost-settings-renderer.tsx b/apps/sim/components/settings/selfhost-settings-renderer.tsx new file mode 100644 index 00000000000..3258c37ebb7 --- /dev/null +++ b/apps/sim/components/settings/selfhost-settings-renderer.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useEffect } from 'react' +import dynamic from 'next/dynamic' +import { usePostHog } from 'posthog-js/react' +import type { SelfHostSettingsSection } from '@/components/settings/navigation' +import { captureEvent } from '@/lib/posthog/client' +import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' + +const Billing = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( + (module) => module.Billing + ) +) +const ChatKeys = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then( + (module) => module.Copilot + ) +) + +interface SelfHostSettingsRendererProps { + section: SelfHostSettingsSection +} + +export function SelfHostSettingsRenderer({ section }: SelfHostSettingsRendererProps) { + const posthog = usePostHog() + + useEffect(() => { + captureEvent(posthog, 'settings_tab_viewed', { plane: 'selfhost', section }) + }, [posthog, section]) + + if (section === 'general') return + if (section === 'billing') return + return +} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 70ec8325f84..af95658ab5a 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -4,9 +4,24 @@ import { useEffect, useRef, useState } from 'react' import { ChipConfirmModal, chipVariants, cn, Tooltip } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' -import type { SettingsNavigationItem, SettingsSection } from '@/components/settings/navigation' +import { + SETTINGS_PLANE_CHROME, + type SettingsNavigationItem, + type SettingsSection, + type StandaloneSettingsPlane, +} from '@/components/settings/navigation' +import { SimWordmark } from '@/app/(landing)/components/navbar/components' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' +/** + * The marketing landing page. `?home` is required: the proxy bounces a + * signed-in user off `/` to `/workspace` unless the param is present. + */ +const LANDING_HREF = '/?home' + +/** Where the Back chip goes on planes that don't show the wordmark. */ +const WORKSPACE_HREF = '/workspace' + interface SettingsNavigationGroup { key: string title: string @@ -19,7 +34,7 @@ interface SidebarSettingsItem
interface SettingsSidebarProps
{ activeSection: string - backHref: string + plane: StandaloneSettingsPlane groups: readonly SettingsNavigationGroup[] hrefForSection: (section: Section) => string items: readonly SidebarSettingsItem
[] @@ -47,7 +62,7 @@ function SidebarTooltip({ export function SettingsSidebar
({ activeSection, - backHref, + plane, groups, hrefForSection, items, @@ -82,18 +97,30 @@ export function SettingsSidebar
({ return ( <>
- + {/* Both stay buttons, not Links: leaving settings must run the unsaved-changes guard. */} + {SETTINGS_PLANE_CHROME[plane].showWordmark ? ( - + ) : ( + + + + )}
{ if (item.id === 'billing' && !isBillingEnabled) return false - if (item.id === 'copilot' && !isHosted) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) @@ -64,6 +74,19 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { isTargetOrganizationAdmin: isOrganizationAdmin, }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, organizationFeatures) ) + const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => { + if (item.id === 'billing' && !isBillingEnabled) return false + // Chat keys are issued by the managed service, so there are none to list on + // a self-hosted deployment — useCopilotKeys is `enabled: isHosted` for the + // same reason. Self-hosters manage their keys on sim.ai. + if (item.id === 'chat-keys' && !isHosted) return false + return true + }) + const selfHostSection = parseSettingsPathSection({ + path: pathname, + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: 'general', + }) const accountSection = parseSettingsPathSection({ path: pathname, items: ACCOUNT_SETTINGS_ITEMS, @@ -76,12 +99,25 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { defaultSection: 'members', aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, }) - const activeSection = plane === 'account' ? accountSection : organizationSection + const activeSection = + plane === 'account' + ? accountSection + : plane === 'selfhost' + ? selfHostSection + : organizationSection const sidebar = - plane === 'account' ? ( + plane === 'selfhost' ? ( + + ) : plane === 'account' ? ( getOrganizationSettingsHref(props.organizationId, section)} items={organizationItems} @@ -98,22 +134,30 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { return ( -
+ {/* + Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless + sidebar column against the app surface, and only the content pane + carrying the rounded border. Keep the two in step — a settings page + should look the same whether it is reached inside a workspace or not. + */} +
-
- - - - {children} - - - -
+
+
+ + + + {children} + + + +
+
) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 813b2d9df80..6d2cb78d2e3 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -22,6 +22,12 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' import { groupIdParam, groupIdUrlKeys, + groupSearchParam, + groupSearchUrlKeys, + groupStatusParam, + groupStatusUrlKeys, + groupTabParam, + groupTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -85,6 +91,45 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon ...groupIdParam.parser, ...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, + }) + const [, setGroupSearch] = useQueryState(groupSearchParam.key, { + ...groupSearchParam.parser, + ...groupSearchUrlKeys, + }) + const [, setGroupStatus] = useQueryState(groupStatusParam.key, { + ...groupStatusParam.parser, + ...groupStatusUrlKeys, + }) + + /** + * The detail view's tab/search/status params are scoped to one group, so both + * transitions reset them — otherwise a stale `group-id` that never resolves + * leaves them in the URL and the next group opens on the previous group's tab + * and filters. nuqs batches these same-tick writes into one URL update. + */ + const openGroupDetail = useCallback( + (groupId: string) => { + void setSelectedGroupId(groupId) + void setGroupTab(null) + void setGroupSearch(null) + void setGroupStatus(null) + }, + [setSelectedGroupId, setGroupTab, setGroupSearch, setGroupStatus] + ) + + const closeGroupDetail = useCallback(() => { + void setSelectedGroupId(null, { history: 'replace' }) + void setGroupTab(null) + void setGroupSearch(null) + void setGroupStatus(null) + }, [setSelectedGroupId, setGroupTab, setGroupSearch, setGroupStatus]) const [showCreateModal, setShowCreateModal] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') @@ -169,8 +214,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon workspaceOptions={workspaceOptions} organizationWorkspaces={organizationWorkspaces} workspacesLoading={workspacesLoading} - onBack={() => void setSelectedGroupId(null, { history: 'replace' })} - onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroupDetail} + onDeleted={closeGroupDetail} /> ) } @@ -207,7 +252,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
-
diff --git a/apps/sim/ee/audit-logs/hooks/audit-logs.ts b/apps/sim/ee/audit-logs/hooks/audit-logs.ts index 04f5c08bdb6..7685f83b3ff 100644 --- a/apps/sim/ee/audit-logs/hooks/audit-logs.ts +++ b/apps/sim/ee/audit-logs/hooks/audit-logs.ts @@ -2,6 +2,8 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs' +export const AUDIT_LOG_LIST_STALE_TIME = 30 * 1000 + export const auditLogKeys = { all: ['audit-logs'] as const, lists: () => [...auditLogKeys.all, 'list'] as const, @@ -47,6 +49,6 @@ export function useAuditLogs(organizationId: string, filters: AuditLogFilters, e initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: Boolean(organizationId) && enabled, - staleTime: 30 * 1000, + staleTime: AUDIT_LOG_LIST_STALE_TIME, }) } diff --git a/apps/sim/ee/components/setting-row.tsx b/apps/sim/ee/components/setting-row.tsx index 4d854cd908a..2b6eb0826f6 100644 --- a/apps/sim/ee/components/setting-row.tsx +++ b/apps/sim/ee/components/setting-row.tsx @@ -1,32 +1,52 @@ -import { Label, Tooltip } from '@sim/emcn' -import { Info } from 'lucide-react' +import { Info, Label } from '@sim/emcn' interface SettingRowProps { label: string description?: string /** Optional supplementary guidance shown in a tooltip on an info icon beside the label. */ labelTooltip?: string + /** Marks the field as not required, rendered as a muted suffix on the label. */ + optional?: boolean + /** Validation message rendered beneath the control. */ + error?: React.ReactNode + /** + * Id of the control this row labels. Wires the label to the control so + * clicking it focuses the field, and points the control at the error text via + * `aria-describedby` — pass the same id to the child input. + */ + htmlFor?: string children: React.ReactNode } -export function SettingRow({ label, description, labelTooltip, children }: SettingRowProps) { +export function SettingRow({ + label, + description, + labelTooltip, + optional = false, + error, + htmlFor, + children, +}: SettingRowProps) { return (
- + {labelTooltip && ( - - - - - - {labelTooltip} - - + + {labelTooltip} + )}
{description &&

{description}

} {children} + {error ? ( +

+ {error} +

+ ) : null}
) } 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 f60a85b95ce..d3a591c0841 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -224,7 +224,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD [deployedLoaded, availableFields, overrideById, inputs] ) - const [expandedInputs, setExpandedInputs] = useState>(new Set()) + const [expandedInputs, setExpandedInputs] = useState>(() => new Set()) const toggleInput = (id: string) => setExpandedInputs((prev) => { const next = new Set(prev) @@ -537,7 +537,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD size='sm' onClick={iconUpload.handleThumbnailClick} disabled={iconUpload.isUploading || !canManageBlock} - className='text-[13px]' + className='text-small' > {iconUrl ? 'Change' : 'Upload'} @@ -547,8 +547,9 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD variant='ghost' size='sm' onClick={iconUpload.handleRemove} + aria-label='Remove icon' disabled={iconUpload.isUploading || !canManageBlock} - className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]' + className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]' > diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 58301485c88..50d0b7ebceb 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -143,7 +143,7 @@ export function CustomBlocks() { title={cb.name} description={cb.description || undefined} trailing={ -
+
{!cb.enabled && Disabled} {canAdmin && }
diff --git a/apps/sim/ee/data-drains/components/data-drain-create.test.tsx b/apps/sim/ee/data-drains/components/data-drain-create.test.tsx new file mode 100644 index 00000000000..d235c93bd21 --- /dev/null +++ b/apps/sim/ee/data-drains/components/data-drain-create.test.tsx @@ -0,0 +1,315 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DESTINATION_TYPES } from '@/lib/data-drains/types' + +const { mockToastError, mockToastSuccess, mockUseCreateDataDrain, mockGuardBack } = vi.hoisted( + () => ({ + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), + mockUseCreateDataDrain: vi.fn(), + mockGuardBack: vi.fn((onLeave: () => void) => onLeave()), + }) +) + +interface SelectProps { + value?: string + onChange?: (value: string) => void + options?: { value: string; label: string }[] + 'aria-label'?: string +} + +vi.mock('@sim/emcn', () => ({ + // A real