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..b5d9b9c5252 100644 --- a/README.md +++ b/README.md @@ -75,71 +75,42 @@ Docker must be installed and running. Use `-p, --port ` to run Sim on a di ## Self-hosting -### Docker Compose +**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/). ```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: - -```bash -git clone https://github.com/simstudioai/sim.git -cd sim bun install -bun run prepare # Set up pre-commit hooks +bun run setup ``` -2. Set up PostgreSQL with pgvector: +`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: -```bash -docker run --name simstudio-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_DB=simstudio -p 5432:5432 -d pgvector/pgvector:pg17 -``` +- **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 -Or install manually via the [pgvector guide](https://github.com/pgvector/pgvector#installation). +When it finishes, open [http://localhost:3000](http://localhost:3000). -3. Configure environment: +Manage your install with `bun run sim`: ```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" +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 ``` -4. Run migrations: +`sim` detects how you're running (Docker Compose, local dev, or Kubernetes) and acts accordingly. -```bash -cd packages/db && bun run db:migrate -``` +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. -5. Start development servers: - -```bash -bun run dev:full # Starts Next.js app and realtime socket server -``` - -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/account/settings/chat-keys](https://sim.ai/account/settings/chat-keys). ## Environment Variables 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/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/account/settings/chat-keys/chat-keys-view.tsx similarity index 91% rename from apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx rename to apps/sim/app/account/settings/chat-keys/chat-keys-view.tsx index c746dd62828..475ea46bced 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/account/settings/chat-keys/chat-keys-view.tsx @@ -12,9 +12,12 @@ import { ChipModalHeader, SecretReveal, } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { getAccountSettingsHref } from '@/components/settings/navigation' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -26,7 +29,7 @@ import { useGenerateCopilotKey, } from '@/hooks/queries/copilot-keys' -const logger = createLogger('CopilotSettings') +const logger = createLogger('ChatKeysSettings') /** Formats a key's last-used timestamp, falling back to "Never" when unset. */ function formatLastUsed(dateString?: string | null): string { @@ -35,10 +38,11 @@ function formatLastUsed(dateString?: string | null): string { } /** - * Copilot Keys management component for handling API keys used with the Copilot feature. - * Provides functionality to create, view, and delete copilot API keys. + * Standalone page for managing the Chat API keys used with the Chat feature. + * Provides functionality to create, view, and delete Chat API keys. */ -export function Copilot() { +export function ChatKeysView() { + const router = useRouter() const { data: keys = [], isLoading } = useCopilotKeys() const generateKey = useGenerateCopilotKey() const deleteKeyMutation = useDeleteCopilotKey() @@ -84,7 +88,7 @@ export function Copilot() { setIsCreateDialogOpen(false) } } catch (error) { - logger.error('Failed to generate copilot API key', { error }) + logger.error('Failed to generate Chat API key', { error }) setCreateError('Failed to create API key. Please check your connection and try again.') } } @@ -98,7 +102,7 @@ export function Copilot() { await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id }) } catch (error) { - logger.error('Failed to delete copilot API key', { error }) + logger.error('Failed to delete Chat API key', { error }) } } @@ -122,12 +126,19 @@ export function Copilot() { return ( <> router.push(getAccountSettingsHref('general')), + }} search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search API keys...', }} actions={actions} + title='Chat keys' + description='Create and manage the API keys that power Chat.' > {isLoading ? null : showEmptyState ? ( Click "Create API key" above to get started diff --git a/apps/sim/app/account/settings/chat-keys/page.tsx b/apps/sim/app/account/settings/chat-keys/page.tsx new file mode 100644 index 00000000000..6c20b821d3b --- /dev/null +++ b/apps/sim/app/account/settings/chat-keys/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getAccountSettingsHref } from '@/components/settings/navigation' +import { getSession } from '@/lib/auth' +import { isHosted } from '@/lib/core/config/env-flags' +import { ChatKeysView } from '@/app/account/settings/chat-keys/chat-keys-view' + +export const metadata: Metadata = { + title: 'Chat keys - Account settings', +} + +export default async function AccountChatKeysPage() { + const session = await getSession() + if (!session?.user) redirect('/login') + if (!isHosted) redirect(getAccountSettingsHref('general')) + + return ( + + + + ) +} 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/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/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/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]/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/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..7d8085d6ea2 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -38,11 +38,9 @@ describe('settings navigation boundaries', () => { 'apikeys', 'workflow-mcp-servers', 'byok', - 'copilot', 'inbox', 'recently-deleted', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -55,7 +53,6 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', - 'copilot', 'admin', 'mothership', ]) @@ -65,7 +62,6 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', - 'domains', 'sessions', 'data-retention', 'data-drains', @@ -121,7 +117,6 @@ describe('settings navigation boundaries', () => { 'billing', 'data-drains', 'data-retention', - 'domains', 'organization', 'sessions', 'sso', @@ -184,6 +179,9 @@ describe('settings navigation boundaries', () => { expect(parseAccountPath('/account/settings/apikeys', null)).toBe('api-keys') expect(parseAccountPath('/account/settings/not-a-section', null)).toBeNull() expect(parseAccountPath('/account/settings', 'general')).toBe('general') + // Chat keys is a real page but deliberately not a nav item — with a null + // default it must resolve to nothing so the sidebar highlights no sibling. + expect(parseAccountPath('/account/settings/chat-keys', null)).toBeNull() }) it('parses canonical, aliased, and invalid organization settings paths', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c8eab37aea1..d76cbfe0409 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, @@ -29,13 +28,7 @@ import { isHosted } from '@/lib/core/config/env-flags' export type SettingsPlane = 'account' | 'organization' | 'workspace' -export type AccountSettingsSection = - | 'general' - | 'billing' - | 'api-keys' - | 'copilot' - | 'admin' - | 'mothership' +export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' export type OrganizationSettingsSection = | 'members' @@ -43,7 +36,6 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' - | 'domains' | 'sessions' | 'data-retention' | 'data-drains' @@ -90,9 +82,7 @@ export type UnifiedSettingsSection = | 'teammates' | 'organization' | 'sso' - | 'domains' | 'whitelabeling' - | 'copilot' | 'forks' | 'mcp' | 'custom-tools' @@ -223,6 +213,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 = { @@ -487,19 +479,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'byok', group: 'workspace', order: 2 }, }, }, - { - 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 }, - }, - }, { label: 'Sim mailer', icon: Send, @@ -544,22 +523,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 +536,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 +553,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 +569,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 +585,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 }, }, }, { @@ -758,7 +721,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, 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/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 70ec8325f84..175bf35b9d8 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -18,7 +18,8 @@ interface SidebarSettingsItem
} interface SettingsSidebarProps
{ - activeSection: string + /** `null` when the route is a nested page that is not itself a nav item — nothing highlights. */ + activeSection: string | null backHref: string groups: readonly SettingsNavigationGroup[] hrefForSection: (section: Section) => string diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 43911876480..ce740200e93 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -21,7 +21,7 @@ import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settin import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' interface StandaloneSettingsShellBaseProps { children: ReactNode @@ -52,7 +52,6 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan) const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { 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 }) @@ -77,10 +76,23 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, }) const activeSection = plane === 'account' ? accountSection : organizationSection + /** + * The sidebar highlights only an exact nav match. A nested route that is not + * itself a nav item (e.g. /account/settings/chat-keys) would otherwise fall back + * to `defaultSection` and light up an unrelated sibling — reading as though the + * page lived inside it. The section above keeps its default for the title + * provider, which every page can still override. + */ + const accountSidebarSection = parseSettingsPathSection({ + path: pathname, + items: ACCOUNT_SETTINGS_ITEMS, + defaultSection: null, + aliases: ACCOUNT_SETTINGS_PATH_ALIASES, + }) const sidebar = plane === 'account' ? ( { + 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