diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md
index f4a88a40314..50e7684363f 100644
--- a/.claude/rules/emcn-components.md
+++ b/.claude/rules/emcn-components.md
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`ChipDatePicker`** — chip-styled date field.
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
+- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
## Modal keyboard defaults
diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md
index 51a882f8607..61960c9eb5c 100644
--- a/.claude/rules/sim-styling.md
+++ b/.claude/rules/sim-styling.md
@@ -60,6 +60,10 @@ Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcu
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
+## Scroll Edges
+
+A scroll region that can hide rows past an edge uses `useScrollEdges` with `scrollFadeClass` + `scrollFadeAttributes` from `@sim/emcn`: a 12px fade at an edge only while content is hidden beyond it, never at rest. The region's baseline padding lives on the scroll box itself (so rows pass through it under the fade), and the divider at that edge is drawn by the neighboring block, conditional on the same edge. Never hand-roll a `mask-image` gradient or a `scrollTop > 0` effect for this.
+
## Font Weight
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
@@ -70,7 +74,7 @@ Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-we
## Color Tokens
-Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
+Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; progress and completion (a checked step, a done state) `--brand-blue` — `--selection` stays the interactive highlight; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
### Line weight
diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts
index 4b6c03016ca..df17dd4f51f 100644
--- a/apps/desktop/e2e/smoke.spec.ts
+++ b/apps/desktop/e2e/smoke.spec.ts
@@ -10,7 +10,7 @@ import { _electron as electron, expect, test } from '@playwright/test'
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const PAGES: Record = {
- '/workspace': `Sim Fixture
+ '/home': `Sim Fixture
fixture-app
internal
external
@@ -82,7 +82,7 @@ test.describe('desktop shell smoke', () => {
app = await launchApp(origin)
const window = await app.firstWindow()
await expect(window.locator('#app')).toHaveText('fixture-app')
- expect(window.url()).toBe(`${origin}/workspace`)
+ expect(window.url()).toBe(`${origin}/home`)
})
test('internal window.open creates an independent full Sim window', async () => {
@@ -150,7 +150,7 @@ test.describe('desktop shell smoke', () => {
app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal)
)
.toEqual(['https://docs.sim.ai/navigation'])
- expect(window.url()).toBe(`${origin}/workspace`)
+ expect(window.url()).toBe(`${origin}/home`)
})
test('unreachable origin shows the bundled offline page', async () => {
diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts
index 245019bdc51..6816c745736 100644
--- a/apps/desktop/src/main/app-routes.test.ts
+++ b/apps/desktop/src/main/app-routes.test.ts
@@ -5,15 +5,15 @@ describe('app routes', () => {
it('derives the new-chat route from the last workspace route', () => {
expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home')
expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home')
- expect(newChatRoute('/account')).toBe('/workspace')
- expect(newChatRoute(undefined)).toBe('/workspace')
- expect(newChatRoute('//evil.example')).toBe('/workspace')
+ expect(newChatRoute('/account')).toBe('/home')
+ expect(newChatRoute(undefined)).toBe('/home')
+ expect(newChatRoute('//evil.example')).toBe('/home')
})
it('derives the settings route from the last workspace route', () => {
expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop')
- expect(settingsRoute('/account')).toBe('/workspace')
- expect(settingsRoute(undefined)).toBe('/workspace')
- expect(settingsRoute('//evil.example')).toBe('/workspace')
+ expect(settingsRoute('/account')).toBe('/home')
+ expect(settingsRoute(undefined)).toBe('/home')
+ expect(settingsRoute('//evil.example')).toBe('/home')
})
})
diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts
index 6e877edd013..abbf62bba33 100644
--- a/apps/desktop/src/main/app-routes.ts
+++ b/apps/desktop/src/main/app-routes.ts
@@ -10,6 +10,12 @@ import { isSafeInternalPath } from '@/main/config'
* do with the tray, and the tray can be absent entirely.
*/
+/**
+ * The web app's signed-in entry. It resolves to the organization the user belongs
+ * to, or to their workspaces, so the shell never has to know which applies.
+ */
+export const APP_ENTRY_ROUTE = '/home'
+
/** Workspace id from the last visited route, or null when it carries none. */
function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
if (isSafeInternalPath(lastRoute)) {
@@ -23,19 +29,19 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
/**
* Route for "New Chat": the home (chat) surface of the workspace the user was
- * last in, falling back to the workspace picker redirect when the last route
- * carries no workspace.
+ * last in, falling back to the app entry when the last route carries no
+ * workspace.
*/
export function newChatRoute(lastRoute: string | undefined): string {
const workspaceId = workspaceIdFromRoute(lastRoute)
- return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace'
+ return workspaceId ? `/workspace/${workspaceId}/home` : APP_ENTRY_ROUTE
}
/**
* Route for "Settings…": the Sim app's settings surface for the workspace the
- * user was last in, falling back to the workspace picker redirect.
+ * user was last in, falling back to the app entry.
*/
export function settingsRoute(lastRoute: string | undefined): string {
const workspaceId = workspaceIdFromRoute(lastRoute)
- return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace'
+ return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE
}
diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts
index b359110bf90..bf441fe3f9f 100644
--- a/apps/desktop/src/main/session-lifecycle.test.ts
+++ b/apps/desktop/src/main/session-lifecycle.test.ts
@@ -75,9 +75,9 @@ describe('decideStartRoute', () => {
})
it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => {
- expect(decideStartRoute(undefined)).toBe('/workspace')
- expect(decideStartRoute('//evil.example')).toBe('/workspace')
- expect(decideStartRoute('/login')).toBe('/workspace')
+ expect(decideStartRoute(undefined)).toBe('/home')
+ expect(decideStartRoute('//evil.example')).toBe('/home')
+ expect(decideStartRoute('/login')).toBe('/home')
})
})
@@ -94,11 +94,11 @@ describe('resolveStartRoute', () => {
)
})
- it('falls back to the workspace picker after confirmed access denial', async () => {
+ it('falls back to the app entry after confirmed access denial', async () => {
const session = sessionWithResponse(403, { error: 'Workspace access denied' })
await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe(
- '/workspace'
+ '/home'
)
})
diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts
index c6d47899b2b..5a8eee85932 100644
--- a/apps/desktop/src/main/session-lifecycle.ts
+++ b/apps/desktop/src/main/session-lifecycle.ts
@@ -7,6 +7,7 @@ import {
completeAccountDataTeardown,
waitForAccountDataMutations,
} from '@/main/account-data-generation'
+import { APP_ENTRY_ROUTE } from '@/main/app-routes'
import { isSafeInternalPath } from '@/main/config'
import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation'
import type { EventRecorder } from '@/main/observability'
@@ -60,14 +61,14 @@ export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean {
/**
* Picks the route to load at launch: the last visited route (when safe and
- * not itself an auth surface), falling back to /workspace. A signed-out
+ * not itself an auth surface), falling back to the app entry. A signed-out
* partition is handled by the web app's own login redirect.
*/
export function decideStartRoute(lastRoute: string | undefined): string {
if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) {
return lastRoute
}
- return '/workspace'
+ return APP_ENTRY_ROUTE
}
function workspaceIdFromRoute(route: string): string | null {
@@ -110,8 +111,8 @@ export async function resolveStartRoute(
}
)
if (response.status === 403) {
- logger.info('Saved workspace route is no longer accessible; opening workspace picker')
- return '/workspace'
+ logger.info('Saved workspace route is no longer accessible; opening the app entry')
+ return APP_ENTRY_ROUTE
}
return route
} catch {
diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx
index 8fdf6b767b2..16763637d19 100644
--- a/apps/docs/content/docs/cli/credentials.mdx
+++ b/apps/docs/content/docs/cli/credentials.mdx
@@ -103,6 +103,7 @@ Update Credential (OAuth login or personal API key required)
| `--service-account-json ` | No | Write-only Google service-account JSON key. |
| `--api-token ` | No | Write-only provider API token. |
| `--domain ` | No | Provider account domain. |
+| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
| `--signing-secret ` | No | Write-only webhook signing secret. |
| `--bot-token ` | No | Write-only bot token. |
| `--client-id ` | No | OAuth client identifier. |
diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx
index 61f299f4b36..9c39df61fc4 100644
--- a/apps/docs/content/docs/cli/reference.mdx
+++ b/apps/docs/content/docs/cli/reference.mdx
@@ -467,6 +467,7 @@ sim credentials update [options]
| `--service-account-json ` | No | Write-only Google service-account JSON key. |
| `--api-token ` | No | Write-only provider API token. |
| `--domain ` | No | Provider account domain. |
+| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
| `--signing-secret ` | No | Write-only webhook signing secret. |
| `--bot-token ` | No | Write-only bot token. |
| `--client-id ` | No | OAuth client identifier. |
diff --git a/apps/docs/content/docs/integrations/slack.mdx b/apps/docs/content/docs/integrations/slack.mdx
index 0c653a656e8..cad0e409c6b 100644
--- a/apps/docs/content/docs/integrations/slack.mdx
+++ b/apps/docs/content/docs/integrations/slack.mdx
@@ -956,7 +956,7 @@ Rename the Slack agent session associated with a thread.
### Slack List Channels
-List up to 10,000 accessible Slack conversations across as many cursor pages as Slack supplies, capped at 200 provider pages. Credential-group user tokens also return one-to-one and group direct messages.
+List up to 10,000 accessible public and private Slack channels across as many cursor pages as Slack supplies, capped at 200 provider pages.
#### Input
@@ -964,7 +964,7 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
-| `includePrivate` | boolean | No | Include private channels the bot is a member of \(default: true\) |
+| `includePrivate` | boolean | No | Include private channels the connected account can access \(default: true\) |
| `excludeArchived` | boolean | No | Exclude archived channels \(default: true\) |
| `limit` | number | No | Conversations to request per Slack page \(default: 100, max: 200\) |
| `cursor` | string | No | Pagination cursor from a previous response.nextCursor to resume from |
@@ -974,7 +974,7 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `channels` | array | Up to 10,000 accessible public and private channels, plus direct and group DMs for credential-group user tokens |
+| `channels` | array | Up to 10,000 accessible public and private channels |
| ↳ `id` | string | Conversation ID \(for example, C123, D123, or G123\) |
| ↳ `name` | string | Channel or group-DM name; omitted for one-to-one direct messages |
| ↳ `is_channel` | boolean | Whether this is a channel |
@@ -998,8 +998,8 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as
| ↳ `is_user_deleted` | boolean | Whether the other participant in a direct message is deactivated |
| ↳ `is_open` | boolean | Whether a direct or group-direct-message conversation is open |
| ↳ `priority` | number | Slack sidebar sort priority |
-| `ids` | array | Conversation IDs for every returned channel or DM |
-| `names` | array | Names of returned channels and group DMs; one-to-one DMs have no name |
+| `ids` | array | Conversation IDs for every returned channel |
+| `names` | array | Names of returned channels |
| `count` | number | Total number of conversations returned across all fetched pages, up to 10,000 |
| `hasMore` | boolean | Whether more Slack conversation pages remain beyond the fetched window |
| `nextCursor` | string | Cursor to fetch the next page; null when there are no more pages |
diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx
index d659d835704..7a9c3ac9235 100644
--- a/apps/docs/content/docs/knowledgebase/connectors.mdx
+++ b/apps/docs/content/docs/knowledgebase/connectors.mdx
@@ -8,6 +8,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
+For workspace Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases.
+
Connectors continuously sync documents from external services into your knowledge base, so you never have to upload files manually. New content is added, changed content is re-processed, and deleted content is removed — all automatically.
## Available Connectors
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index fd86838d469..649510a1483 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -10,6 +10,7 @@
"workflows",
"agents",
"---Workspace---",
+ "search",
"knowledgebase",
"tables",
"files",
diff --git a/apps/docs/content/docs/platform/connected-accounts.mdx b/apps/docs/content/docs/platform/connected-accounts.mdx
new file mode 100644
index 00000000000..1840650f209
--- /dev/null
+++ b/apps/docs/content/docs/platform/connected-accounts.mdx
@@ -0,0 +1,117 @@
+---
+title: Connected accounts
+description: Collect accounts in one organization credential group and control which workspaces can use them.
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+
+**Connected accounts** collects people's accounts in one shared pool for your organization, also called a **credential group**. An organization admin chooses the providers, invites people to connect, and allows specific workspaces to use the pool. Workflows in those workspaces can find an account by email through the [Credential block](/workflows/blocks/credential).
+
+Each organization has at most one pool. Creating it does not give any workspace access; the workspace allowlist starts empty.
+
+## Availability
+
+Connected accounts must be enabled for your organization. Sim Cloud also requires an active Enterprise plan. Organization owners and admins manage the pool, subject to the organization's permission settings. A workspace admin who is not an organization admin cannot change the pool or its workspace access.
+
+For self-hosted deployments using environment-based feature flags, set `CREDENTIAL_GROUPS=true`. Availability is organization-scoped; personal workspaces cannot use an organization pool.
+
+The setup instructions below describe **Settings → Connected accounts**, shown when Knowledge Member Access is disabled. When `KNOWLEDGE_MEMBER_ACCESS=true` and connected accounts is available, organization settings shows the Search **Integrations** page instead. Only the selected page is available, including through direct links. Switching pages does not remove existing connections or workspace access.
+
+## Set up connected accounts
+
+Open your organization’s **Settings → Connected accounts**. Select **Set up connected accounts** if this is your first time.
+
+The page has three tabs:
+
+| Tab | What you manage |
+| --- | --- |
+| **Providers** | Services people can connect and their shared configuration |
+| **People** | Connection requests, each person's connected accounts, and revocation |
+| **Workspace access** | Workspaces allowed to use the organization's accounts |
+
+### 1. Add providers
+
+In **Providers**, select **Add provider** and search the catalog. Complete any required configuration before adding the provider. Added providers appear in the list; use **Configure** to edit their settings or **Remove** in the row menu to remove them.
+
+| Provider | Organization setup |
+| --- | --- |
+| OAuth providers, such as Gmail or Google Drive | Add the provider from the available catalog. Each invited person authorizes their account. |
+| Slack | Supply the Slack App ID, Slack workspace ID, OAuth client ID, and client secret, then complete app verification. The organization uses one app and Slack workspace. Existing workspace Slack bots remain separate. |
+| Fireflies and Granola | Add the provider. Sim supplies the MCP endpoint and handles OAuth client registration. People only complete their own authorization. |
+| Databricks | Enter a name, the tenant's MCP URL, a registered OAuth client ID, and a client secret if required by that client. |
+
+For Databricks, **Add** validates and saves the configuration before the provider appears in the list. Cancelling the form leaves nothing added. Use an official Databricks HTTPS MCP endpoint, such as `https://your-workspace.cloud.databricks.com/api/2.0/mcp/sql`, and the OAuth client registered for your deployment. People connecting later use this organization configuration.
+
+When editing Databricks, leaving the client secret blank preserves the saved secret. Changing the MCP URL or OAuth client requires people to reconnect.
+
+Adding a provider makes account connections available without enabling indexing. Search source setup is managed on **Settings → Integrations** when Search is enabled. Connected accounts has no indexing controls or status indicators. Managed MCP providers currently support live tool calls only.
+
+### 2. Invite people
+
+For Search-enabled organizations, open **Settings → Integrations → People**. Otherwise, use **Settings → Connected accounts → People**. Set up a provider for personal account connections before inviting people; approving a Search integration alone is not enough.
+
+1. Select **Request connections**.
+2. Enter their email addresses and select **Send requests**.
+3. Each person opens the invitation, signs in to Sim with the verified invitation email, and authorizes the providers they want to connect.
+4. They select **Submit** to finish the connection form.
+
+Invitees can contribute accounts without joining your organization. The invitation grants access to their connection form; it does not grant access to your workspaces or workflows.
+
+Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Organization Account** with that email and **Gmail** finds Alex's active Gmail contribution.
+
+#### How the email is associated with a Sim user
+
+On first use, the invitation email must match the signed-in user's verified Sim email. Sim then binds the invitation to that user's account. Another person cannot take it over by opening the link, and a later email change does not transfer it to a different Sim user. The invitation email remains the workflow lookup key.
+
+OAuth account providers also verify that the provider email matches the invitation email. Managed MCP connections are associated with the verified Sim user who completes authorization; they do not independently verify the MCP provider's account email. Keep using the invitation email when looking up those connections.
+
+In **People**, open a person's actions menu and select **Resend** when they need another invitation. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own.
+
+### 3. Allow workspaces
+
+Open **Workspace access** and select **Add workspaces** to choose one or more workspaces. The list shows workspaces that have access; use a row's **Remove** action to withdraw it. Adding or removing a workspace applies immediately. Only workspaces in this organization can be allowed.
+
+
+An allowed workspace gives every authorized manual and deployed workflow in that workspace access to **every active account in the pool**. This includes scheduled, webhook, and public deployments that pass their normal workflow authorization. There is no separate workflow allowlist or restriction to the running user's own contributions.
+
+
+For example, allowing the Support workspace lets its authorized workflows use Alex's Gmail contribution even when someone else runs the workflow. Allowing the workspace does not grant anyone permission to edit or run a workflow they could not otherwise access.
+
+Removing a workspace stops subsequent use of the pool. It does not recall provider requests already in flight or remove data a workflow has already received. Chat continues to use each person's own connections; workspace access does not give Chat access to other people's accounts.
+
+## Use accounts in workflows
+
+Use the **Credential** block's organization operations in an allowed workspace:
+
+- **Find Organization Account** selects an OAuth account by invitation email and provider.
+- **List Organization Accounts** returns a page of OAuth accounts, optionally filtered by email and providers.
+- **Find Organization MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider.
+- **List Organization MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider.
+
+The organization is determined by the workflow's workspace. You do not enter a credential group ID or organization ID in the block.
+
+The outputs are account references, without tokens. Use an OAuth `credentialId` in the corresponding integration block's credential field. For managed MCP, `credentialId` identifies the person's connection; `mcpServerId` identifies shared configuration and cannot select that person's authorization by itself.
+
+See the [Credential block reference](/workflows/blocks/credential#organization-accounts) for inputs, outputs, pagination, and connection-event triggers.
+
+## Reconnect or stop sharing
+
+People can open **Settings → Account → Connected accounts** to view accounts they contributed, including contributions to organizations they have not joined. **Reconnect** starts authorization again. **Disconnect** stops the organization from using that account in subsequent calls.
+
+For administrators, the controls have different scopes:
+
+| Action | Effect |
+| --- | --- |
+| Remove a provider | Stops use of that provider's contributions in the pool |
+| Revoke a person | Stops use of all that person's contributions to the organization |
+| Remove a workspace from the allowlist | Stops that workspace's workflows from using the pool |
+
+## Current limits and troubleshooting
+
+- **One pool per organization:** there are no additional groups, per-workflow grants, or per-account workspace allowlists.
+- **One Databricks configuration:** multiple tenant endpoints cannot be added as separate Databricks providers in the same pool.
+- **Organization operations are missing:** confirm the feature is enabled for the organization and the workflow's workspace is allowed.
+- **An invitation rejects your sign-in:** use the verified Sim account matching the invitation email. For OAuth providers, connect the provider account with that same email.
+- **A Find operation fails:** it requires exactly one active matching connection. Check the invitation email, provider, connection status, and workspace access. It never chooses an arbitrary account when there are zero or multiple matches.
+- **A list seems incomplete:** organization list operations return up to 100 connections per page. Follow `nextCursor` while `hasMore` is true.
+- **A legacy Credential Group block fails:** replace it with the appropriate Credential block operation or trigger, update its output references, and redeploy the workflow.
diff --git a/apps/docs/content/docs/platform/meta.json b/apps/docs/content/docs/platform/meta.json
index c4d59da4bdc..4e7ffeec952 100644
--- a/apps/docs/content/docs/platform/meta.json
+++ b/apps/docs/content/docs/platform/meta.json
@@ -1,4 +1,11 @@
{
"title": "Platform",
- "pages": ["workspaces", "organization", "permissions", "credentials", "costs"]
+ "pages": [
+ "workspaces",
+ "organization",
+ "permissions",
+ "connected-accounts",
+ "credentials",
+ "costs"
+ ]
}
diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx
index c5b962f6fab..5b8bfc8f435 100644
--- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx
+++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx
@@ -49,6 +49,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or
| Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search |
| Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs |
| Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors |
+| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync |
| Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers |
| Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired |
| OAuth token cleanup | `/api/cron/cleanup-oauth-tokens` | `0 * * * *` | Deletes access and refresh tokens after the retention tail |
diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
index 2e73a5e9de7..d8a11cc0e2a 100644
--- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
+++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
@@ -114,6 +114,18 @@ One app registration in [Entra ID](https://entra.microsoft.com) covers all of th
The same variables also power "Sign in with Microsoft".
+### GitHub Search
+
+Register a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with repository **Contents: read-only**, **Metadata: read-only**, and account **Email addresses: read-only** permissions. Keep user access token expiration enabled so Sim can rotate access and refresh tokens.
+
+| Environment variables | Provider ID |
+|---|---|
+| `GITHUB_APP_CLIENT_ID` `GITHUB_APP_CLIENT_SECRET` | `github-repositories` |
+
+Register `https:///api/auth/oauth2/callback/github-repositories` as the callback. These App OAuth client credentials are separate from `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` used for Sim sign-in. Sim does not require an App private key.
+
+A repository or organization administrator installs the App on the repositories to search. Each member connects their own GitHub account, with a verified email matching their Sim account. Search indexes repository files that both the member and the installed App can access. GitHub workflow blocks and existing knowledge-base token connections continue to use personal access tokens.
+
### Everything else
| Service | Environment variables | Provider ID |
diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx
new file mode 100644
index 00000000000..1937bddd578
--- /dev/null
+++ b/apps/docs/content/docs/search/confluence.mdx
@@ -0,0 +1,197 @@
+---
+title: Confluence
+description: Connect Confluence Cloud spaces and set up each teammate's search access
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin configures the source, and each teammate connects their Confluence account.
+
+Search indexes each page's own text, including supported local callouts and code blocks. It does not expand Include Page, Excerpt Include, or third-party macros into that page. Referenced pages can be indexed separately with their own access rules.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Choose a connection method
+
+| Method | Who supplies the content? | What teammates do |
+| --- | --- | --- |
+| **Admin or service account** | One account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. |
+| **Member accounts** | Sim syncs content separately through connected members' accounts. | Connect their own Confluence account to establish which pages they can access. |
+
+Use **Admin or service account** when you have a dedicated account that can read the intended spaces and their permissions. Use **Member accounts** when each person should supply their own connection. Available methods depend on your organization's enabled features.
+
+**Everyone still connects in both methods.** With a central account, teammates supply their identity; they do not configure another central crawl or choose spaces again.
+
+## Before you start
+
+- Be a **Sim organization admin** to add the source.
+- Use a Confluence Cloud site such as `your-team.atlassian.net`. This connector does not connect to Server or Data Center.
+- Each teammate needs a verified Sim email matching their active Atlassian account's email.
+- For a central crawl, grant its account access to Confluence, the chosen spaces, and any restricted pages you want indexed. Admin status alone does not bypass page restrictions. It also needs permission to read space permissions and the user/group directory.
+
+On hosted Sim, personal connections authorize the existing Sim app. Teammates do not create OAuth apps or service-account tokens. Self-hosted deployments need the [shared OAuth configuration](#self-hosted-operator-setup) even when a service account supplies the content.
+
+## Set up the source
+
+
+
+
+### Choose Confluence
+
+Open **Settings → Integrations → Providers**, approve **Confluence**, then select **Set up**. Select your **Connection method**.
+
+
+
+
+### Select an account
+
+For **Admin or service account**, open **Account** and select an existing account, choose **Connect Confluence account** for OAuth, or add a service account using the [steps below](#using-a-service-account).
+
+For **Member accounts**, **Browse with** supplies an account for the space picker only. Select or connect an account, or switch **Spaces** to manual input to enter space keys without a browsing account. Browsing does not connect that account to Search or share its access with teammates.
+
+
+
+
+### Select the spaces
+
+Enter **Confluence Domain**, then choose one or more **Spaces**. The picker shows spaces accessible to the selected account. Use the switch beside the field to enter comma-separated **Space Keys**, such as `ENG, PRODUCT`.
+
+Keep **Content Type** at its default for pages, or choose blog posts or both. Leave **Filter by Label** empty unless you want a smaller scope. **Document details (optional)** contains metadata tag settings.
+
+
+
+
+
+
+### Save and connect your identity
+
+Click **Connect & Sync** for a central account, or **Add source** for member accounts. Back in Integrations, click **Connect account** on the Confluence row and finish the connection in the new tab. Sign in using the Atlassian email that matches your verified Sim email, and authorize the configured site.
+
+Each teammate completes this last step. A previously authorized account may already be connected. Return to Integrations to see indexing status and your searchable document count.
+
+
+
+
+## Using a service account
+
+Sim's Atlassian service account form accepts a **scoped API token** and **site domain**.
+
+
+
+
+### Give the service account Confluence access
+
+Have an Atlassian organization admin create a service account under **Directory → Service accounts** in [Atlassian Administration](https://admin.atlassian.com/). Give it Confluence access on the intended site. A space admin must also grant access to the chosen spaces and any restricted pages the source should index. See [Atlassian's service-account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/).
+
+
+
+
+### Choose API token authentication
+
+Select the service account, then **Create credentials → API token → Next**. This is the credential type accepted by Sim's service-account form.
+
+
+
+Atlassian Administration's credential selector. See the [current Atlassian instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/).
+
+
+
+
+### Select Confluence scopes
+
+Name the token and choose an expiry between 1 and 365 days. In the scope picker, choose **Confluence** and add the scopes below; the list includes both classic and granular scopes. Review and create the token, then copy it for the next step. Atlassian only reveals the token once.
+
+Use these scopes for Confluence Search content and permission reads:
+
+```text
+read:confluence-content.all
+read:page:confluence
+read:blogpost:confluence
+read:space:confluence
+read:label:confluence
+search:confluence
+read:confluence-space.summary
+read:content.metadata:confluence
+read:space.permission:confluence
+read:confluence-user
+read:user:confluence
+read:group:confluence
+```
+
+
+
+
+### Add the token to Sim
+
+In the Search setup's **Account** menu, choose the service-account option. Paste the **API token** and enter **Site domain**. Optionally add a display name and description, then click **Add service account**. Continue in the original source modal, using the same domain in both forms.
+
+
+
+
+Scopes do not grant access to spaces or pages by themselves. Keep the account's Confluence permissions and its token scopes aligned. When a token expires or needs different scopes, create a replacement in Atlassian. In Sim, open **Integrations**, select the saved service account, and click **Reconnect** to enter the new token and the same site domain.
+
+
+Personal OAuth uses Sim's shared Confluence integration and requests a broader set of permissions, including writes. Search reads content and permissions; it does not edit your Confluence pages. Older OAuth connections need to reconnect to grant the group-read permission used by central permission syncing.
+
+
+## Configuration
+
+| Setting | What it controls |
+| --- | --- |
+| **Confluence Domain** | The Cloud hostname, such as `your-team.atlassian.net`. Do not paste a page URL or `/wiki` path. |
+| **Spaces / Space Keys** | Required spaces to index. The picker and manual key input are two ways to set the same scope. |
+| **Content Type** | **Pages only** by default. **All content** means pages and blog posts; it does not include comments or attachment contents. |
+| **Filter by Label** | Optional comma-separated labels. Content can match any listed label. |
+| **Document details** | Optional labels, version, and last-modified metadata tags. |
+
+Search manages the schedule and hides item limits. Published/current content is indexed; archived and trashed content is excluded.
+
+## Teammates and ongoing sync
+
+Existing organization members see the configured Confluence source and their own **Connect account** or **Reconnect** action. Add new teammates through your Sim organization invitation or SSO onboarding, then have them connect Confluence from Integrations. Connecting a Confluence account does not add someone to the Sim organization.
+
+With a central account, Sim applies space access together with the page's restrictions and inherited ancestor restrictions. Group membership is refreshed in the background. With member accounts, each person's provider listing determines the pages available to them. A Sim organization admin does not automatically receive access to every Confluence document.
+
+New content and permission changes require a sync and processing before Search reflects them. Open **Manage** on the source to inspect errors, edit its configuration, or trigger a sync. If your own account needs authorization again, use **Reconnect** on the source row.
+
+## Troubleshooting
+
+| What you see | What to check |
+| --- | --- |
+| **Connect & Sync** is disabled | Select a central account, enter the domain, and choose at least one space. |
+| Space picker is empty | Connect an account, enter the correct domain, and verify its space access. You can also switch to manual space keys. |
+| Service-account validation fails | Check the token's expiry, site, Confluence app access, and scopes. Use a scoped API token from an Atlassian service account. |
+| Content syncs but central search returns nothing | Connect your personal Confluence identity. Ask the admin to check directory/permission sync errors and group-read scopes. |
+| A restricted page is missing | Ensure the crawling account can view that page and its ancestors, and that your own account has the required access. |
+| Included or embedded content is missing | Add the referenced page's space to the source if appropriate. Search indexes pages separately; remote macro output, comments, and attachment contents are excluded. |
+| **Reconnect** or an email mismatch | Reauthorize with the Atlassian account matching your verified Sim email and grant all requested permissions. |
+
+### Check access in Confluence
+
+Open a missing page in Confluence with the affected teammate's account. On the page, **Share → General access** shows whether access comes from the space, a parent, or an explicit restriction. A space admin can inspect restricted pages under **Space settings → Content → Restricted**. Check both the teammate and central crawling account when using **Admin or service account**. See Atlassian's [content access guide](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/).
+
+On Confluence Premium, **Inspect permissions** can show where a user's access is denied across the page, its ancestors, the space, and the product. Check **Can view**, resolve the relevant permission, then run a sync in Sim. See [Atlassian's permission inspection guide](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/).
+
+## Self-hosted operator setup
+
+Configure one shared Confluence OAuth integration for your deployment. This powers personal identity connections in both Search methods and the optional central OAuth account.
+
+1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create your deployment's **OAuth 2.0 integration**.
+2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` to **Callback URLs**, keep existing callbacks used by the deployment, and save.
+3. Under **Permissions**, add the Confluence API and configure the full `confluence` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The service-account read scopes above do not replace the broader shared OAuth scope set.
+4. Enable sharing under **Distribution**. Set `CONFLUENCE_CLIENT_ID` and `CONFLUENCE_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim.
+5. Start authorization from Search and select the configured site. Reconnect old accounts after adding scopes so the new permission grant takes effect.
+
+A callback mismatch needs a corrected callback URL; a connection that works only for the app owner needs sharing enabled. See Atlassian's [OAuth configuration guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth).
diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx
new file mode 100644
index 00000000000..aaadf503cad
--- /dev/null
+++ b/apps/docs/content/docs/search/connect-your-account.mdx
@@ -0,0 +1,72 @@
+---
+title: Connect your account
+description: Join your team's Search sources and finish connecting your own accounts
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Your admin approves the provider and can configure shared source filters. You connect your own account so Sim can establish what you are allowed to search.
+
+
+
+
+### Join your organization
+
+Accept your Sim organization invitation or sign in through your organization's SSO. Use a verified Sim email that matches your account at the source. Organization Search does not require workspace access.
+
+
+
+
+### Open Integrations
+
+Open **Integrations**, find the provider or source, and select **Connect account**. Your first connection may ask for required source settings, such as repository or project names. If the provider is missing, ask an organization admin to approve it.
+
+
+
+
+
+
+### Authorize your account
+
+In the new tab, select **Connect** and complete the provider's authorization. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval.
+
+Return to Integrations when the connection completes. If the popup was blocked or closed, allow popups and select **Connect account** again. While authorization is pending, use **Open again**.
+
+
+
+
+### Start searching
+
+The source row shows indexing status and how many documents are available to you. Open **Search** in the organization sidebar and search for something you can already open in the source. Use **Home** to ask the assistant about your connected documents. The first sync may take time, especially for large accounts.
+
+
+
+
+For a source configured inside a workspace, join that workspace and use its **Search** page instead. Organization and workspace sources are separate.
+
+## Do I always need to connect?
+
+| Source setup | Your next step |
+| --- | --- |
+| Member accounts | Connect your own account, including when you are the admin. |
+| Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. |
+| Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. |
+| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. |
+
+Connecting one Google service does not connect all of them. Gmail, Calendar, and Drive each have their own Search connection.
+
+## If you get stuck
+
+| Status | What to do |
+| --- | --- |
+| **Connect account** | Complete the connection in the new tab. |
+| **Reconnect** | Authorize the same source account again. |
+| **Finish connecting in the other tab** | Finish authorization, or use **Open again**. Allow popups for Sim. |
+| No results | Check the source's filters and sync status with your admin. Confirm you can open the document at the source. |
+| Needs admin attention | Ask your admin to inspect **Manage** for the source error. |
+
+
+ Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you.
+
diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx
new file mode 100644
index 00000000000..a6ca8d92fa2
--- /dev/null
+++ b/apps/docs/content/docs/search/github.mdx
@@ -0,0 +1,153 @@
+---
+title: GitHub
+description: Search repository files through each member's GitHub account
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+GitHub Search indexes text files from a repository on `github.com`. An organization admin chooses the repository, then each person connects their GitHub account. Installing the GitHub App alone does not connect your teammates.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Before you start
+
+Your Sim deployment needs a GitHub App configured as described below. The repository must contain at least one commit; initialize an empty repository with a README before adding it. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary.
+
+## Configure the GitHub App
+
+This step belongs to the Sim deployment administrator. If the App is already configured, continue to [Add a repository](#add-a-repository).
+
+
+
+
+### Register the App
+
+For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**.
+
+Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL.
+
+Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter:
+
+```text
+https:///api/auth/oauth2/callback/github-repositories
+```
+
+| GitHub setting | Value for Sim Search |
+|---|---|
+| Allow wildcard matching | Disabled |
+| Expire user authorization tokens | Enabled |
+| Request user authorization (OAuth) during installation | Disabled |
+| Enable Device Flow | Disabled |
+| Post installation → Setup URL | Empty |
+| Webhook → Active | Disabled |
+
+Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook.
+
+
+
+*Example registration. Replace `sim.example.com` with your Sim domain.*
+
+
+
+
+### Set read permissions
+
+Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**.
+
+
+
+Expand **Account permissions** and set **Email addresses → Access: Read-only**.
+
+
+
+| Permission area | Permission | Access |
+|---|---|---|
+| Repository | Contents | Read-only |
+| Repository | Metadata | Read-only |
+| Account | Email addresses | Read-only |
+
+Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings.
+
+GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app).
+
+Under **Where can this GitHub App be installed?**, choose **Only on this account** for an organization-owned App used only by members of that organization. Choose **Any account** when teammates or repository owners are outside that organization, or the App is owned by your personal account. A private App owned by a personal account can only be authorized by its owner; see GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private).
+
+Select **Create GitHub App**.
+
+
+
+
+### Configure Sim and install the App
+
+On the App's **General** settings page, copy its **Client ID**, then select **Generate a new client secret**. Configure these deployment variables and restart Sim:
+
+```text
+GITHUB_APP_CLIENT_ID=
+GITHUB_APP_CLIENT_SECRET=
+```
+
+Use the **Client ID** and **client secret** from **Developer settings → GitHub Apps**. OAuth App credentials used for GitHub sign-in are not compatible. The numeric **App ID** and downloaded private key are not used by this connector. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens).
+
+In the App's sidebar, choose **Install App**, select the target account, and grant access to the repositories you want to search. Return to Sim and select **Connect account**. Every teammate must authorize from Sim too.
+
+
+
+
+## Add a repository
+
+
+
+
+### Open GitHub setup
+
+As a Sim organization admin, open **Settings → Integrations → Providers**, approve **GitHub**, then select **Set up**.
+
+
+
+
+### Choose what to index
+
+Enter the repository and keep **Sync documents with → Connected members** for the usual setup.
+
+| Field | What to enter |
+|---|---|
+| Repository | `owner/repo`. Add another source for another repository. |
+| Branch | Optional. Leave blank to follow the repository's default branch. |
+| Path Filter | Optional prefix such as `docs/`. |
+| File Extensions | Optional comma-separated list, such as `.md, .txt, .mdx`. |
+
+**Document details** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source.
+
+You can instead select an existing account under **Sync documents with** to supply file contents centrally. Teammates still connect their own accounts to establish which files they may find.
+
+
+
+
+### Connect your account
+
+On the GitHub source row, select **Connect account** and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app).
+
+With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Admins can open **Manage** on the source to inspect sync progress and errors.
+
+
+
+
+## Troubleshooting
+
+| Problem | Next step |
+|---|---|
+| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. |
+| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). |
+| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. |
+| Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. |
+| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. |
+| Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. |
+| Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. |
+| Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. |
+| Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. |
+
+
+ GitHub Search covers repository text files up to 100 MB, including symbolic links to files within the same repository. Path and extension filters apply to the link's path. Broken or external links, binaries, and submodules are not indexed. Issues, pull requests, separate wikis, GitHub Enterprise Server, and `ghe.com` domains are not supported by this connector. Personal access tokens remain available for general knowledge-base connectors, with that knowledge base's access rules.
+
diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx
new file mode 100644
index 00000000000..e8aeaa52d1a
--- /dev/null
+++ b/apps/docs/content/docs/search/gitlab.mdx
@@ -0,0 +1,94 @@
+---
+title: GitLab
+description: Index a self-managed GitLab project with its source permissions
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed GitLab email.
+
+
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates do not need a personal GitLab connection. For workspace Search, use **Search → Add source** instead.
+
+## Before you start
+
+Use a self-managed GitLab instance running version **17.4 or later**. Setup needs both a Sim organization admin and an active GitLab **instance administrator**. A project Maintainer or group Owner is insufficient.
+
+
+ This Search path requires GitLab's administrator directory and settings APIs. GitLab.com projects do not support this setup. General knowledge-base GitLab connectors can still use project-readable tokens, but their knowledge-base access rules are different from Search's source permissions.
+
+
+## Add a project
+
+
+
+
+### Create the administrator token
+
+Sign in to your self-managed GitLab instance as an instance administrator, then open **Avatar → Edit profile → Access → Personal access tokens**. On current releases choose **Generate token → Legacy token**; older versions show **Add new token** or the token form directly.
+
+
+
+*Official [GitLab Handbook example](https://handbook.gitlab.com/handbook/security/product-security/security-platforms-architecture/product-security-engineering/runbooks/rotate-service-account-personal-access-tokens/). Navigation and button labels vary by version. The example's existing `api` tokens are unrelated to Sim; use the scopes below.*
+
+| Token setting | Value for Sim Search |
+|---|---|
+| Token name | A recognizable name, such as `Sim Search` |
+| Expiration date | A date allowed by your instance's token policy |
+| Scopes | `read_api`; also `admin_mode` if Admin Mode is enabled |
+
+Select **Generate token** or **Create personal access token**, then copy the value into Sim. GitLab only shows it once. This connector uses the traditional scoped PAT flow; do not substitute a project/group token or assume a fine-grained token has the required administrator API permissions. See GitLab's [current token creation steps](https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token).
+
+The token must read the project, users, inherited project membership, instance settings, and related group settings. Sim checks these before accepting source permission mirroring. See GitLab's [token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/).
+
+
+
+
+### Configure the source in Sim
+
+Open **Settings → Integrations → Providers**, approve **GitLab**, then select **Set up**. Paste the token and enter your instance host explicitly.
+
+| Field | What to enter |
+|---|---|
+| Host | Your self-managed domain, such as `gitlab.example.com`. |
+| Project | `group/project` or the numeric project ID. Add another source for another project. |
+| Content | Defaults to **Wiki & Issues**. Choose **Code, Wiki, Issues & Merge Requests** to include all supported types. |
+| Branch | Optional branch or tag for repository files; blank uses the project's default branch. |
+| Path Filter / File Extensions | Optional limits for repository files. |
+| Issue State / Labels / Milestone | Optional filters for issues. |
+| Max Items | Optional positive limit. Leave blank for all matching items. |
+
+
+
+Select **Connect & Sync**. Sim validates the token and source policy, then starts indexing.
+
+
+
+
+### Let teammates search
+
+Invite teammates to the Sim organization using their verified work email. Sim matches that email against the GitLab directory and applies project, feature, and confidential-issue permissions. No GitLab **Connect account** step is required.
+
+Admins can open **Manage** on the source to review sync progress. Permission and membership changes are picked up during background refreshes.
+
+
+
+
+## What is indexed
+
+The connector supports text repository files, wiki pages, issues, merge requests, and non-internal issue and merge-request comments. It does not index internal comments, binaries, or epics. **Document details** controls optional result metadata.
+
+## Troubleshooting
+
+| Problem | Next step |
+|---|---|
+| Administrator token required | Use an active instance administrator's PAT with `read_api`, plus `admin_mode` when required. A project or group token cannot replace it. |
+| Source permissions cannot be mirrored | Read the reported policy. Sim rejects unsupported external authorization, IP restrictions, download-ban policies, or session-specific step-up requirements. |
+| Project not found | Check the host, project path or ID, and token access. |
+| A teammate sees no results | Confirm both accounts' verified/confirmed email addresses match and the user has the required GitLab project or feature access. |
+| Token expired | Remove and add the source again with a new token. This connector does not support replacing its token in place or refreshing PATs automatically. |
+
+Custom GitLab roles may grant more access than Sim's conservative role mapping recognizes. A source requiring unsupported policies must remain unavailable until its access model can be represented accurately.
diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx
new file mode 100644
index 00000000000..107422340b6
--- /dev/null
+++ b/apps/docs/content/docs/search/gmail.mdx
@@ -0,0 +1,113 @@
+---
+title: Gmail
+description: Connect each teammate's Gmail account to search their email in Sim
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search email threads from your own Gmail account. An organization admin enables the source; each teammate connects their own account. An admin's connection does not make their mailbox available to the team.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Set up the source
+
+These steps require a Sim organization admin.
+
+
+
+
+### Add Gmail
+
+Open **Settings → Integrations → Providers**, approve **Gmail**, then select **Set up**. Gmail uses **Member accounts**; there is no domain-wide or service-account crawl in Search.
+
+
+
+
+### Choose what to include
+
+Keep the defaults to search all dates and labels, excluding Promotions, Social, Spam, and Trash. Add filters below if your team needs a narrower source.
+
+
+
+
+### Create the source
+
+Click **Add source**. Gmail appears in the **Sources** list. Each person, including the admin, then connects their own account.
+
+
+
+
+
+
+## Connect your account
+
+1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect account** beside Gmail.
+2. Complete the connection in the tab that opens. Choose the Google account whose verified email matches your Sim email, and grant the requested permissions.
+3. Return to Integrations. The source shows its indexing status and the number of documents you can search.
+
+Teammates follow these same steps after joining the organization. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand.
+
+## Source options
+
+An admin can change these under **Manage** on the Gmail source. Filters apply separately to each connected mailbox.
+
+| Option | Behavior |
+| --- | --- |
+| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member setup. |
+| Date Range | All time by default. Choose the last 7, 30, or 90 days, 6 months, or year. |
+| Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. |
+| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. |
+
+**Document details** contains optional metadata tags. Sync frequency and the general knowledge-base **Max Threads** setting are hidden in Search.
+
+## What gets indexed
+
+Sim indexes the message text Gmail returns for each matching thread, plus subjects, senders, dates, and labels. Filters select threads; messages within a selected thread are not filtered again. HTML email is converted to text. Results link back to Gmail.
+
+File attachments and image contents are not indexed. Thread discovery uses Gmail's default exclusion of Spam and Trash. A filter such as `has:attachment` selects the email thread; it does not index the attachment. Gmail API filtering also differs from Gmail's interface for aliases and thread-wide searches. See Google's [thread listing reference](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads/list) and [filtering guide](https://developers.google.com/workspace/gmail/api/guides/filtering).
+
+Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search.
+
+## Troubleshooting
+
+| What you see | What to do |
+| --- | --- |
+| A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. |
+| No searchable documents | Check the source's labels, date range, category exclusions, and search filter. Allow the first sync to finish. |
+| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect account** again. |
+| Reconnect | Click **Reconnect** and authorize the same account again. |
+| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. |
+
+## Self-hosted operator setup
+
+Users do not need to create Google Cloud credentials. The deployment operator configures one Google OAuth client for the instance:
+
+1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it.
+2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience.
+3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application).
+4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth).
+
+```text
+https:///api/auth/oauth2/callback/google-email
+```
+
+
+
+This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable.
+
+The current Sim Gmail connection uses these scopes:
+
+```text
+openid
+https://www.googleapis.com/auth/userinfo.email
+https://www.googleapis.com/auth/userinfo.profile
+https://www.googleapis.com/auth/gmail.modify
+https://www.googleapis.com/auth/gmail.send
+https://www.googleapis.com/auth/gmail.labels
+```
+
+
+ Google's `gmail.readonly` scope is sufficient for Search's email reads. Sim currently shares its Gmail OAuth connection with workflow actions and requires the broader scope set above; do not substitute `gmail.readonly` in this setup. Search does not send or modify email. See [Google's scope descriptions](https://developers.google.com/workspace/gmail/api/auth/scopes).
+
diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx
new file mode 100644
index 00000000000..4509563522f
--- /dev/null
+++ b/apps/docs/content/docs/search/google-calendar.mdx
@@ -0,0 +1,118 @@
+---
+title: Google Calendar
+description: Search calendar events using each teammate's own Google access
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search meetings and event details available to your Google account. An organization admin enables the source; every teammate connects their own account. Google controls which calendar and event details each person can read.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Set up the source
+
+These steps require a Sim organization admin.
+
+
+
+
+### Add Google Calendar
+
+Open **Settings → Integrations → Providers**, approve **Google Calendar**, then select **Set up**. Search uses **Member accounts**; an admin or service account cannot connect on behalf of everyone.
+
+
+
+
+### Choose the calendars
+
+Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select them using **Browse with**, or switch to **Calendar IDs** and enter their IDs.
+
+**Browse with** only helps you choose calendars. It does not connect your account for Search or grant teammates access.
+
+
+
+
+### Create the source
+
+Keep the default date range for the previous and next 30 days, then click **Add source**. Each person, including the admin, connects their own account from the **Sources** list.
+
+
+
+
+
+
+
+ `primary` means the connected person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every member, and only members with access to it can search its events.
+
+
+## Connect your account
+
+1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect account** beside Google Calendar.
+2. In the connection tab, choose the Google account whose verified email matches your Sim email. Grant the requested permissions.
+3. Return to Integrations to see indexing status and your searchable document count.
+
+Teammates repeat only these connection steps after joining the organization. They do not need to configure the source. Connecting Gmail or Google Drive does not replace the Calendar connection.
+
+## Source options
+
+An admin can change these under **Manage** on the source.
+
+| Option | Behavior |
+| --- | --- |
+| Calendars / Calendar IDs | Empty defaults to each member's `primary` calendar. Explicit IDs restrict the source to those calendars. Multiple IDs are comma-separated; combine `primary` with shared calendar IDs if needed. |
+| Date Range | Previous and next 30 days by default. Alternatives are the previous 30 days, next 30 days, or 90 days in each direction. The window moves forward on later syncs. |
+| Search Query | Optional text filter applied by Google to event titles, descriptions, locations, and organizer or attendee names and emails. Leave empty to include all matching events in the date range. |
+| Include Attendees | **Yes** by default. **No** omits organizer and attendee identity fields and keeps the attendee count. It does not redact names written into titles or descriptions. |
+
+**Document details** contains optional metadata tags. Search hides sync frequency and the general knowledge-base **Max Events** setting.
+
+## What gets indexed
+
+Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Results link back to Google Calendar.
+
+Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing).
+
+Search schedules syncs hourly. Event edits, cancellations, access changes, and events moving outside the date window are reconciled during background sync. The first sync may take longer, and results appear as indexing progresses.
+
+## Troubleshooting
+
+| What you see | What to do |
+| --- | --- |
+| No events | Check the date range, search query, and calendar IDs. Use an empty calendar selection or `primary` for each person's own calendar. |
+| A shared calendar is missing | Confirm the connected Google account can read its events. Selecting a calendar in Sim does not share it in Google. |
+| Busy times without event details | Google may expose only availability or hide private details. Ask the calendar owner to review sharing if more access is appropriate. |
+| A different email is requested | Choose the Google account matching your verified Sim email. |
+| Reconnect | Click **Reconnect** and complete Google authorization again. Allow pop-ups if the connection tab does not open. |
+| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. |
+
+## Self-hosted operator setup
+
+The deployment operator configures Google OAuth once; teammates then use the normal connection flow.
+
+1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Google Calendar API**, and enable it.
+2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users. Add test users while an external app is testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent).
+3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add the URI below under **Authorized redirect URIs**. Add it to the existing Google client if the instance already uses one. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application).
+4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth).
+
+```text
+https:///api/auth/oauth2/callback/google-calendar
+```
+
+
+
+This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable.
+
+The current Sim Calendar connection uses these scopes:
+
+```text
+openid
+https://www.googleapis.com/auth/userinfo.email
+https://www.googleapis.com/auth/userinfo.profile
+https://www.googleapis.com/auth/calendar
+```
+
+
+ Search's reads can use `calendar.events.readonly`, `calendar.calendarlist.readonly`, and `calendar.calendars.readonly` for events, the calendar list, and calendar details. Sim currently shares its Calendar OAuth connection with workflow actions and requires the broader `calendar` scope above. Do not replace it with read-only scopes in this setup. Search does not change calendars or events. See [Google's scope descriptions](https://developers.google.com/workspace/calendar/api/auth).
+
diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx
new file mode 100644
index 00000000000..0918de1e56c
--- /dev/null
+++ b/apps/docs/content/docs/search/google-drive.mdx
@@ -0,0 +1,165 @@
+---
+title: Google Drive
+description: Connect Drive files through member accounts or a delegated service account
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search Google Docs, Sheets, Slides, and supported files in Drive. A Sim organization admin chooses the folders and connection method once.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Choose your setup
+
+| Method | Use it when | What teammates do |
+| --- | --- | --- |
+| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts after the source is created. |
+| **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Sign in to Sim with matching verified email addresses; no personal Drive connection is needed for this source. |
+
+
+ A central crawl indexes only files the configured **Crawl as** account can access. Domain-wide delegation does not make this connector crawl every employee's Drive. Share the intended content with the indexing account, or use member accounts for each person's accessible files.
+
+
+## Set up member accounts
+
+
+
+
+### Add Google Drive
+
+Open **Settings → Integrations → Providers**, approve **Google Drive**, then select **Set up** and choose **Member accounts**.
+
+
+
+
+### Choose the files
+
+Leave **Folders** empty to include supported files each connected member can access, or select folders to narrow the source. **Browse with** helps you pick folders; you can also switch to **Folder IDs** and enter comma-separated IDs from their Drive URLs.
+
+Keep **Sync documents with → Connected members** unless you have a dedicated indexing account. Selecting an indexing account does not replace each person's access verification. If that indexing account is a delegated service account, use **Crawl as** to choose the Google Workspace user whose files it should fetch.
+
+
+
+
+### Create and connect
+
+Select **Add source**, then **Connect account** on the source row. Use the Google account matching your verified Sim email. Teammates follow the same [connection steps](/search/connect-your-account) after joining the organization.
+
+
+
+
+## Set up a central service account
+
+This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path.
+
+
+
+
+
+
+### Prepare the service account
+
+In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Google Drive API** and **Admin SDK API** under **APIs & Services → Library**. Then open **IAM & Admin → Service Accounts → Create service account**, enter a name, and finish creation. Google Cloud project roles do not grant access to Workspace files; they are not required for this crawl.
+
+
+
+Open the service account's **Keys** tab and choose **Add key → Create new key → JSON**, then select **Create** to download the key. Store it securely; you will add it to Sim next. See [Google's key creation guide](https://docs.cloud.google.com/iam/docs/keys-create-delete#creating).
+
+
+
+
+
+
+### Authorize domain-wide delegation
+
+In the service account's **Details**, expand **Advanced settings** and copy its numeric **Client ID**. Sign in to the [Workspace Admin Console](https://admin.google.com/ac/owl/domainwidedelegation) as a super administrator. Open **Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new**.
+
+
+
+Paste that Client ID into **Client ID**, then enter these exact scopes as a comma-separated list under **OAuth scopes**:
+
+```text
+https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly
+```
+
+Select **Authorize**, then **View details** to confirm all three scopes were saved. If your organization requires multi-party approval, another super administrator must approve the request. Delegation changes can take up to 24 hours to propagate. See Google's [Admin Console delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation).
+
+These are Search's central crawl scopes. The general [Google service account guide](/integrations/google-service-account) includes broader scopes for workflow actions; do not copy those into this Search setup.
+
+
+
+
+### Add the credential in Sim
+
+In Google Drive's Search setup, choose **Service account**. Open the **Service account** picker, choose its connection action, and paste the JSON key into **Add Google Service Account**. Give it a name and add it. Sim returns you to the source form with that credential selected.
+
+
+
+
+
+
+### Choose the indexing identity
+
+Set **Crawl as** to a Google Workspace administrator who can read groups, memberships, and domains, and can access the content you want indexed. Select folders if needed, then choose **Connect & Sync**. Sim validates Drive and Directory access before accepting the source.
+
+
+
+
+## Source options
+
+| Option | Behavior |
+| --- | --- |
+| Folders / Folder IDs | Optional. Includes files in each selected folder and its accessible subfolders. A folder selection does not grant access. |
+| File Type | All supported files by default, or only Google Docs, Sheets, Slides, or text formats. **Plain text files only** also includes CSV, HTML, Markdown, JSON, and XML. |
+| Crawl as | Required for the central service account. In Member accounts, it optionally supplies the impersonated user when a dedicated service account fetches content. It has no effect on ordinary OAuth accounts. |
+| Openly shared files | Applies only to central crawls; it has no effect in Member accounts. **Keep out of search** by default. You can include discoverable domain shares or discoverable public shares. Link-only sharing does not grant Search access; named user and group permissions still apply. |
+| Document details | Optional owner, file type, modification date, and starred metadata. |
+
+Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads).
+
+Search schedules syncs hourly. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. Admins can inspect progress and errors through **Manage** on the source.
+
+## Troubleshooting
+
+| Problem | Next step |
+| --- | --- |
+| Directory access failed | Check the delegated scopes and the **Crawl as** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. |
+| An existing central source uses a normal Google OAuth account | Replace it with a delegated service account. If the source is **Disabled**, choose **Resume** first. Then open **Manage**, select or add the delegated service account, and choose **Change indexing account**. A **Paused** source can change credentials before you resume it. |
+| Missing files in a central crawl | Open them as the **Crawl as** user. Delegation does not grant that user access to all domain files. Check folder and file-type filters. |
+| A teammate sees no results | Confirm their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. |
+| A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. |
+| Reconnect or credential error | Reauthorize the member account, or replace the service-account credential and verify delegation, as applicable. |
+
+## Self-hosted OAuth configuration
+
+The deployment operator configures Google OAuth for **Member accounts** and **Browse with**. This is separate from the central service account above.
+
+1. In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Google Drive API** under **APIs & Services → Library**.
+2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users, adding test users while testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent guidance](https://developers.google.com/workspace/guides/configure-oauth-consent).
+3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add this URI under **Authorized redirect URIs**. Add it to the existing Google client if your instance already uses one.
+
+```text
+https:///api/auth/oauth2/callback/google-drive
+```
+
+
+
+This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable.
+
+Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth).
+
+The current Sim Drive OAuth connection uses these scopes:
+
+```text
+openid
+https://www.googleapis.com/auth/userinfo.email
+https://www.googleapis.com/auth/userinfo.profile
+https://www.googleapis.com/auth/drive
+https://www.googleapis.com/auth/drive.file
+```
+
+
+ Google's `drive.readonly` scope covers Search's file reads. Sim's existing OAuth connection also supports workflow actions and requires the broader scopes above; do not substitute read-only scopes for member OAuth. The central service account uses the separate read-only Drive and Directory scopes listed earlier. See [Google's Drive scope descriptions](https://developers.google.com/workspace/drive/api/guides/api-specific-auth).
+
diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx
new file mode 100644
index 00000000000..a03d0366433
--- /dev/null
+++ b/apps/docs/content/docs/search/index.mdx
@@ -0,0 +1,100 @@
+---
+title: Search
+description: Connect your team's sources and search the documents each person can access
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search brings your connected sources into one place. An organization admin adds a source and chooses what to include. Teammates then [connect their accounts](/search/connect-your-account) when the source requires it. Each person searches with their own access.
+
+## Add your first source
+
+
+
+
+### Choose a source
+
+As an organization admin, open **Settings → Integrations → Providers**. Approve the provider for Sim Search, then select **Set up** beside it. Use its **Setup guide** for the provider's prerequisites.
+
+Approval lets members connect; it does not connect an account or start indexing. Slack also requires an admin to configure the organization's Slack app first.
+
+
+
+
+### Configure it once
+
+Choose the folders, repositories, calendars, spaces, or channels to include. Start with the defaults unless you need to narrow the scope. **Document details** contains optional metadata.
+
+Select **Connect & Sync** for an administrator connection, or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts.
+
+
+
+
+### Connect and search
+
+In **Integrations**, select **Connect account** if prompted—even if you created the source. Complete authorization in the new tab, then return to the source list. Open **Search** in the organization sidebar to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses.
+
+
+
+
+
+
+Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue.
+
+## Choose the right connection method
+
+Most sources use member accounts. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance.
+
+| Method | What the admin does | What teammates do |
+| --- | --- | --- |
+| **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. |
+| **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. |
+| **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. |
+
+Some member sources offer **Sync documents with**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. **Browse with** only helps an admin pick source options—it does not enroll that account for Search.
+
+
+ An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. It also does not automatically discover every employee's data: the indexing account must be able to read the configured content.
+
+
+## Connector guides
+
+| Source | Content | Connection in Search |
+| --- | --- | --- |
+| [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects |
+| [GitHub](/search/github) | Repository text files | GitHub App installation plus each member's authorization |
+| [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection |
+| [Gmail](/search/gmail) | Email thread text | Each member's Gmail account |
+| [Google Calendar](/search/google-calendar) | Calendar events | Each member's Google Calendar account |
+| [Google Drive](/search/google-drive) | Supported Drive files | Delegated service account or member accounts |
+| [Jira](/search/jira) | Issues | Each member's Jira account |
+| [Slack](/search/slack) | Channel messages and threads | Slack app installation plus each member's authorization |
+
+## Bring your team
+
+Invite people through the organization's **Settings → Members**, or use your organization's [SSO provisioning](/platform/enterprise/sso). Share the organization's **Home** or **Integrations** URL. People need their own Sim account and organization membership; they do not need access to a workspace. Connecting an external account alone does not grant organization membership.
+
+Organization admins manage source configuration and sync status through **Manage** under **Settings → Integrations → Providers**. Other members connect or reconnect their own accounts. See [Connect your account](/search/connect-your-account) for the teammate walkthrough.
+
+## Search, Assistant, and MCP
+
+**Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin.
+
+To search from an MCP-compatible app, open **Settings → Search MCP**. Generate a personal Sim API key there, or use an existing personal key with the displayed connection details. MCP applies your current organization membership and document access.
+
+## Existing workspace Search
+
+Workspace Search remains separate. Workspace admins add sources through **Search → Add source**; the member-account action is **Create & Invite**. Teammates need workspace access and connect from its source list. Organization Search does not automatically include workspace sources or grant access to workspace content.
+
+## Check that it works
+
+1. Let the first sync finish, then search for a distinctive phrase in a document you can open in the source.
+2. Open the result's source link and confirm the document is the expected one.
+3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them.
+4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh.
+
+Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. Admins can inspect errors and progress under **Manage**.
+
+These guides cover permission-aware Search sources. For a general knowledge base used by workflows, see [Knowledge-base connectors](/knowledgebase/connectors); its workspace access settings are a separate choice.
diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx
new file mode 100644
index 00000000000..0c16e6ecabe
--- /dev/null
+++ b/apps/docs/content/docs/search/jira.mdx
@@ -0,0 +1,141 @@
+---
+title: Jira
+description: Connect Jira Cloud projects to Search using each teammate's account
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Search issue titles, descriptions, and metadata from selected Jira Cloud projects. An organization admin sets up the source; each teammate connects their own Jira account to search the issues they can access.
+
+This Search connector uses **Member accounts**. It does not offer a central admin crawl. Comments, attachment contents, dashboards, and saved filters are not indexed.
+
+Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Before you start
+
+- A **Sim organization admin** must approve Jira. An admin can configure the source beforehand, or the first member connection can supply the required site and project settings.
+- Use an Atlassian Cloud site such as `your-team.atlassian.net`. Jira Server and Data Center are not supported by this connector.
+- Each person needs a verified Sim email matching the email on their active Atlassian account, plus access to the selected Jira site and projects. Jira's **Browse Projects** and issue security permissions still determine which issues they can search.
+
+On hosted Sim, teammates authorize the existing Sim app. They do not create an Atlassian app or API token. Deployment owners running their own Sim instance configure the [shared OAuth app](#self-hosted-operator-setup) once.
+
+
+Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to read issues, `read:me` to identify the connected person, and `offline_access` to refresh the connection. The authorization screen also includes permissions for other Jira features, including writes. Review the requested permissions before authorizing.
+
+
+## Set up the source
+
+
+
+
+### Choose Jira
+
+Open **Settings → Integrations → Providers**, approve **Jira**, then select **Set up**. The connection method is **Member accounts**.
+
+
+
+
+### Choose the projects
+
+Under **Browse with**, select an account or choose **Connect Jira account** and complete Atlassian authorization. Enter **Jira Domain**, then choose one or more **Projects**.
+
+If you already know the project keys, use the switch beside **Projects** to select manual input and enter keys such as `ENG, SUPPORT`. Manual input lets you configure the source without connecting a browsing account first.
+
+**Browse with** only populates the project picker. It does not enroll you or share that account's issue access with teammates.
+
+
+
+
+
+
+### Create the source
+
+Leave **JQL Filter** empty to include all accessible issues in the selected projects, or add a condition such as `status = "Done"`. Open **Document details (optional)** only if you want to change metadata tags.
+
+Click **Add source**. The source appears in the shared source list, and Sim starts preparing member connections in the background.
+
+
+
+
+### Connect your search account
+
+On the new Jira row, click **Connect account**. Complete the connection in the new tab using the Atlassian email that matches your verified Sim email. Select the configured Atlassian site when asked and grant the requested permissions.
+
+Return to Integrations to see connection and indexing status. Each teammate follows this same step. A previously authorized account may already be connected.
+
+
+
+
+## Configuration
+
+| Setting | What to enter |
+| --- | --- |
+| **Jira Domain** | The Cloud site hostname, such as `your-team.atlassian.net`. Use the same site during authorization. |
+| **Projects / Project Keys** | One or more projects. The picker shows projects available to the browsing account; manual input accepts comma-separated keys. |
+| **JQL Filter** | Optional conditions that narrow the selected projects. Leave out `ORDER BY`; Sim supplies the sorting. |
+| **Document details** | Optional issue type, status, priority, labels, assignee, and last-updated tags. |
+
+Search manages the sync schedule. Item limits and sync frequency are not setup decisions on this page.
+
+## Teammates and ongoing sync
+
+Existing organization members see the same source configuration and their own **Connect account**, **Reconnect**, or indexing status. They do not choose projects again. Invite new teammates to the Sim organization through its Members settings or SSO onboarding, then have them open Integrations and connect Jira. A Jira authorization does not grant Sim organization membership.
+
+Sim checks Jira separately using each connected person's account. Issue content and tags become searchable as processing finishes; changes and lost issue access are picked up by later syncs. The source row reports the number of documents searchable by the current viewer. Admins can open **Manage** to review sync status or update the source.
+
+## Troubleshooting
+
+| What you see | What to do |
+| --- | --- |
+| No provider setup controls | Ask a Sim organization admin to approve and set up Jira. |
+| Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. |
+| Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. |
+| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. |
+| Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. |
+| **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. |
+| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. |
+
+### Check access in Jira
+
+First, open a missing issue in Jira using the same account you connected to Sim. If you cannot open it there, ask a Jira admin to check its project permissions and issue security.
+
+For company-managed projects, an admin can open **Settings → System → Admin Helper → Permission Helper**, enter the affected user and issue key, and check **Browse Projects**. The result explains which permission condition failed. Fix access in Jira, then let the next Sim sync finish. See Atlassian's [Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/) and [illustrated permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials).
+
+
+
+Atlassian illustration from its [permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). UI labels may vary by Jira version.
+
+## Self-hosted operator setup
+
+The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect account** from Sim.
+
+1. Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/) and select your deployment's **OAuth 2.0 integration**, or create one for the deployment.
+2. Under **Authorization**, configure **OAuth 2.0 (3LO)**. Add `https:///api/auth/oauth2/callback/jira` to **Callback URLs**, keeping any callbacks already used by your deployment, then save.
+
+
+
+ Example callback in Atlassian's developer console. Replace `sim.example.com` with your Sim domain.
+
+3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions.
+4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim.
+5. Start a connection from Search. Confirm that Atlassian lists the intended site, then return to Sim. After changing requested scopes, reconnect previously authorized accounts.
+
+For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from Search. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth).
diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json
new file mode 100644
index 00000000000..8572aa0c75d
--- /dev/null
+++ b/apps/docs/content/docs/search/meta.json
@@ -0,0 +1,15 @@
+{
+ "title": "Search",
+ "pages": [
+ "index",
+ "connect-your-account",
+ "confluence",
+ "github",
+ "gitlab",
+ "gmail",
+ "google-calendar",
+ "google-drive",
+ "jira",
+ "slack"
+ ]
+}
diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx
new file mode 100644
index 00000000000..61ce37a9c81
--- /dev/null
+++ b/apps/docs/content/docs/search/slack.mdx
@@ -0,0 +1,116 @@
+---
+title: Slack
+description: Set up a workspace Slack app and connect members for channel search
+---
+
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+Slack Search indexes channel messages and threads. A Sim organization admin configures your Slack app once, then each teammate authorizes their own Slack account. Their results are limited to the selected public channels and private channels they can access. DMs and group DMs are not indexed.
+
+
+
+## Before you start
+
+You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account.
+
+This guide covers **indexing Slack messages for Search and MCP**. It does not install a Sim assistant that answers inside Slack.
+
+## Set up the organization's Slack app
+
+Skip to **Connect the source** if the organization already has a verified Slack app for member accounts.
+
+
+
+
+### Open provider settings
+
+Open **Settings → Integrations → Providers**. Approve **Slack**, select **Set up**, then **Set up Slack**. Approval alone does not configure the app or start indexing.
+
+
+
+
+### Configure an app in Slack
+
+On the [Slack Apps page](https://api.slack.com/apps), create an app for the target workspace, or use an app dedicated to your Sim organization. Under **OAuth & Permissions**, add the user scopes and both redirect URLs listed below. Keep **Token Rotation** disabled.
+
+
+
+*Official Slack example: [Basic Information](https://docs.slack.dev/tools/bolt-python/creating-an-app/#create-a-new-app). Use your own app's credentials.*
+
+In Sim, enter these four fields:
+
+| Sim field | Where to find it |
+|---|---|
+| Slack App ID | Slack app **Basic Information → App Credentials** (`A…`). |
+| Slack workspace ID | The workspace segment of the Slack web URL, `app.slack.com/client/T…/…`. |
+| Client ID | The same app's **Basic Information → App Credentials**. |
+| Client Secret | The same app's **Basic Information → App Credentials**. |
+
+Organization setup uses personal user authorization. It does not ask for a bot token or signing secret.
+
+
+
+
+### Verify and continue
+
+Select **Verify and add**, then authorize the app in the Slack popup. Sim verifies the app, workspace, client credentials, and required scopes. Allow popups if the window does not open.
+
+After verification, Sim returns to the source form. If Slack requires administrator approval, complete that approval before continuing.
+
+
+
+
+## Connect the source
+
+Keep **Sync documents with → Connected members** for the usual setup. Configure only the limits you need:
+
+| Field | Behavior |
+|---|---|
+| Channels | Leave blank for all accessible public and private channels, or choose channel names/IDs. |
+| Excluded Channels | Names or IDs to omit; exclusions override included channels. |
+| Archived Channels | Included by default. |
+| Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. |
+
+Select **Add source**. On the source row, each person selects **Connect account** and approves the configured Slack app. Creating the source or verifying the Slack app does not authorize teammates automatically.
+
+You can instead select an existing account under **Sync documents with** to supply message content centrally. Members still connect their own accounts to establish access. The selected account must itself be able to read the selected channels.
+
+Admins can use **Manage** to inspect sync progress. Search reads the indexed content, so source changes appear after background syncing. Slack retention and API limits determine how much history is available.
+
+## Permissions reference
+
+The organization account pool supports Search and workspace workflow tools. Its current authorization requests the following **User Token Scopes**, including write permissions. Search itself only indexes channel messages and threads; workspace use is controlled separately in the organization account settings.
+
+| Purpose | User scopes |
+|---|---|
+| Public channels | `channels:read`, `channels:history`, `channels:write` |
+| Private channels | `groups:read`, `groups:history`, `groups:write` |
+| Messages and conversations | `chat:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write` |
+| Files and canvases | `files:read`, `files:write`, `canvases:read`, `canvases:write` |
+| Reactions | `reactions:read`, `reactions:write` |
+| Identity and profile | `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write` |
+
+Add both redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin:
+
+```text
+https:///api/credential-groups/slack-managed-users/callback
+https:///api/credential-groups/oauth/slack/callback
+```
+
+Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table above. If scopes change, update the Slack app and have members reconnect. Do not change a shared production app's credentials to configure a separate test installation.
+
+For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests the six read-only channel and identity scopes instead. Its bot installation is separate from the organization setup described here.
+
+See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/).
+
+## Troubleshooting
+
+| Problem | Next step |
+|---|---|
+| Setup keeps asking for a Slack app | Finish **Verify and add** in the Slack setup; approval alone is insufficient. |
+| Redirect mismatch | Check both redirect URLs above against your Sim origin. |
+| App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. |
+| Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. |
+| Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. |
+| Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. |
diff --git a/apps/docs/content/docs/workflows/blocks/credential.mdx b/apps/docs/content/docs/workflows/blocks/credential.mdx
index ae28df764a2..a6044fea0a0 100644
--- a/apps/docs/content/docs/workflows/blocks/credential.mdx
+++ b/apps/docs/content/docs/workflows/blocks/credential.mdx
@@ -1,6 +1,6 @@
---
title: Credential
-description: The Credential block outputs an OAuth credential's ID reference for downstream blocks to use.
+description: Select workspace OAuth credentials or find organization OAuth and MCP account references for downstream blocks.
---
import { Callout } from 'fumadocs-ui/components/callout'
@@ -14,7 +14,7 @@ import {
} from '@/components/workflow-preview'
import { FAQ } from '@/components/ui/faq'
-The **Credential block** hands a downstream block an OAuth credential to use, without exposing the secret. It has two operations: **Select Credential** outputs one credential's ID reference for other blocks to use; **List Credentials** returns all the workspace's OAuth credentials (optionally filtered by provider) as an array to iterate over.
+The **Credential block** passes account references to downstream blocks without exposing tokens. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the organization operations find or list contributed OAuth accounts and managed MCP connections.
@@ -30,6 +30,10 @@ The **Credential block** hands a downstream block an OAuth credential to use, wi
|---|---|
| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks |
| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop |
+| **Find Organization Account** | Find exactly one active OAuth contribution by invitation email and provider |
+| **List Organization Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers |
+| **Find Organization MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider |
+| **List Organization MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider |
### Credential (Select operation)
@@ -73,6 +77,68 @@ Filter the returned OAuth credentials by provider. Select one or more providers
+## Organization accounts
+
+An organization owner or admin must first [set up connected accounts](/platform/connected-accounts) and allow this workflow's workspace. The block uses the organization that owns the workspace; there is no credential group or organization selector.
+
+Every authorized workflow in an allowed workspace can use every active contribution in the organization's pool. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply.
+
+### Inputs
+
+| Operation | Required fields | Optional fields |
+| --- | --- | --- |
+| **Find Organization Account** | Email, Provider | — |
+| **List Organization Accounts** | — | Email, Providers, Limit, Cursor |
+| **Find Organization MCP Connection** | Email, MCP provider | — |
+| **List Organization MCP Connections** | — | Email, MCP provider, Limit, Cursor |
+
+For list operations, **Limit** accepts 1–100 and defaults to 100. **Cursor** accepts the previous page's `nextCursor`.
+
+**Email** refers to the address used for the person's invitation. Sim associates that invitation with their verified Sim user. See [email association](/platform/connected-accounts#how-the-email-is-associated-with-a-sim-user) for how OAuth and managed MCP identity checks differ.
+
+Find operations fail unless there is exactly one active matching connection. List operations return an empty array when there are no matches and omit inactive or revoked connections.
+
+### OAuth outputs
+
+**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode.
+
+**List Organization Accounts** returns these account references in `credentials`, along with `count`, `hasMore`, and `nextCursor`. `count` is the number returned on this page. Feed `credentials` into a ForEach loop and use `` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically.
+
+For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `` in a Gmail block to act using Alex's contribution.
+
+### Managed MCP outputs
+
+**Find Organization MCP Connection** returns:
+
+| Output | Type | Description |
+| --- | --- | --- |
+| `credentialId` | `string` | The person's managed MCP connection ID; use this connection for MCP calls |
+| `email` | `string` | Invitation email used to find the connection |
+| `displayName` | `string` | Connection display name |
+| `mcpServerId` | `string` | Shared MCP configuration ID |
+| `mcpServerName` | `string` | Configured MCP server name |
+| `toolNames` | `json` | Tool names available to this connection |
+
+**List Organization MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page.
+
+
+For a managed MCP account, use the returned **`credentialId`** to select the person's connection in the MCP Tool block. **`mcpServerId`** identifies the shared provider configuration; it does not identify a person's authorization. No OAuth token or client secret is returned by the Credential block.
+
+
+## Connection-event triggers
+
+Switch the Credential block to trigger mode to start a workflow when an account connects or a connection form is submitted. Select an **Event** and deploy the workflow in an allowed workspace.
+
+| Event | When it runs |
+| --- | --- |
+| **Credential Added** | A person adds a new account contribution |
+| **Credential Reconnected** | A person reconnects an existing contribution |
+| **Account Connections Submitted** | A person submits the connection form |
+
+Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Added and reconnected events also include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account.
+
+Each deployed workflow that selects the event in an allowed workspace can receive it. Removing workspace access stops subsequent event delivery. Legacy **Credential Group** blocks must be replaced with the Credential block; they are not automatically converted.
+
## Examples
### Share one credential across blocks
@@ -123,17 +189,19 @@ The same reference works for any OAuth block. In a Gmail or Slack block's creden
## Best Practices
- **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `` instead of selecting the account five times
-- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs
+- **Inspect references, not tokens**: The block returns credential IDs and account metadata rather than tokens. Organization outputs can include people's email addresses
- **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable
- **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference
- **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop
- **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown
in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." },
+ { question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference in your Function block's code. The function receives the ID string. It does not automatically resolve credential IDs to tokens; use a compatible integration or MCP block to perform an authenticated action." },
{ question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." },
]} />
diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json
index d3a1fa9f366..533dcf0f5f3 100644
--- a/apps/docs/openapi-v2-resources.json
+++ b/apps/docs/openapi-v2-resources.json
@@ -8210,6 +8210,7 @@
"providerId": {
"type": "string",
"enum": [
+ "github-repositories",
"google-email",
"google-drive",
"google-docs",
@@ -9492,6 +9493,11 @@
"minLength": 1,
"maxLength": 2048
},
+ "atlassianProduct": {
+ "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.",
+ "type": "string",
+ "enum": ["jira", "confluence"]
+ },
"signingSecret": {
"description": "Write-only webhook signing secret.",
"writeOnly": true,
diff --git a/apps/docs/public/static/search/add-source.jpg b/apps/docs/public/static/search/add-source.jpg
new file mode 100644
index 00000000000..d2c1b6cbf2d
Binary files /dev/null and b/apps/docs/public/static/search/add-source.jpg differ
diff --git a/apps/docs/public/static/search/atlassian-oauth-callback.png b/apps/docs/public/static/search/atlassian-oauth-callback.png
new file mode 100644
index 00000000000..23257a56eaf
Binary files /dev/null and b/apps/docs/public/static/search/atlassian-oauth-callback.png differ
diff --git a/apps/docs/public/static/search/confluence-setup.jpg b/apps/docs/public/static/search/confluence-setup.jpg
new file mode 100644
index 00000000000..e2fe50864e4
Binary files /dev/null and b/apps/docs/public/static/search/confluence-setup.jpg differ
diff --git a/apps/docs/public/static/search/connect-account.png b/apps/docs/public/static/search/connect-account.png
new file mode 100644
index 00000000000..6f4bb5a06a0
Binary files /dev/null and b/apps/docs/public/static/search/connect-account.png differ
diff --git a/apps/docs/public/static/search/github-app-callback.jpg b/apps/docs/public/static/search/github-app-callback.jpg
new file mode 100644
index 00000000000..5ae4a20392b
Binary files /dev/null and b/apps/docs/public/static/search/github-app-callback.jpg differ
diff --git a/apps/docs/public/static/search/github-app-email-permission.jpg b/apps/docs/public/static/search/github-app-email-permission.jpg
new file mode 100644
index 00000000000..a7086ca4739
Binary files /dev/null and b/apps/docs/public/static/search/github-app-email-permission.jpg differ
diff --git a/apps/docs/public/static/search/github-app-repository-permissions.jpg b/apps/docs/public/static/search/github-app-repository-permissions.jpg
new file mode 100644
index 00000000000..0e95461689e
Binary files /dev/null and b/apps/docs/public/static/search/github-app-repository-permissions.jpg differ
diff --git a/apps/docs/public/static/search/gitlab-options.jpg b/apps/docs/public/static/search/gitlab-options.jpg
new file mode 100644
index 00000000000..96371a64fe8
Binary files /dev/null and b/apps/docs/public/static/search/gitlab-options.jpg differ
diff --git a/apps/docs/public/static/search/gitlab-setup.jpg b/apps/docs/public/static/search/gitlab-setup.jpg
new file mode 100644
index 00000000000..eceb8eab76e
Binary files /dev/null and b/apps/docs/public/static/search/gitlab-setup.jpg differ
diff --git a/apps/docs/public/static/search/gmail-setup.jpg b/apps/docs/public/static/search/gmail-setup.jpg
new file mode 100644
index 00000000000..cdc89f465fc
Binary files /dev/null and b/apps/docs/public/static/search/gmail-setup.jpg differ
diff --git a/apps/docs/public/static/search/google-calendar-setup.jpg b/apps/docs/public/static/search/google-calendar-setup.jpg
new file mode 100644
index 00000000000..f369fb01237
Binary files /dev/null and b/apps/docs/public/static/search/google-calendar-setup.jpg differ
diff --git a/apps/docs/public/static/search/google-create-private-key.png b/apps/docs/public/static/search/google-create-private-key.png
new file mode 100644
index 00000000000..ab21a326288
Binary files /dev/null and b/apps/docs/public/static/search/google-create-private-key.png differ
diff --git a/apps/docs/public/static/search/google-create-service-account.png b/apps/docs/public/static/search/google-create-service-account.png
new file mode 100644
index 00000000000..ec988ffcc24
Binary files /dev/null and b/apps/docs/public/static/search/google-create-service-account.png differ
diff --git a/apps/docs/public/static/search/google-domain-delegation.png b/apps/docs/public/static/search/google-domain-delegation.png
new file mode 100644
index 00000000000..b68680d0b82
Binary files /dev/null and b/apps/docs/public/static/search/google-domain-delegation.png differ
diff --git a/apps/docs/public/static/search/google-drive-setup.jpg b/apps/docs/public/static/search/google-drive-setup.jpg
new file mode 100644
index 00000000000..9e4e0af001d
Binary files /dev/null and b/apps/docs/public/static/search/google-drive-setup.jpg differ
diff --git a/apps/docs/public/static/search/google-oauth-web-client.png b/apps/docs/public/static/search/google-oauth-web-client.png
new file mode 100644
index 00000000000..d7cba7d65c4
Binary files /dev/null and b/apps/docs/public/static/search/google-oauth-web-client.png differ
diff --git a/apps/docs/public/static/search/google-service-account.jpg b/apps/docs/public/static/search/google-service-account.jpg
new file mode 100644
index 00000000000..07c72b357fe
Binary files /dev/null and b/apps/docs/public/static/search/google-service-account.jpg differ
diff --git a/apps/docs/public/static/search/jira-setup.jpg b/apps/docs/public/static/search/jira-setup.jpg
new file mode 100644
index 00000000000..75af1015724
Binary files /dev/null and b/apps/docs/public/static/search/jira-setup.jpg differ
diff --git a/apps/docs/public/static/search/slack-app.jpg b/apps/docs/public/static/search/slack-app.jpg
new file mode 100644
index 00000000000..ba66b20cb52
Binary files /dev/null and b/apps/docs/public/static/search/slack-app.jpg differ
diff --git a/apps/docs/public/static/search/slack-setup.jpg b/apps/docs/public/static/search/slack-setup.jpg
new file mode 100644
index 00000000000..9b701cd4427
Binary files /dev/null and b/apps/docs/public/static/search/slack-setup.jpg differ
diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts
index 93a94dd6eae..5361878dec2 100644
--- a/apps/sim/app/(auth)/auth-redirect.test.ts
+++ b/apps/sim/app/(auth)/auth-redirect.test.ts
@@ -32,7 +32,7 @@ describe('resolvePostSignupDestination', () => {
it('never routes to verify when no mail provider is configured', () => {
expect(
resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' })
- ).toEqual({ kind: 'workspace' })
+ ).toEqual({ kind: 'entry' })
})
it('preserves the callback URL when verification is not enforceable', () => {
diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts
index e567b85b210..c487f5a1c99 100644
--- a/apps/sim/app/(auth)/auth-redirect.ts
+++ b/apps/sim/app/(auth)/auth-redirect.ts
@@ -1,3 +1,5 @@
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
+
/**
* Where the user goes once authentication finishes, carried across the login →
* signup → verify hops. Written only after `validateCallbackUrl` accepts it, and
@@ -8,19 +10,22 @@ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'
/** Route the verify hop lives at, entered only from signup. */
export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true'
-/** Default post-auth destination when no callback URL was carried in. */
-export const DEFAULT_POST_AUTH_ROUTE = '/workspace'
+/**
+ * Default post-auth destination when no callback URL was carried in: the app
+ * entry, which resolves to the viewer's organization or their workspaces.
+ */
+export const DEFAULT_POST_AUTH_ROUTE = APP_ENTRY_PATH
/**
* Where a successful email signup goes next.
* - `verify`: the verification hop, which owns the post-auth redirect from there
* - `redirect`: the validated callback URL the visitor arrived with
- * - `workspace`: the default destination
+ * - `entry`: the default destination, {@link DEFAULT_POST_AUTH_ROUTE}
*/
export type PostSignupDestination =
| { kind: 'verify' }
| { kind: 'redirect'; url: string }
- | { kind: 'workspace' }
+ | { kind: 'entry' }
interface PostSignupDestinationParams {
/** The server-derived effective flag — verification enabled AND deliverable. */
@@ -40,7 +45,7 @@ export function resolvePostSignupDestination({
redirectUrl,
}: PostSignupDestinationParams): PostSignupDestination {
if (emailVerificationEnabled) return { kind: 'verify' }
- return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
+ return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'entry' }
}
/** The raw redirect-carrying params, as read from a URL on client or server. */
diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx
index c200d86bd11..37df7815ed7 100644
--- a/apps/sim/app/(auth)/components/social-login-buttons.tsx
+++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
+import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
const logger = createLogger('SocialLoginButtons')
@@ -22,7 +23,7 @@ export function SocialLoginButtons({
githubAvailable,
googleAvailable,
microsoftAvailable,
- callbackURL = '/workspace',
+ callbackURL = DEFAULT_POST_AUTH_ROUTE,
children,
}: SocialLoginButtonsProps) {
const [isGithubLoading, setIsGithubLoading] = useState(false)
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index cfe0b1403b8..c2dafcb06ca 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -22,7 +22,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 { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
import {
AuthDivider,
AuthField,
@@ -108,7 +108,7 @@ export default function LoginPage({
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
}
- const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
+ const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : DEFAULT_POST_AUTH_ROUTE
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
const signupHref = buildAuthCrossLink('/signup', {
callbackUrl: isValidCallbackUrl ? callbackUrl : null,
diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts
index 1ecbb6e398f..d66a51b7d60 100644
--- a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts
+++ b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts
@@ -150,7 +150,7 @@ describe('OAuth login bridge', () => {
})
)
expect(response.status).toBe(307)
- expect(response.headers.get('location')).toBe('https://sim.test/workspace')
+ expect(response.headers.get('location')).toBe('https://sim.test/home')
}
})
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 61ad48a8328..7488520915a 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -408,7 +408,9 @@ function SignupFormContent({
- {hasOnlySSO &&
}
+ {hasOnlySSO && (
+
+ )}
{emailEnabled && (
diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
index 6e182a8ef48..a279ec39f27 100644
--- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
+++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
@@ -21,6 +21,7 @@ import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'
import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css'
@@ -143,7 +144,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') {
setSocialLoading(provider)
try {
- await client.signIn.social({ provider, callbackURL: '/workspace' })
+ await client.signIn.social({ provider, callbackURL: APP_ENTRY_PATH })
} catch (error) {
logger.warn('Social sign-in did not complete', { provider, error })
} finally {
diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
index 2584ea88c37..8e13703055c 100644
--- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
+++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
@@ -151,12 +151,14 @@ describe('desktop title-bar surface audit', () => {
expect(rule).not.toContain('margin-top')
})
- it('drops the content pane border where the pane meets the window edge', () => {
- // Collapsing the sidebar in the desktop shell takes the pane's padding to 0, so a
- // retained border and radius drew a hairline outline inset from the square window.
+ it('drops the pane divider where the pane meets the window edge', () => {
+ // The pane meets the rail on a single left hairline. Collapsing the sidebar in the
+ // desktop shell leaves no rail beside it, so a retained divider would draw a stray
+ // line down the window's left edge. The pane carries no radius or full border to
+ // drop anymore; the divider is the only chrome between them.
const flush = '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:'
- expect(workspaceChrome).toContain(`${flush}rounded-none`)
- expect(workspaceChrome).toContain(`${flush}border-0`)
+ expect(workspaceChrome).toContain(`${flush}border-l-0`)
+ expect(workspaceChrome).not.toContain('rounded-[8px]')
})
it('clears the lane for panels that embed pages away from the lights', () => {
@@ -289,6 +291,9 @@ const SELF_RESERVE_REQUIRED = new Set([
// `WorkspaceHostProvider` — an ancestor of the chrome, not a descendant — returns it
// instead of its children on a client-side 403. Neither is a double reservation.
'app/workspace/[workspaceId]/components/workspace-access-denied.tsx',
+ // Same shape on the organization surface: `o/[organizationId]/layout.tsx` returns it
+ // for a non-member before reaching ``.
+ 'app/o/[organizationId]/components/organization-access-denied.tsx',
])
/** Every file under `app/`, so ancestor layouts can be resolved without extra fs calls. */
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 232ab9ebe9b..c99b6a04651 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -392,7 +392,7 @@
:root {
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */
- --sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
+ --sidebar-expanded-width: 256px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
--desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */
--workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */
--desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */
@@ -403,17 +403,12 @@
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
/**
- * The padding `.workspace-content-shell` insets the panel and terminal from
- * the viewport by (CONTENT_WINDOW_GAP).
- *
- * Published here because surfaces portalled to `` — the toast stack —
- * position against those elements from the viewport, so they must add back
- * whatever separates the element from the viewport edge. Reading it rather
- * than hardcoding 8px is what keeps the toast and the canvas controls on the
- * same clearance when the shell drops its padding; the controls are laid out
- * inside the shell and so need no correction.
+ * Distance between `.workspace-content-shell` and the viewport edge
+ * (CONTENT_WINDOW_GAP). The shell sits flush, so this is zero; it stays
+ * published because surfaces portalled to `` — the toast stack — and
+ * the panel and terminal geometry all read it rather than assuming a value.
*/
- --workspace-content-gap: 8px;
+ --workspace-content-gap: 0px;
--output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */
/**
* Neutral border and divider thickness. Standard-density displays cannot draw
@@ -562,14 +557,6 @@ html[data-sim-desktop-title-bar="inset"]
--workspace-content-title-bar-inset: var(--desktop-title-bar-height);
}
-/* The one case the shell drops its padding entirely (see `workspace-chrome.tsx`:
- `isCollapsed && '[[data-sim-desktop-title-bar=inset]_&]:p-0'`). Declared on the
- root so the portalled toast stack — which cannot inherit from the shell — sees
- it too, and keeps the same clearance the in-shell canvas controls keep. */
-html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sidebar-collapsed]) {
- --workspace-content-gap: 0px;
-}
-
.workspace-root code,
.workspace-root kbd,
.workspace-root samp,
@@ -580,7 +567,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
.sidebar-container {
width: var(--sidebar-width);
- transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
/**
@@ -607,12 +593,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
--sidebar-width: var(--sidebar-expanded-width);
}
-/* The card appears at full width, so the aside's own width transition would animate
- 0 -> expanded inside it. */
-.sidebar-shell-outer[data-peek] .sidebar-container {
- transition: none;
-}
-
/* The card is a flex column sized to its content, so the shell must be allowed to
shrink for the sidebar's own scroll region to bound itself once the card hits its
max height. Docked, this element is not a flex item and the rule is inert. */
@@ -623,7 +603,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
.sidebar-container span,
.sidebar-container .text-small {
- transition: opacity 120ms ease;
white-space: nowrap;
}
@@ -632,51 +611,10 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
opacity: 0;
}
-.sidebar-container .sidebar-collapse-hide {
- transition: opacity 60ms ease;
-}
-
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
opacity: 0;
}
-@keyframes sidebar-collapse-guard {
- from {
- pointer-events: none;
- }
- to {
- pointer-events: auto;
- }
-}
-
-.sidebar-container[data-collapsed] {
- animation: sidebar-collapse-guard 250ms step-end;
-}
-
-.sidebar-container.is-resizing {
- transition: none;
-}
-
-/* Suppress width/transform transitions on the chrome wrappers during a
- drag-resize so the outer overflow-hidden clip doesn't lag behind the inner
- sidebar content, which is already at the correct width instantly. */
-html.sidebar-resizing .sidebar-shell-outer,
-html.sidebar-resizing .sidebar-shell-inner {
- transition: none !important;
-}
-
-/* Suppress sidebar transitions during the initial hydration window. The
- pre-paint script sets the correct --sidebar-width, but store rehydration
- re-applies it a tick later; without this guard that re-apply animates the
- rail, reading as a collapse -> expand flash on a fresh page load. Removed
- after the first paint (see workspace-chrome.tsx) so user-driven toggles and
- the fullscreen slide still animate. */
-html.sidebar-booting .sidebar-container,
-html.sidebar-booting .sidebar-shell-outer,
-html.sidebar-booting .sidebar-shell-inner {
- transition: none !important;
-}
-
.panel-container {
width: var(--panel-width);
}
@@ -787,6 +725,9 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
+ /* Progress and completion — the checked step, the done state. Deeper and
+ quieter than --selection, which stays the interactive highlight. */
+ --brand-blue: #3b6fe0;
--selection: #1a5cf6;
--selection-muted: #1a5cf647;
--warning: #ea580c;
@@ -948,6 +889,8 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
+ /* Lifted for contrast on dark surfaces, the same step --selection takes. */
+ --brand-blue: #5b8def;
--selection: #4b83f7;
--selection-muted: #4b83f759;
--warning: #ff6600;
diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
index 70d2ec4e50c..13200bbf7f6 100644
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -177,7 +177,7 @@ describe('OAuth Utils', () => {
).rejects.toThrow('Failed to refresh token')
})
- it('should not attempt refresh if no refresh token', async () => {
+ it('requires reconnection for an expired token without attempting an unavailable refresh', async () => {
const mockCredential = {
id: 'credential-id',
accessToken: 'token',
@@ -186,10 +186,11 @@ describe('OAuth Utils', () => {
providerId: 'google',
}
- const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
+ await expect(
+ refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
+ ).rejects.toThrow('OAuth access token expired and cannot be refreshed; reconnect the account')
expect(mockRefreshOAuthToken).not.toHaveBeenCalled()
- expect(result).toEqual({ accessToken: 'token', refreshed: false })
})
it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => {
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
index 9c7c35370d7..358cd1f3753 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -56,11 +56,8 @@ vi.mock('@/lib/credentials/application/create-credential-connection', () => ({
execute: mocks.createConnection,
},
}))
-vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({
- launchCredentialConnection: {
- operation: { id: 'credentials.connections.launch' },
- execute: mocks.launchConnection,
- },
+vi.mock('@/lib/credentials/application/launch-scoped-credential-connection', () => ({
+ launchScopedCredentialConnection: mocks.launchConnection,
}))
vi.mock('@/lib/oauth/utils', () => ({
getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes,
@@ -440,7 +437,7 @@ describe('OAuth2 authorize route', () => {
const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
- expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`)
expect(mocks.requireClient).toHaveBeenCalledWith('google-email')
expect(mocks.createConnection).not.toHaveBeenCalled()
})
@@ -521,7 +518,7 @@ describe('OAuth2 authorize route', () => {
)
expect(response.headers.get('location')).toBe(
- `${BASE_URL}/workspace?error=credential_provider_mismatch`
+ `${BASE_URL}/home?error=credential_provider_mismatch`
)
})
@@ -561,9 +558,7 @@ describe('OAuth2 authorize route', () => {
})
)
- expect(response.headers.get('location')).toBe(
- `${BASE_URL}/workspace?error=workspace_access_denied`
- )
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=workspace_access_denied`)
})
it('redirects a draft launch infrastructure failure through the browser error contract', async () => {
@@ -571,7 +566,7 @@ describe('OAuth2 authorize route', () => {
const response = await GET(request({ draftId: 'draft-1' }))
- expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`)
})
it('routes custom providers through the exact application draft', async () => {
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts
index 546271edaba..c0775f1e1cd 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts
@@ -15,8 +15,9 @@ import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target'
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
-import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
+import { launchScopedCredentialConnection } from '@/lib/credentials/application/launch-scoped-credential-connection'
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config'
import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants'
import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state'
@@ -171,18 +172,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query
try {
+ let organizationId: string | undefined
let fromConnectionDraft = false
let connectionDraftId: string | undefined
let encryptedQuickBooksClientConfig: string | null | undefined
if (draftId) {
try {
- const { draft } = await launchCredentialConnection.execute({
+ const { draft } = await launchScopedCredentialConnection({
principal,
input: { draftId },
request,
})
providerId = draft.providerId
- workspaceId = draft.workspaceId
+ workspaceId = draft.workspaceId ?? undefined
+ organizationId = draft.organizationId ?? undefined
credentialId = draft.credentialId ?? undefined
connectionDraftId = draft.id
encryptedQuickBooksClientConfig = draft.oauthConfig
@@ -190,11 +193,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
if (!(error instanceof OrchestrationError)) throw error
logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code })
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`)
}
}
- if (!providerId || !workspaceId) {
+ if (!providerId || (!workspaceId && !organizationId)) {
throw new Error('Validated OAuth authorization request is missing its target')
}
if (providerId !== 'quickbooks') {
@@ -209,9 +212,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
: connectionCompleteUrl.toString()
: requestedCallback?.startsWith(`${baseUrl}/`)
? requestedCallback
- : `${baseUrl}/workspace`
+ : `${baseUrl}${APP_ENTRY_PATH}`
if (!fromConnectionDraft) {
+ if (!workspaceId) throw new Error('Workspace OAuth launch is missing its owner')
try {
const connection = await createCredentialConnection.execute({
principal,
@@ -226,22 +230,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
connectionDraftId = connection.draftId
} catch (error) {
if (error instanceof CredentialConnectionProviderMismatchError) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
+ return NextResponse.redirect(
+ `${baseUrl}${APP_ENTRY_PATH}?error=credential_provider_mismatch`
+ )
}
if (
credentialId &&
error instanceof ForbiddenOperationError &&
error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED'
) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=credential_access_denied`)
}
if (error instanceof OrchestrationError && error.code === 'not_found') {
return NextResponse.redirect(
- `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}`
+ `${baseUrl}${APP_ENTRY_PATH}?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}`
)
}
if (error instanceof OrchestrationError && error.code === 'forbidden') {
- return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=workspace_access_denied`)
}
throw error
}
@@ -253,7 +259,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (providerId === 'quickbooks') {
if (!encryptedQuickBooksClientConfig) {
- const { draft } = await launchCredentialConnection.execute({
+ const { draft } = await launchScopedCredentialConnection({
principal,
input: { draftId: connectionDraftId },
request,
@@ -309,7 +315,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
providerId,
status: linkResponse.status,
})
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`)
}
const response = NextResponse.redirect(payload.url)
@@ -324,6 +330,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return response
} catch (error) {
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`)
}
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
index 19284a950fc..afca4590c1f 100644
--- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
@@ -18,6 +18,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { safeAccountInsert } from '@/lib/oauth/credential-service'
import {
parseInstagramLongLivedToken,
@@ -52,7 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`))
+ return clearOAuthCookies(
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`)
+ )
}
const parsed = await parseRequest(instagramCallbackContract, request, {})
@@ -68,7 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error_description,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_access_denied`)
)
}
@@ -79,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
hasCookieState: Boolean(cookieState),
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_state_mismatch`)
)
}
@@ -90,7 +93,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!code) {
logger.error('No authorization code received from Instagram')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_code`)
)
}
@@ -123,7 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_token_error`)
)
}
@@ -136,7 +139,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!shortLived) {
logger.error('Instagram short-lived token response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_token`)
)
}
@@ -160,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_exchange_error`)
)
}
@@ -174,7 +177,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!longLived) {
logger.error('Instagram long-lived token response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_long_lived`)
)
}
@@ -199,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`)
)
}
@@ -212,7 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!profile) {
logger.error('Instagram profile response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`)
)
}
@@ -222,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!igUserId) {
logger.error('Instagram profile response missing user_id', { profile })
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_user_id`)
)
}
@@ -311,7 +314,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value
const redirectUrl =
- returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace`
+ returnUrlCookie && isSameOrigin(returnUrlCookie)
+ ? returnUrlCookie
+ : `${baseUrl}${APP_ENTRY_PATH}`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('instagram_connected', 'true')
@@ -322,6 +327,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
? 'instagram_config_error'
: 'instagram_callback_error'
- return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`))
+ return clearOAuthCookies(
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`)
+ )
}
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
index 51a36fd3c98..1dd57e3d27a 100644
--- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
@@ -140,7 +140,7 @@ describe('QuickBooks OAuth callback', () => {
expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled()
expect(response.headers.get('location')).toBe(
- 'https://sim.test/workspace?error=quickbooks_callback_error'
+ 'https://sim.test/home?error=quickbooks_callback_error'
)
})
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
index 675e7e8a076..f232cb655f6 100644
--- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
@@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { normalizeQuickBooksRealmId } from '@/lib/oauth/quickbooks'
import { parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state'
@@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
- const fallbackUrl = `${baseUrl}/workspace`
+ const fallbackUrl = `${baseUrl}${APP_ENTRY_PATH}`
let validatedReturnUrl: URL | null = null
try {
diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
index 8447e56d48d..7df3318106e 100644
--- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
@@ -12,6 +12,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state'
@@ -63,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`)
}
const { searchParams } = request.nextUrl
@@ -79,28 +80,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!validateHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_hmac_invalid`)
}
if (!state) {
logger.error('Missing state in Shopify OAuth callback')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_state_mismatch`)
}
if (!code) {
logger.error('No code received from Shopify')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_code`)
}
const shopDomain = shop
if (!shopDomain) {
logger.error('No shop domain available')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_shop`)
}
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format:', { shopDomain })
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_invalid_shop`)
}
const { draftId, returnUrl } = parseShopifyOAuthState({
@@ -128,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
status: tokenResponse.status,
body: errorText,
})
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_token_error`)
}
const tokenData = await tokenResponse.json()
@@ -142,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!accessToken) {
logger.error('No access token in response')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_token`)
}
await completeShopifyOAuthConnection({
@@ -157,7 +158,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (returnUrl && !isSameOrigin(returnUrl)) {
throw new Error('Shopify OAuth state contains an invalid return URL')
}
- const redirectUrl = returnUrl ?? `${baseUrl}/workspace`
+ const redirectUrl = returnUrl ?? `${baseUrl}${APP_ENTRY_PATH}`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('shopify_connected', 'true')
@@ -169,7 +170,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
? 'shopify_config_error'
: 'shopify_callback_error'
return clearShopifyOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`)
)
}
})
diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts
index 2d45e1dca3b..bbb96651bbb 100644
--- a/apps/sim/app/api/auth/trello/callback/route.ts
+++ b/apps/sim/app/api/auth/trello/callback/route.ts
@@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const logger = createLogger('TrelloCallback')
@@ -48,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const returnUrl =
requestedReturnUrl && isSameOrigin(requestedReturnUrl)
? requestedReturnUrl
- : `${baseUrl}/workspace`
+ : `${baseUrl}${APP_ENTRY_PATH}`
const queryState = parsed.data.query.state
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts
index 14b5d2bd4a8..7a11c742bbc 100644
--- a/apps/sim/app/api/billing/portal/route.ts
+++ b/apps/sim/app/api/billing/portal/route.ts
@@ -10,6 +10,7 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const logger = createLogger('BillingPortal')
@@ -28,7 +29,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const context = parsedBody.data.context
const organizationId = parsedBody.data.organizationId
- const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated`
+ const returnUrl =
+ parsedBody.data.returnUrl || `${getBaseUrl()}${APP_ENTRY_PATH}?billing=updated`
const stripe = requireStripeClient()
diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts
index dc669e8a169..2b38862261f 100644
--- a/apps/sim/app/api/billing/update-cost/route.test.ts
+++ b/apps/sim/app/api/billing/update-cost/route.test.ts
@@ -68,7 +68,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
},
COPILOT_BILLING_PROTOCOL_HEADER: 'x-sim-billing-protocol',
requireAccountBillingDecisionHeader: mockRequireAccountBillingDecisionHeader,
- requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
+ requireBillingCallbackAttribution: mockRequireBillingAttributionHeader,
resolveLegacyV0BillingAttribution: mockResolveLegacyV0BillingAttribution,
toBillingContext: mockToBillingContext,
}))
@@ -303,6 +303,33 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})
+ it('settles an organization charge from its immutable envelope with no workspace ID', async () => {
+ const orgAttribution = { ...ATTRIBUTION, workspaceId: null }
+ mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution)
+ const id = '00000000-0000-4000-8000-000000000001'
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ { ...SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY, idempotencyKey: id },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': id,
+ 'x-sim-billing-attribution': 'serialized-org-attribution',
+ }
+ )
+ )
+ expect(response.status).toBe(200)
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: undefined,
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ )
+ expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
+ })
+
it('does not let markerless legacy traffic fall through to a modern attribution envelope', async () => {
const res = await POST(
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, {
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index 92e0d3b32d0..f9537851d57 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -14,7 +14,7 @@ import {
COPILOT_BILLING_PROTOCOL_HEADER,
type CopilotBillingProtocol,
requireAccountBillingDecisionHeader,
- requireBillingAttributionHeader,
+ requireBillingCallbackAttribution,
resolveLegacyV0BillingAttribution,
toBillingContext,
} from '@/lib/billing/core/billing-attribution'
@@ -156,8 +156,17 @@ async function updateCostInner(req: NextRequest, span: Span): Promise ({
mockCheckInternalApiKey: vi.fn(),
mockCheckAttributedUsageLimits: vi.fn(),
@@ -41,6 +43,7 @@ const {
mockSerializeBillingAttributionHeader: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetWorkspaceBillingSettings: vi.fn(),
+ mockAuthorizeOrganizationChat: vi.fn(),
}))
const ATTRIBUTION = {
@@ -117,6 +120,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
deriveBillingContext: mockDeriveBillingContext,
}))
+vi.mock('@/lib/copilot/chat/organization-chats', () => ({
+ authorizeOrganizationChatDelegation: { execute: mockAuthorizeOrganizationChat },
+}))
+
vi.mock('@/lib/copilot/request/http', () => ({
checkInternalApiKey: mockCheckInternalApiKey,
}))
@@ -312,6 +319,73 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})
+ it('requires the current actor and canonical private chat for organization admission', async () => {
+ const orgAttribution = { ...ATTRIBUTION, workspaceId: null }
+ mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution)
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '00000000-0000-4000-8000-000000000001',
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ )
+ expect(response.status).toBe(200)
+ expect(mockAuthorizeOrganizationChat).toHaveBeenCalledWith({
+ principal: expect.objectContaining({
+ kind: 'organization_delegated',
+ subjectUserId: 'user-1',
+ organizationId: 'org-1',
+ resourceScope: { chatId: 'chat-1' },
+ }),
+ })
+ expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(expect.anything(), {
+ actorUserId: 'user-1',
+ organizationId: 'org-1',
+ })
+ expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(orgAttribution)
+ })
+
+ it('denies removed members before billing admission', async () => {
+ mockAuthorizeOrganizationChat.mockRejectedValueOnce(
+ new OrchestrationError('not_found', 'Conversation not found')
+ )
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' },
+ { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' }
+ )
+ )
+ expect(response.status).toBe(403)
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('rejects markerless organization admission rather than settling it as a personal account', async () => {
+ const response = await POST(
+ request({ userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' })
+ )
+ expect(response.status).toBe(400)
+ expect(mockAuthorizeOrganizationChat).not.toHaveBeenCalled()
+ expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('rejects an organization request missing its private chat', async () => {
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ { userId: 'user-1', organizationId: 'org-1' },
+ { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' }
+ )
+ )
+ expect(response.status).toBe(400)
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ })
+
it('uses the exact frozen attribution for attributed-v1 admission', async () => {
const res = await POST(
request(
diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts
index ae9e01f4782..970220f9099 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot'
@@ -13,12 +14,18 @@ import {
requireBillingAttributionHeader,
requireBillingRequestIdHeader,
resolveLegacyV0BillingAttribution,
+ resolveOrganizationBillingAttribution,
serializeAccountBillingDecisionHeader,
serializeBillingAttributionHeader,
} from '@/lib/billing/core/billing-attribution'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
+import {
+ COPILOT_APPLICATION_DELEGATION_TTL_MS,
+ createTrustedOrganizationCopilotPrincipal,
+} from '@/lib/copilot/auth/application-delegation'
+import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats'
import {
BILLING_ACCOUNT_DECISION_HEADER,
BILLING_ATTRIBUTION_HEADER,
@@ -33,6 +40,7 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { isHosted } from '@/lib/core/config/env-flags'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotApiKeysValidate')
@@ -51,7 +59,7 @@ type AdmissionBillingDecision =
userId: string
}
| {
- kind: 'legacy-workspace'
+ kind: 'legacy-scoped'
attribution: BillingAttributionSnapshot
includeAttribution: boolean
}
@@ -74,21 +82,38 @@ async function resolveAdmissionBillingDecision(
req: NextRequest,
protocol: CopilotBillingProtocol | undefined,
actorUserId: string,
- workspaceId: string | undefined
+ workspaceId: string | undefined,
+ organizationId: string | undefined,
+ chatId: string | undefined
): Promise {
const hasBillingRequestId = Boolean(req.headers.get(BILLING_REQUEST_ID_HEADER))
const hasBillingAttribution = Boolean(req.headers.get(BILLING_ATTRIBUTION_HEADER))
const hasBillingAccountDecision = Boolean(req.headers.get(BILLING_ACCOUNT_DECISION_HEADER))
+ if (organizationId && protocol === undefined) return invalidBillingProtocolResponse()
+ if (organizationId && protocol !== COPILOT_BILLING_PROTOCOL.direct) {
+ if (!chatId) return invalidBillingProtocolResponse()
+ const principal = createTrustedOrganizationCopilotPrincipal(
+ {
+ userId: actorUserId,
+ organizationId,
+ chatId,
+ delegationId: req.headers.get(BILLING_REQUEST_ID_HEADER) ?? generateId(),
+ },
+ { audience: 'sim:copilot-billing', ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS }
+ )
+ await authorizeOrganizationChatDelegation.execute({ principal })
+ }
+
if (protocol === COPILOT_BILLING_PROTOCOL.attributed) {
- if (!workspaceId || hasBillingAccountDecision) {
+ if ((!workspaceId && !organizationId) || hasBillingAccountDecision) {
return invalidBillingProtocolResponse()
}
try {
requireBillingRequestIdHeader(req.headers)
const attribution = requireBillingAttributionHeader(req.headers, {
actorUserId,
- workspaceId,
+ ...(organizationId ? { organizationId } : { workspaceId }),
})
return { kind: 'attributed', attribution }
} catch {
@@ -124,10 +149,18 @@ async function resolveAdmissionBillingDecision(
if (hasBillingRequestId || hasBillingAttribution || hasBillingAccountDecision) {
return invalidBillingProtocolResponse()
}
- if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId) {
+ if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId && !organizationId) {
return invalidBillingProtocolResponse()
}
+ if (organizationId) {
+ return {
+ kind: 'legacy-scoped',
+ attribution: await resolveOrganizationBillingAttribution({ actorUserId, organizationId }),
+ includeAttribution: true,
+ }
+ }
+
if (workspaceId) {
const attribution = await resolveLegacyV0BillingAttribution({
actorUserId,
@@ -135,7 +168,7 @@ async function resolveAdmissionBillingDecision(
})
if (attribution) {
return {
- kind: 'legacy-workspace',
+ kind: 'legacy-scoped',
attribution,
includeAttribution: protocol === COPILOT_BILLING_PROTOCOL.legacy,
}
@@ -152,7 +185,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
scope: string
accountBillingDecision?: AccountBillingDecision
}> {
- if (admission.kind === 'attributed' || admission.kind === 'legacy-workspace') {
+ if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') {
const usage = await checkAttributedUsageLimits(admission.attribution)
const enforcedUsage =
usage.scope === 'member' && usage.memberUsage ? usage.memberUsage : usage.payerUsage
@@ -254,7 +287,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
)
if (!parsed.success) return parsed.response
- const { userId, workspaceId } = parsed.data.body
+ const { userId, workspaceId, organizationId, chatId } = parsed.data.body
const protocol = parsed.data.headers?.[COPILOT_BILLING_PROTOCOL_HEADER]
span.setAttribute(TraceAttr.UserId, userId)
@@ -267,7 +300,14 @@ export const POST = withRouteHandler((req: NextRequest) =>
}
logger.info('[API VALIDATION] Validating usage limit', { userId })
- const admission = await resolveAdmissionBillingDecision(req, protocol, userId, workspaceId)
+ const admission = await resolveAdmissionBillingDecision(
+ req,
+ protocol,
+ userId,
+ workspaceId,
+ organizationId,
+ chatId
+ )
if (admission instanceof NextResponse) {
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody)
span.setAttribute(TraceAttr.HttpStatusCode, admission.status)
@@ -289,9 +329,9 @@ export const POST = withRouteHandler((req: NextRequest) =>
scope: usage.scope,
billingProtocol: protocol ?? COPILOT_BILLING_PROTOCOL.legacy,
billingResolution:
- admission.kind === 'legacy-workspace' ? 'mutable-request-time' : 'immutable-or-account',
+ admission.kind === 'legacy-scoped' ? 'mutable-request-time' : 'immutable-or-account',
billingPayer:
- admission.kind === 'attributed' || admission.kind === 'legacy-workspace'
+ admission.kind === 'attributed' || admission.kind === 'legacy-scoped'
? admission.attribution.billingEntity
: (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }),
})
@@ -322,7 +362,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
responseHeaders[BILLING_ACCOUNT_DECISION_HEADER] = serializeAccountBillingDecisionHeader(
usage.accountBillingDecision
)
- } else if (admission.kind === 'legacy-workspace' && admission.includeAttribution) {
+ } else if (admission.kind === 'legacy-scoped' && admission.includeAttribution) {
responseHeaders[BILLING_ATTRIBUTION_HEADER] = serializeBillingAttributionHeader(
admission.attribution
)
@@ -334,6 +374,9 @@ export const POST = withRouteHandler((req: NextRequest) =>
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return NextResponse.json({ isEnterprise }, { status: 200, headers: responseHeaders })
} catch (error) {
+ const code = asOrchestrationError(error)?.code
+ if (code === 'not_found' || code === 'forbidden')
+ return NextResponse.json({ error: 'Conversation access denied' }, { status: 403 })
logger.error('Error validating usage limit', { error })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError)
span.setAttribute(TraceAttr.HttpStatusCode, 500)
diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts
index f3655d0f825..dcd1c74dd33 100644
--- a/apps/sim/app/api/copilot/chat/abort/route.test.ts
+++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts
@@ -5,6 +5,7 @@ import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
+ mockGetAccessibleChat,
mockAbortActiveStream,
mockAuthenticate,
mockGetLatestRunForStream,
@@ -16,6 +17,7 @@ const {
const order: string[] = []
return {
order,
+ mockGetAccessibleChat: vi.fn(),
mockAbortActiveStream: vi.fn(async () => {
order.push('abortActiveStream')
return true
@@ -30,6 +32,10 @@ const {
}
})
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({
+ getAccessibleCopilotChatAuth: mockGetAccessibleChat,
+}))
+
vi.mock('@/lib/copilot/request/http', () => ({
authenticateCopilotRequestSessionOnly: mockAuthenticate,
}))
@@ -55,6 +61,7 @@ describe('POST /api/copilot/chat/abort', () => {
beforeEach(() => {
vi.clearAllMocks()
order.length = 0
+ mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' })
mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true })
mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' })
mockWaitForPendingChatStream.mockResolvedValue(true)
@@ -93,6 +100,21 @@ describe('POST /api/copilot/chat/abort', () => {
expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1')
})
+ it('refuses an inaccessible organization chat before changing stream state', async () => {
+ mockGetAccessibleChat.mockResolvedValueOnce(null)
+ const response = await POST(abortRequest())
+ expect(response.status).toBe(404)
+ expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled()
+ expect(mockAbortActiveStream).not.toHaveBeenCalled()
+ })
+
+ it('refuses a chat ID that does not belong to the authenticated run', async () => {
+ mockGetLatestRunForStream.mockResolvedValueOnce({ chatId: 'different-chat' })
+ const response = await POST(abortRequest())
+ expect(response.status).toBe(404)
+ expect(mockAbortActiveStream).not.toHaveBeenCalled()
+ })
+
it('rejects an unauthenticated caller without touching either abort path', async () => {
mockAuthenticate.mockResolvedValue({ userId: undefined, isAuthenticated: false })
diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts
index bd90ccf1083..8aea11d6439 100644
--- a/apps/sim/app/api/copilot/chat/abort/route.ts
+++ b/apps/sim/app/api/copilot/chat/abort/route.ts
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot'
import { validationErrorResponse } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
@@ -29,8 +30,11 @@ export const POST = withRouteHandler((request: NextRequest) =>
TraceSpan.CopilotChatAbortStream,
undefined,
async (rootSpan) => {
- const { userId: authenticatedUserId, isAuthenticated } =
- await authenticateCopilotRequestSessionOnly()
+ const {
+ userId: authenticatedUserId,
+ isAuthenticated,
+ principal,
+ } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Unauthorized)
@@ -67,6 +71,15 @@ export const POST = withRouteHandler((request: NextRequest) =>
})
return null
})
+ if (!run || (chatId && chatId !== run.chatId)) {
+ return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
+ }
+ const chat = run.chatId
+ ? await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal })
+ : null
+ if (run.chatId && !chat) {
+ return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
+ }
if (!chatId && run?.chatId) {
chatId = run.chatId
}
@@ -98,6 +111,7 @@ export const POST = withRouteHandler((request: NextRequest) =>
userId: authenticatedUserId,
chatId,
workspaceId,
+ ...(chat?.organizationId ? { organizationId: chat.organizationId } : {}),
timeoutMs: GO_EXPLICIT_ABORT_TIMEOUT_MS,
})
goAbortOk = true
diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts
index 1b020709711..4a5c9ae2e73 100644
--- a/apps/sim/app/api/copilot/chat/route.ts
+++ b/apps/sim/app/api/copilot/chat/route.ts
@@ -1,10 +1,10 @@
import type { NextRequest } from 'next/server'
import { copilotChatGetContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
-import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
+import { handleUnifiedChatPost } from '@/lib/copilot/chat/post'
import { GET as getChat } from '@/app/api/copilot/chat/queries'
-export { maxDuration }
+export const maxDuration = 3600
export const POST = handleUnifiedChatPost
diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts
index 7c35ca8d355..ccee5a1bde7 100644
--- a/apps/sim/app/api/copilot/chat/stop/route.test.ts
+++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts
@@ -5,9 +5,15 @@ import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({
- mockAppendCopilotChatMessages: vi.fn(),
- mockPublishStatusChanged: vi.fn(),
+const { mockAppendCopilotChatMessages, mockPublishStatusChanged, mockGetAccessibleChat } =
+ vi.hoisted(() => ({
+ mockGetAccessibleChat: vi.fn(),
+ mockAppendCopilotChatMessages: vi.fn(),
+ mockPublishStatusChanged: vi.fn(),
+ }))
+
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({
+ getAccessibleCopilotChatAuth: mockGetAccessibleChat,
}))
vi.mock('@/lib/copilot/chat/messages-store', () => ({
@@ -49,7 +55,21 @@ describe('copilot chat stop route', () => {
// Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
dbChainMockFns.limit.mockReset()
resetDbChainMock()
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'user-1' },
+ session: { id: 'session-1' },
+ })
+ mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' })
+ })
+
+ it('does not persist stopped content after organization access is removed', async () => {
+ mockGetAccessibleChat.mockResolvedValueOnce(null)
+ const response = await POST(
+ createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'private' })
+ )
+ expect(response.status).toBe(200)
+ expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
+ expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
})
it('returns 401 when unauthenticated', async () => {
diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts
index 91f17dbcff3..ef02d470844 100644
--- a/apps/sim/app/api/copilot/chat/stop/route.ts
+++ b/apps/sim/app/api/copilot/chat/stop/route.ts
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatStopContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
normalizeMessage,
type PersistedMessage,
@@ -40,6 +41,10 @@ export const POST = withRouteHandler((req: NextRequest) =>
return parsed.response
}
const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body
+ const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
+ principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
+ })
+ if (!chat) return NextResponse.json({ success: true })
span.setAttributes({
[TraceAttr.ChatId]: chatId,
[TraceAttr.StreamId]: streamId,
diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts
index 24d7a99cfef..e93ca3121a1 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.test.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts
@@ -10,13 +10,23 @@ import {
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
-const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } =
- vi.hoisted(() => ({
- getLatestRunForStream: vi.fn(),
- readEvents: vi.fn(),
- readFilePreviewSessions: vi.fn(),
- checkForReplayGap: vi.fn(),
- }))
+const {
+ mockGetAccessibleChat,
+ getLatestRunForStream,
+ readEvents,
+ readFilePreviewSessions,
+ checkForReplayGap,
+} = vi.hoisted(() => ({
+ mockGetAccessibleChat: vi.fn(),
+ getLatestRunForStream: vi.fn(),
+ readEvents: vi.fn(),
+ readFilePreviewSessions: vi.fn(),
+ checkForReplayGap: vi.fn(),
+}))
+
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({
+ getAccessibleCopilotChatAuth: mockGetAccessibleChat,
+}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
getLatestRunForStream,
@@ -65,6 +75,7 @@ async function readAllChunks(response: Response): Promise {
describe('copilot chat stream replay route', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' })
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
@@ -74,6 +85,21 @@ describe('copilot chat stream replay route', () => {
checkForReplayGap.mockResolvedValue(null)
})
+ it('refuses replay after organization membership is removed', async () => {
+ getLatestRunForStream.mockResolvedValueOnce({
+ status: 'complete',
+ id: 'run-1',
+ chatId: 'chat-1',
+ })
+ mockGetAccessibleChat.mockResolvedValueOnce(null)
+ const response = await GET(
+ new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&batch=true')
+ )
+ expect(response.status).toBe(404)
+ expect(readEvents).not.toHaveBeenCalled()
+ expect(readFilePreviewSessions).not.toHaveBeenCalled()
+ })
+
it('returns preview sessions in batch mode', async () => {
getLatestRunForStream.mockResolvedValue({
status: 'active',
diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts
index 47b1f65c79c..1a56c7f8435 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.ts
@@ -1,4 +1,5 @@
import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api'
+import type { Principal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
@@ -6,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotChatStreamContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
@@ -115,8 +117,11 @@ function buildResumeTerminalEnvelopes(options: {
}
export const GET = withRouteHandler(async (request: NextRequest) => {
- const { userId: authenticatedUserId, isAuthenticated } =
- await authenticateCopilotRequestSessionOnly()
+ const {
+ userId: authenticatedUserId,
+ isAuthenticated,
+ principal,
+ } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -169,6 +174,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
afterCursor,
batchMode,
authenticatedUserId,
+ principal,
rootSpan,
rootContext,
})
@@ -186,6 +192,7 @@ async function handleResumeRequestBody({
afterCursor,
batchMode,
authenticatedUserId,
+ principal,
rootSpan,
rootContext,
}: {
@@ -194,6 +201,7 @@ async function handleResumeRequestBody({
afterCursor: string
batchMode: boolean
authenticatedUserId: string
+ principal?: Principal
rootSpan: Span
rootContext: Context
}) {
@@ -211,7 +219,11 @@ async function handleResumeRequestBody({
hasRun: !!run,
runStatus: run?.status,
})
- if (!run) {
+ if (
+ !run ||
+ (run.chatId &&
+ !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal })))
+ ) {
rootSpan.setAttribute(TraceAttr.CopilotResumeOutcome, CopilotResumeOutcome.StreamNotFound)
rootSpan.end()
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
@@ -323,6 +335,13 @@ async function handleResumeRequestBody({
request.signal.addEventListener('abort', abortListener, { once: true })
const flushEvents = async () => {
+ if (
+ run?.chatId &&
+ !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }))
+ ) {
+ closeController()
+ return
+ }
const events = await readEvents(streamId, cursor)
if (events.length > 0) {
logger.debug('[Resume] Flushing events', {
diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts
index d247d93ee99..b6a0c57c2ec 100644
--- a/apps/sim/app/api/copilot/tools/execute/route.ts
+++ b/apps/sim/app/api/copilot/tools/execute/route.ts
@@ -41,16 +41,20 @@ const turnRegistryCache = new Map<
async function getTurnEgressRegistry(
userId: string,
workspaceId: string | undefined,
- messageId: string | undefined
+ messageId: string | undefined,
+ requestMode?: string,
+ organizationId?: string
): Promise {
- const key = `${userId}\u0000${workspaceId ?? ''}\u0000${messageId ?? ''}`
+ const key = `${userId}\u0000${workspaceId ?? ''}\u0000${organizationId ?? ''}\u0000${messageId ?? ''}\u0000${requestMode ?? ''}`
const now = Date.now()
const hit = turnRegistryCache.get(key)
if (hit && hit.expiresAt > now) {
hit.expiresAt = now + TURN_REGISTRY_TTL_MS
return hit.registry
}
- const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId)
+ const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId, {
+ includeSecrets: requestMode !== 'assistant',
+ })
for (const [cachedKey, cached] of turnRegistryCache) {
if (cached.expiresAt <= now) turnRegistryCache.delete(cachedKey)
}
@@ -107,10 +111,13 @@ export const POST = withRouteHandler((request: NextRequest) =>
userId,
workflowId,
workspaceId,
+ organizationId,
chatId,
messageId,
parentToolCallId,
userPermission,
+ requestMode,
+ assistantSearch,
} = validation.data
rootSpan.setAttributes({
[TraceAttr.ToolName]: toolName,
@@ -121,7 +128,13 @@ export const POST = withRouteHandler((request: NextRequest) =>
let toolRegistry: ResolvedSecretTraceRegistry
let turnRegistry: ResolvedSecretTraceRegistry
try {
- turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId)
+ turnRegistry = await getTurnEgressRegistry(
+ userId,
+ workspaceId,
+ messageId,
+ requestMode,
+ organizationId
+ )
toolRegistry = turnRegistry.forkForInputPaths([])
} catch (err) {
/**
@@ -167,12 +180,16 @@ export const POST = withRouteHandler((request: NextRequest) =>
userId,
workflowId: workflowId ?? '',
workspaceId,
+ organizationId,
chatId,
messageId,
toolCallId,
parentToolCallId,
userPermission,
copilotToolExecution: true,
+ copilotInteractionMode: 'interactive',
+ requestMode,
+ assistantSearch,
resolvedSecretTraceRegistry: toolRegistry,
})
const projection = inspectToolResultForCopilot(result, toolRegistry, toolName)
diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts
index ff16fe62b7c..621463df095 100644
--- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts
+++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts
@@ -38,9 +38,9 @@ const context = {
params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }),
}
-function request() {
+function request(query = '') {
return new NextRequest(
- 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1'
+ `http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1${query}`
)
}
@@ -69,6 +69,41 @@ describe('credential group OAuth start route', () => {
})
})
+ it('forwards only the closed Search return context to the authorized operation', async () => {
+ await GET(request('?returnTo=search'), context)
+ expect(mocks.startOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal,
+ input: { invitationToken: 'invitation-token', optionId: 'option-1', returnTo: 'search' },
+ })
+ )
+ mocks.startOAuth.mockClear()
+ const response = await GET(request('?returnTo=https://external.test'), context)
+ expect(response.status).toBe(400)
+ expect(mocks.startOAuth).not.toHaveBeenCalled()
+ })
+
+ it.each(['ip', 'enrollment', 'unavailable', 'configuration'])(
+ 'preserves exact Search focus after %s failure',
+ async (failure) => {
+ if (failure === 'ip')
+ mocks.ipRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
+ if (failure === 'enrollment')
+ mocks.enrollmentRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
+ if (failure === 'unavailable') mocks.authenticate.mockResolvedValue(null)
+ if (failure === 'configuration')
+ mocks.startOAuth.mockRejectedValue(new Error('Unavailable configuration'))
+ const response = await GET(request('?returnTo=search'), context)
+ const location = new URL(response.headers.get('location')!, 'http://localhost')
+ expect(location.pathname).toBe('/credential-groups/enroll/invitation-token')
+ expect(location.searchParams.get('optionId')).toBe('option-1')
+ expect(location.searchParams.get('returnTo')).toBe('search')
+ expect(location.searchParams.get('oauth')).toBe(
+ failure === 'ip' || failure === 'enrollment' ? 'rate_limited' : 'unavailable'
+ )
+ }
+ )
+
it('returns an unavailable enrollment to its public page', async () => {
mocks.authenticate.mockResolvedValue(null)
diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts
index bc66315f68e..aa5582d7263 100644
--- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts
+++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts
@@ -29,25 +29,27 @@ export const GET = withRouteHandler(
const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context)
if (!parsed.success) return limited ?? parsed.response
const { token, optionId } = parsed.data.params
+ const { returnTo } = parsed.data.query
+ const focus: Record = returnTo ? { optionId, returnTo } : {}
if (limited) {
- return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
+ return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' })
}
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) {
- return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
+ return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'unavailable' })
}
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
principal.enrollmentId
)
if (enrollmentLimited) {
- return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
+ return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' })
}
try {
const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({
principal,
- input: { invitationToken: token, optionId },
+ input: { invitationToken: token, optionId, ...(returnTo ? { returnTo } : {}) },
request,
})
const response = NextResponse.redirect(authorizationUrl)
@@ -59,6 +61,7 @@ export const GET = withRouteHandler(
error: getErrorMessage(error),
})
return createCredentialGroupEnrollmentRedirect(token, {
+ ...focus,
oauth:
error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts
index b2a34e897bf..3ac896cdf46 100644
--- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts
+++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts
@@ -20,11 +20,22 @@ export function createCredentialGroupEnrollmentRedirect(
})
}
-export function createCredentialGroupCompletionRedirect(): NextResponse {
+export type CredentialGroupOAuthFailure =
+ | 'denied'
+ | 'account_mismatch'
+ | 'permissions_required'
+ | 'configuration_changed'
+ | 'rate_limited'
+ | 'unavailable'
+ | 'failed'
+
+export function createCredentialGroupCompletionRedirect(
+ oauth?: CredentialGroupOAuthFailure
+): NextResponse {
return new NextResponse(null, {
status: 303,
headers: {
- Location: '/credential-groups/complete',
+ Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`,
...NO_STORE_REDIRECT_HEADERS,
},
})
diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts
index 217ff6b8aa0..67d998aa631 100644
--- a/apps/sim/app/api/credential-groups/oauth-callback.ts
+++ b/apps/sim/app/api/credential-groups/oauth-callback.ts
@@ -3,15 +3,20 @@ import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups'
-import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
+import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
+import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import {
CredentialGroupInvitationUnavailableError,
CredentialGroupOAuthError,
} from '@/lib/credential-groups/provider-adapter'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
-import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
+import {
+ type CredentialGroupOAuthFailure,
+ createCredentialGroupCompletionRedirect,
+ createCredentialGroupEnrollmentRedirect,
+} from '@/app/api/credential-groups/enrollment-redirect'
const logger = createLogger('CredentialGroupOAuthCallbackAPI')
@@ -34,6 +39,12 @@ export async function handleCredentialGroupOAuthCallback({
try {
attempt = await consumeCredentialGroupOAuthAttempt(state)
} catch (error) {
+ if (error instanceof CredentialGroupOAuthStateVersionError) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 400, headers: { 'Cache-Control': 'no-store' } }
+ )
+ }
logger.error('Failed to consume credential group OAuth state', {
error: getErrorMessage(error),
})
@@ -49,34 +60,36 @@ export async function handleCredentialGroupOAuthCallback({
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
)
}
+ const focus: Record = attempt.returnTo
+ ? { optionId: attempt.optionId, returnTo: attempt.returnTo }
+ : {}
+ const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
+ attempt.completionRedirect
+ ? createCredentialGroupCompletionRedirect(oauth)
+ : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
if (limited) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'rate_limited',
- })
+ return failureRedirect('rate_limited')
}
if (providerError) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
+ return failureRedirect('denied')
}
if (!code) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
- }
-
- const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
- if (!principal) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'unavailable',
- })
+ return failureRedirect('failed')
}
try {
+ const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
await completePublicCredentialGroupOAuth.execute({
principal,
input: { attempt, code },
request,
})
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- connected: attempt.optionId,
- })
+ return attempt.completionRedirect
+ ? createCredentialGroupCompletionRedirect()
+ : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
+ ...focus,
+ connected: attempt.optionId,
+ })
} catch (error) {
logger.error('Managed OAuth authorization failed', {
provider,
@@ -92,6 +105,6 @@ export async function handleCredentialGroupOAuthCallback({
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
: 'failed'
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status })
+ return failureRedirect(status)
}
}
diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts
index 0a402c66585..7056e109ba9 100644
--- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts
+++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts
@@ -3,6 +3,7 @@
*/
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
@@ -12,7 +13,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
- authenticateCredentialGroupEnrollment: mocks.authenticate,
+ credentialGroupOAuthAttemptPrincipal: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
@@ -27,7 +28,10 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit,
}))
-import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter'
+import {
+ CredentialGroupInvitationUnavailableError,
+ CredentialGroupOAuthError,
+} from '@/lib/credential-groups/provider-adapter'
import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route'
const principal = {
@@ -56,7 +60,7 @@ describe('credential group OAuth callback', () => {
vi.clearAllMocks()
mocks.rateLimit.mockResolvedValue(null)
mocks.consumeAttempt.mockResolvedValue(attempt)
- mocks.authenticate.mockResolvedValue(principal)
+ mocks.authenticate.mockReturnValue(principal)
mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' })
})
@@ -65,6 +69,7 @@ describe('credential group OAuth callback', () => {
const response = await GET(callbackRequest, context)
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
+ expect(mocks.authenticate).toHaveBeenCalledWith(attempt)
expect(mocks.completeOAuth).toHaveBeenCalledWith({
principal,
input: { attempt, code: 'code-1' },
@@ -76,6 +81,56 @@ describe('credential group OAuth callback', () => {
)
})
+ it('restores the exact focused option after a successful Search connection', async () => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, optionId: 'site-two', returnTo: 'search' })
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.headers.get('location')).toBe(
+ '/credential-groups/enroll/invitation-token?optionId=site-two&returnTo=search&connected=site-two'
+ )
+ expect(mocks.completeOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal,
+ input: expect.objectContaining({
+ attempt: expect.objectContaining({ optionId: 'site-two' }),
+ }),
+ })
+ )
+ })
+
+ it.each([
+ [new CredentialGroupInvitationUnavailableError(), 'unavailable'],
+ [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
+ [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
+ [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
+ [new Error('Provider failed'), 'failed'],
+ ])('retains Search focus after a rejected provider exchange: %s', async (error, status) => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' })
+ mocks.completeOAuth.mockRejectedValueOnce(error)
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.headers.get('location')).toBe(
+ `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}`
+ )
+ })
+
+ it.each(['denied', 'rate_limited'])(
+ 'retains Search focus without exchanging after %s',
+ async (status) => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' })
+ if (status === 'rate_limited')
+ mocks.rateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
+ const response = await GET(
+ request(
+ status === 'denied' ? 'state=state-1&error=access_denied' : 'state=state-1&code=code-1'
+ ),
+ context
+ )
+ expect(response.headers.get('location')).toBe(
+ `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}`
+ )
+ expect(mocks.completeOAuth).not.toHaveBeenCalled()
+ }
+ )
+
it('rejects standard providers on the custom callback route', async () => {
const response = await GET(
new NextRequest(
@@ -113,7 +168,7 @@ describe('credential group OAuth callback', () => {
})
it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => {
- mocks.authenticate.mockResolvedValue(null)
+ mocks.completeOAuth.mockRejectedValueOnce(new CredentialGroupInvitationUnavailableError())
const response = await GET(request('state=state-1&code=code-1'), context)
@@ -121,7 +176,7 @@ describe('credential group OAuth callback', () => {
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=unavailable'
)
- expect(mocks.completeOAuth).not.toHaveBeenCalled()
+ expect(mocks.completeOAuth).toHaveBeenCalledOnce()
})
it('returns an unavailable enrollment redirect when the invitation is revoked during exchange', async () => {
@@ -150,4 +205,59 @@ describe('credential group OAuth callback', () => {
expect(mocks.authenticate).not.toHaveBeenCalled()
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
+
+ it('returns personal connections to the fixed completion page after invitation rotation', async () => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe('/credential-groups/complete')
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ expect(response.headers.get('referrer-policy')).toBe('no-referrer')
+ })
+
+ it.each([['error=access_denied', 'denied']])(
+ 'shows personal callback failure without reopening a stale invitation: %s',
+ async (query, status) => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
+ const response = await GET(request(`state=state-1&${query}`), context)
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`)
+ expect(mocks.completeOAuth).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each([
+ [new CredentialGroupInvitationUnavailableError(), 'unavailable'],
+ [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'],
+ [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'],
+ [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'],
+ [new Error('Provider failed'), 'failed'],
+ ])('shows failed personal authorization on the completion page', async (error, status) => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
+ mocks.completeOAuth.mockRejectedValueOnce(error)
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`)
+ })
+
+ it('shows rate limits on the personal completion page without exchanging', async () => {
+ mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true })
+ mocks.rateLimit.mockResolvedValue(
+ NextResponse.json({ error: 'Too many requests' }, { status: 429 })
+ )
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.status).toBe(303)
+ expect(response.headers.get('location')).toBe('/credential-groups/complete?oauth=rate_limited')
+ expect(mocks.completeOAuth).not.toHaveBeenCalled()
+ })
+ it('reports a state protocol change as an explicit restart without exchanging a code', async () => {
+ mocks.consumeAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError())
+ const response = await GET(request('state=state-1&code=code-1'), context)
+ expect(response.status).toBe(400)
+ expect(await response.json()).toEqual({
+ error: expect.stringContaining('Reopen your invitation and connect again'),
+ })
+ expect(mocks.authenticate).not.toHaveBeenCalled()
+ expect(mocks.completeOAuth).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/api/credentials/personal/connect/route.ts b/apps/sim/app/api/credentials/personal/connect/route.ts
new file mode 100644
index 00000000000..d7ea060700b
--- /dev/null
+++ b/apps/sim/app/api/credentials/personal/connect/route.ts
@@ -0,0 +1,20 @@
+import { startPersonalCredentialConnectionContract } from '@/lib/api/contracts/credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection'
+
+export const POST = defineInternalJsonRoute({
+ contract: startPersonalCredentialConnectionContract,
+ auth: internalSessionAuth,
+ operation: credentialOperations.startPersonalConnection,
+ rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.connect' }),
+ errorPolicy: internalPersonalCredentialConnectionErrorPolicy,
+ mapInput: ({ body }) => body,
+ useCase: startPersonalCredentialConnection,
+ present: (result) => result,
+})
diff --git a/apps/sim/app/api/credentials/personal/route.ts b/apps/sim/app/api/credentials/personal/route.ts
new file mode 100644
index 00000000000..61eb9d0f1cb
--- /dev/null
+++ b/apps/sim/app/api/credentials/personal/route.ts
@@ -0,0 +1,26 @@
+import { listPersonalCredentialsContract } from '@/lib/api/contracts/credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
+import { credentialOperations } from '@/lib/credentials/application/operations'
+import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials'
+
+export const GET = defineInternalJsonRoute({
+ contract: listPersonalCredentialsContract,
+ auth: internalSessionAuth,
+ operation: credentialOperations.listPersonal,
+ rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.list' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ query }) => query,
+ useCase: listPersonalCredentials,
+ present: ({ credentials }) => ({
+ credentials: credentials.map((entry) => ({
+ ...entry,
+ updatedAt: entry.updatedAt.toISOString(),
+ connectedAt: entry.connectedAt.toISOString(),
+ })),
+ }),
+})
diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
index 65e2fe04550..cfc34b07b96 100644
--- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
+++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
@@ -84,7 +84,7 @@ describe('Enterprise owner claim routes', () => {
mocks.acceptClaim.mockResolvedValue({
success: true,
claim,
- redirectPath: '/workspace',
+ redirectPath: '/home',
})
const response = await POST(
diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts
index f0ac6468536..13aaf73db66 100644
--- a/apps/sim/app/api/files/authorization.test.ts
+++ b/apps/sim/app/api/files/authorization.test.ts
@@ -33,7 +33,9 @@ vi.mock('@/lib/uploads/server/metadata', () => ({
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
- inferContextFromKey: vi.fn(() => 'knowledge-base'),
+ inferContextFromKey: vi.fn((key: string) =>
+ key.startsWith('kb/') ? 'knowledge-base' : key.split('/')[0]
+ ),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
@@ -44,6 +46,7 @@ vi.mock('@/executor/constants', () => ({
isUuid: vi.fn(() => false),
}))
+import { SYSTEM_ACCESS_SCOPE } from '@/lib/knowledge/access/types'
import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization'
const CLOUD_KEY = 'kb/1780162789495-secret.txt'
@@ -322,3 +325,70 @@ describe('workspace-scoped access (workspace files and mothership attachments)',
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
})
+
+describe('organization connector cache access', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetFileMetadataByKey.mockResolvedValue({
+ workspaceId: null,
+ organizationId: 'org-1',
+ userId: USER_ID,
+ deletedAt: null,
+ })
+ mockGetUserEntityPermissions.mockResolvedValue('admin')
+ mockGetFileMetadata.mockResolvedValue({ userId: USER_ID })
+ dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }])
+ })
+
+ it.each(['general', 'profile-pictures', 'knowledge-base'] as const)(
+ 'denies the uploader a raw download even with a forged %s context',
+ async (context) => {
+ await expect(
+ verifyFileAccess(CLOUD_KEY, USER_ID, undefined, context, false, { knowledgeAccess: 'user' })
+ ).resolves.toBe(false)
+ expect(mockGetFileMetadata).not.toHaveBeenCalled()
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ }
+ )
+
+ it('allows the internal processor to read a live bound connector cache', async () => {
+ await expect(
+ verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, {
+ knowledgeAccess: SYSTEM_ACCESS_SCOPE,
+ })
+ ).resolves.toBe(true)
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ })
+
+ it('denies system reads after the cache loses its active document reference', async () => {
+ dbChainMockFns.limit.mockResolvedValue([])
+ await expect(
+ verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, {
+ knowledgeAccess: SYSTEM_ACCESS_SCOPE,
+ })
+ ).resolves.toBe(false)
+ })
+
+ it('denies a binding claiming both organization and workspace ownership', async () => {
+ mockGetFileMetadataByKey.mockResolvedValue({
+ organizationId: 'org-1',
+ workspaceId: 'ws-1',
+ deletedAt: null,
+ })
+ await expect(
+ verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, {
+ knowledgeAccess: SYSTEM_ACCESS_SCOPE,
+ })
+ ).resolves.toBe(false)
+ await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
+ })
+
+ it('does not let a raw download endpoint delete organization caches', async () => {
+ await expect(
+ verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'general', false, {
+ requireWrite: true,
+ knowledgeAccess: SYSTEM_ACCESS_SCOPE,
+ })
+ ).resolves.toBe(false)
+ })
+})
diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts
index dbac5ed031e..a847a5748ac 100644
--- a/apps/sim/app/api/files/authorization.ts
+++ b/apps/sim/app/api/files/authorization.ts
@@ -2,8 +2,10 @@ import { db } from '@sim/db'
import { document, knowledgeBase, workspaceFile } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { permissionSatisfies } from '@sim/platform-authz/workspace'
-import { and, eq, isNull } from 'drizzle-orm'
+import { and, eq, isNotNull, isNull, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate'
import {
resolveUserKnowledgeAccessScope,
@@ -151,6 +153,12 @@ export async function verifyFileAccess(
): Promise {
const requireWrite = options?.requireWrite ?? false
try {
+ const keyContext = inferContextFromKey(cloudKey)
+ if (keyContext === 'knowledge-base') {
+ return requireWrite
+ ? verifyKBFileWriteAccess(cloudKey, userId)
+ : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess)
+ }
if (context === 'general') {
return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite)
}
@@ -188,7 +196,9 @@ export async function verifyFileAccess(
// 4. KB files: kb/filename
if (inferredContext === 'knowledge-base') {
- return await verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess)
+ return requireWrite
+ ? verifyKBFileWriteAccess(cloudKey, userId)
+ : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess)
}
// 5. Chat files: chat/filename
@@ -310,7 +320,7 @@ async function verifyPublicAssetWriteAccess(
try {
if (context === 'workspace-logos') {
const binding = await getFileMetadataByKey(cloudKey, 'workspace-logos')
- if (!binding?.workspaceId) {
+ if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) {
logger.warn('workspace-logos delete denied: no ownership binding', { userId, cloudKey })
return false
}
@@ -496,7 +506,7 @@ type ResolvedKnowledgeFileAccess = KnowledgeAccessScope | SystemAccessScope
async function hasActiveKbDocumentForKey(
cloudKey: string,
- workspaceId: string,
+ scope: ResourceScope,
access: ResolvedKnowledgeFileAccess
): Promise {
const rows = await db
@@ -505,12 +515,15 @@ async function hasActiveKbDocumentForKey(
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(
and(
- eq(knowledgeBase.workspaceId, workspaceId),
+ resourceScopeCondition(knowledgeBase, scope),
eq(document.storageKey, cloudKey),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt),
isNull(knowledgeBase.deletedAt),
+ access.kind === 'system'
+ ? undefined
+ : or(isNull(document.connectorId), isNotNull(document.contentHash)),
knowledgeAccessCondition(access)
)
)
@@ -572,6 +585,19 @@ async function verifyKBFileAccess(
logger.warn('KB file access denied for deleted file binding', { userId, cloudKey })
return false
}
+ if (binding.organizationId) {
+ if (
+ binding.workspaceId ||
+ typeof knowledgeAccess !== 'object' ||
+ knowledgeAccess.kind !== 'system'
+ )
+ return false
+ return hasActiveKbDocumentForKey(
+ cloudKey,
+ { kind: 'organization', organizationId: binding.organizationId },
+ knowledgeAccess
+ )
+ }
if (!binding.workspaceId) {
logger.warn('KB file binding missing workspace owner', { userId, cloudKey })
return false
@@ -588,7 +614,13 @@ async function verifyKBFileAccess(
}
const access = await resolveKnowledgeFileAccess(knowledgeAccess, userId, binding.workspaceId)
- if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId, access))) {
+ if (
+ !(await hasActiveKbDocumentForKey(
+ cloudKey,
+ { kind: 'workspace', workspaceId: binding.workspaceId },
+ access
+ ))
+ ) {
logger.warn('KB file access denied: no readable document references the file', {
userId,
cloudKey,
@@ -619,7 +651,7 @@ async function verifyKBFileAccess(
export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise {
try {
const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base')
- if (!binding?.workspaceId) {
+ if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) {
logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey })
return false
}
diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts
index d6008ad1748..458c1658863 100644
--- a/apps/sim/app/api/files/uploads/purposes.ts
+++ b/apps/sim/app/api/files/uploads/purposes.ts
@@ -254,6 +254,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads')
case 'system':
throw new UploadSessionError('forbidden', 'System principals cannot create uploads')
+ case 'organization_delegated':
case 'credential_group_enrollment':
throw new UploadSessionError(
'forbidden',
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts
index ab89571ab7e..af895bf5a0d 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts
@@ -24,8 +24,6 @@ export const PATCH = defineInternalJsonRoute({
connectorId: params.connectorId,
knowledgeBaseId: params.id,
accessMode: body.accessMode,
- credentialGroupId: body.credentialGroupId,
- credentialGroupOptionId: body.credentialGroupOptionId,
credentialId: body.credentialId,
resolveBillingAttribution: (workspaceId: string) =>
resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId),
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
index 28199775a22..10eeff6ea5a 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
@@ -50,8 +50,6 @@ export const POST = defineInternalJsonRoute({
sourceConfig: body.sourceConfig,
syncIntervalMinutes: body.syncIntervalMinutes,
accessMode: body.accessMode,
- credentialGroupId: body.credentialGroupId,
- credentialGroupOptionId: body.credentialGroupOptionId,
resolveBillingAttribution: (workspaceId: string) =>
resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId),
source: 'ui' as const,
diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts
new file mode 100644
index 00000000000..f9b396d2cda
--- /dev/null
+++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts
@@ -0,0 +1,153 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ createMockRequest,
+ flattenMockConditions,
+ hasMockCondition,
+ schemaMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockVerifyCronAuth, mockConnectorRows, mockDispatch, mockClaim, mockWhere } = vi.hoisted(
+ () => ({
+ mockVerifyCronAuth: vi.fn(() => null),
+ mockConnectorRows: vi.fn(),
+ mockDispatch: vi.fn(),
+ mockClaim: vi.fn(),
+ mockWhere: vi.fn(),
+ })
+)
+
+vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
+vi.mock('@/lib/knowledge/connectors/directory-queue', () => ({
+ dispatchDirectorySync: mockDispatch,
+}))
+vi.mock('@sim/db', () => ({
+ db: {
+ update: () => ({ set: () => ({ where: () => ({ returning: () => mockClaim() }) }) }),
+ select: () => ({
+ from: () => ({
+ innerJoin: () => ({
+ where: (condition: unknown) => {
+ mockWhere(condition)
+ return { orderBy: () => ({ limit: () => mockConnectorRows() }) }
+ },
+ }),
+ }),
+ }),
+ },
+}))
+
+import { GET } from '@/app/api/knowledge/connectors/directory-sync/route'
+
+function connector(overrides: Record = {}) {
+ return { id: 'connector-1', nextDirectorySyncAt: new Date(0), ...overrides }
+}
+
+async function run() {
+ const response = await GET(createMockRequest('GET'))
+ return response.json()
+}
+
+describe('connector directory sync scheduler', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockVerifyCronAuth.mockReturnValue(null)
+ mockDispatch.mockResolvedValue(undefined)
+ mockClaim.mockResolvedValue([{ id: 'connector-1' }])
+ })
+
+ /**
+ * Every eligible connector is offered under one tick time; the tenant-level
+ * freshness check in the refresh, not the scheduler, decides which walk.
+ */
+ it('dispatches a refresh for every admin-mode connector under the same tick', async () => {
+ mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })])
+
+ await expect(run()).resolves.toMatchObject({ considered: 2, dispatched: 2, failed: 0 })
+ expect(mockDispatch).toHaveBeenCalledTimes(2)
+ const [, first] = mockDispatch.mock.calls[0]
+ const [, second] = mockDispatch.mock.calls[1]
+ expect(first.tickAt).toBe(second.tickAt)
+ })
+
+ it('includes either canonical owner while retaining mirrored-source eligibility', async () => {
+ mockConnectorRows.mockResolvedValue([connector({ id: 'org-source' })])
+ await run()
+ const condition = mockWhere.mock.calls[0][0]
+ const ownerChoice = flattenMockConditions(condition).find((entry) => entry.type === 'or')
+ expect(ownerChoice).toBeDefined()
+ expect(ownerChoice?.conditions).toHaveLength(2)
+ const [workspaceOwner, organizationOwner] = Array.isArray(ownerChoice?.conditions)
+ ? ownerChoice.conditions
+ : []
+ expect(
+ hasMockCondition(
+ workspaceOwner,
+ (node) => node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.workspaceId
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ workspaceOwner,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.organizationId
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ organizationOwner,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.workspaceId
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ organizationOwner,
+ (node) =>
+ node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.organizationId
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ condition,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ condition,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ condition,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt
+ )
+ ).toBe(true)
+ expect(mockDispatch).toHaveBeenCalledExactlyOnceWith('org-source', expect.anything())
+ })
+
+ it('contains a dispatch failure to the connector that caused it', async () => {
+ mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })])
+ mockDispatch.mockRejectedValueOnce(new Error('queue unreachable'))
+
+ await expect(run()).resolves.toMatchObject({ dispatched: 1, failed: 1 })
+ })
+
+ it('does not enqueue a connector another scheduler claimed or paused', async () => {
+ mockConnectorRows.mockResolvedValue([connector()])
+ mockClaim.mockResolvedValueOnce([])
+ await expect(run()).resolves.toMatchObject({ considered: 1, dispatched: 0, failed: 0 })
+ expect(mockDispatch).not.toHaveBeenCalled()
+ })
+
+ it('refuses an unauthenticated tick', async () => {
+ mockVerifyCronAuth.mockReturnValue(new Response('nope', { status: 401 }))
+
+ const response = await GET(createMockRequest('GET'))
+
+ expect(response.status).toBe(401)
+ expect(mockConnectorRows).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts
new file mode 100644
index 00000000000..9867a9823cd
--- /dev/null
+++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts
@@ -0,0 +1,89 @@
+import { db } from '@sim/db'
+import { knowledgeBase, knowledgeConnector } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { verifyCronAuth } from '@/lib/auth/internal'
+import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { EXTERNAL_GROUP_SYNC_INTERVAL_MS } from '@/lib/knowledge/access/external-groups'
+import { MIRRORING_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes'
+import { dispatchDirectorySync } from '@/lib/knowledge/connectors/directory-queue'
+import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('ConnectorDirectorySyncSchedulerAPI')
+
+/** Connectors offered per tick, and how many dispatches are in flight at once. */
+const MAX_DIRECTORIES_PER_TICK = 200
+const DISPATCH_CONCURRENCY = 8
+
+/** Offers the oldest due directories first; successful claims advance across bounded ticks. */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ const tickAt = new Date()
+ logger.info('Connector directory sync scheduler triggered')
+
+ const authError = verifyCronAuth(request, 'Connector directory sync scheduler')
+ if (authError) return authError
+
+ const connectors = await db
+ .select({ id: knowledgeConnector.id })
+ .from(knowledgeConnector)
+ .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
+ .where(
+ and(
+ inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES),
+ lte(knowledgeConnector.nextDirectorySyncAt, tickAt),
+ inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES),
+ isNull(knowledgeConnector.archivedAt),
+ isNull(knowledgeConnector.deletedAt),
+ isNull(knowledgeBase.deletedAt),
+ or(
+ and(isNotNull(knowledgeBase.workspaceId), isNull(knowledgeBase.organizationId)),
+ and(isNull(knowledgeBase.workspaceId), isNotNull(knowledgeBase.organizationId))
+ )
+ )
+ )
+ .orderBy(asc(knowledgeConnector.nextDirectorySyncAt), asc(knowledgeConnector.id))
+ .limit(MAX_DIRECTORIES_PER_TICK)
+
+ let dispatched = 0
+ let failed = 0
+ await mapWithConcurrency(connectors, DISPATCH_CONCURRENCY, async ({ id: connectorId }) => {
+ try {
+ const claimed = await db
+ .update(knowledgeConnector)
+ .set({
+ nextDirectorySyncAt: new Date(tickAt.getTime() + EXTERNAL_GROUP_SYNC_INTERVAL_MS),
+ })
+ .where(
+ and(
+ eq(knowledgeConnector.id, connectorId),
+ lte(knowledgeConnector.nextDirectorySyncAt, tickAt),
+ inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES),
+ inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES),
+ isNull(knowledgeConnector.archivedAt),
+ isNull(knowledgeConnector.deletedAt)
+ )
+ )
+ .returning({ id: knowledgeConnector.id })
+ if (!claimed.length) return
+ await dispatchDirectorySync(connectorId, { requestId, tickAt })
+ dispatched += 1
+ } catch (error) {
+ failed += 1
+ logger.error('Failed to dispatch a directory refresh', {
+ connectorId,
+ error: getErrorMessage(error),
+ })
+ }
+ })
+
+ const summary = { considered: connectors.length, dispatched, failed }
+ logger.info('Connector directory sync scheduler finished', summary)
+ return Response.json({ success: true, ...summary })
+})
diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts
new file mode 100644
index 00000000000..c045b61d8c9
--- /dev/null
+++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts
@@ -0,0 +1,132 @@
+/** @vitest-environment node */
+import {
+ createMockRequest,
+ dbChainMockFns,
+ hasMockCondition,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ auth: vi.fn(),
+ dispatch: vi.fn(),
+ workspaceBilling: vi.fn(),
+ organizationBilling: vi.fn(),
+ sweep: vi.fn(),
+}))
+vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.auth }))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ resolveSystemBillingAttribution: mocks.workspaceBilling,
+ resolveSystemOrganizationBillingAttribution: mocks.organizationBilling,
+}))
+vi.mock('@/lib/knowledge/connectors/member-queue', () => ({
+ dispatchMemberSync: mocks.dispatch,
+ QUEUEABLE_MEMBER_SYNC_STATUSES: ['idle', 'error'],
+}))
+vi.mock('@/lib/knowledge/connectors/member-observations', () => ({
+ sweepStaleMemberObservations: mocks.sweep,
+}))
+
+import { GET } from '@/app/api/knowledge/connectors/member-sync/route'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mocks.auth.mockReturnValue(null)
+ mocks.dispatch.mockResolvedValue(undefined)
+ mocks.sweep.mockResolvedValue({ members: 0 })
+ mocks.workspaceBilling.mockResolvedValue({ workspaceId: 'workspace-a' })
+ mocks.organizationBilling.mockResolvedValue({ workspaceId: null, organizationId: 'org-a' })
+})
+
+describe('member sync scheduler owner routing', () => {
+ it('does not read or dispatch without cron authentication', async () => {
+ mocks.auth.mockReturnValue(new Response('Unauthorized', { status: 401 }))
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(401)
+ expect(dbChainMockFns.select).not.toHaveBeenCalled()
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(mocks.dispatch).not.toHaveBeenCalled()
+ })
+
+ it('projects org ownership and dispatches with its actual system payer', async () => {
+ const nextMemberSyncAt = new Date('2026-09-01T00:00:00Z')
+ queueTableRows(schemaMock.knowledgeConnector, [
+ { id: 'org-source', workspaceId: null, organizationId: 'org-a', nextMemberSyncAt },
+ ])
+ await GET(createMockRequest('GET'))
+ expect(dbChainMockFns.select).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: schemaMock.knowledgeBase.workspaceId,
+ organizationId: schemaMock.knowledgeBase.organizationId,
+ })
+ )
+ expect(mocks.organizationBilling).toHaveBeenCalledExactlyOnceWith('org-a')
+ expect(mocks.workspaceBilling).not.toHaveBeenCalled()
+ expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith('org-source', {
+ billingAttribution: { workspaceId: null, organizationId: 'org-a' },
+ expectedNextMemberSyncAt: nextMemberSyncAt,
+ requestId: expect.any(String),
+ requireRunnable: true,
+ })
+ const where = dbChainMockFns.where.mock.calls.at(-1)?.[0]
+ expect(
+ hasMockCondition(
+ where,
+ (node) =>
+ node.type === 'eq' &&
+ node.left === schemaMock.knowledgeConnector.accessMode &&
+ node.right === 'members'
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ where,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ where,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ where,
+ (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt
+ )
+ ).toBe(true)
+ })
+
+ it('preserves workspace dispatch and refuses absent or ambiguous ownership', async () => {
+ queueTableRows(schemaMock.knowledgeConnector, [
+ { id: 'missing', workspaceId: null, organizationId: null },
+ { id: 'ambiguous', workspaceId: 'workspace-a', organizationId: 'org-a' },
+ { id: 'workspace-source', workspaceId: 'workspace-a', organizationId: null },
+ ])
+ await GET(createMockRequest('GET'))
+ expect(mocks.organizationBilling).not.toHaveBeenCalled()
+ expect(mocks.workspaceBilling).toHaveBeenCalledExactlyOnceWith('workspace-a')
+ expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith(
+ 'workspace-source',
+ expect.objectContaining({
+ billingAttribution: { workspaceId: 'workspace-a' },
+ requireRunnable: true,
+ })
+ )
+ })
+
+ it('does not enqueue an org source when its payer cannot be resolved', async () => {
+ queueTableRows(schemaMock.knowledgeConnector, [
+ { id: 'org-source', workspaceId: null, organizationId: 'org-a' },
+ ])
+ mocks.organizationBilling.mockRejectedValue(new Error('Organization payer unavailable'))
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(200)
+ expect(mocks.dispatch).not.toHaveBeenCalled()
+ expect(mocks.workspaceBilling).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
index 076344f81dd..10088e0cb14 100644
--- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
+++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
@@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
-import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution'
+import {
+ resolveSystemBillingAttribution,
+ resolveSystemOrganizationBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -20,6 +24,7 @@ import {
MAX_CONSECUTIVE_FAILURES,
MEMBER_SYNC_STALE_LOCK_TTL_MS,
} from '@/lib/knowledge/connectors/sync-limits'
+import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock'
export const dynamic = 'force-dynamic'
@@ -162,13 +167,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
id: knowledgeConnector.id,
nextMemberSyncAt: knowledgeConnector.nextMemberSyncAt,
workspaceId: knowledgeBase.workspaceId,
+ organizationId: knowledgeBase.organizationId,
})
.from(knowledgeConnector)
.innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
.where(
and(
eq(knowledgeConnector.accessMode, 'members'),
- inArray(knowledgeConnector.status, ['active', 'error']),
+ inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES),
inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES),
lte(knowledgeConnector.nextMemberSyncAt, now),
isNull(knowledgeConnector.archivedAt),
@@ -191,10 +197,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => {
try {
- if (!connector.workspaceId) {
- throw new Error(`Connector ${connector.id} is missing workspace billing context`)
- }
- const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId)
+ const scope = resourceScopeFromOwner(connector)
+ const billingAttribution =
+ scope.kind === 'organization'
+ ? await resolveSystemOrganizationBillingAttribution(scope.organizationId)
+ : await resolveSystemBillingAttribution(scope.workspaceId)
await dispatchMemberSync(connector.id, {
billingAttribution,
expectedNextMemberSyncAt: connector.nextMemberSyncAt ?? undefined,
diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts
index 2c8a6c2982b..1dd99647867 100644
--- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts
+++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts
@@ -27,18 +27,25 @@ import {
MAX_CONSECUTIVE_FAILURES,
} from '@/lib/knowledge/connectors/sync-limits'
-const { mockVerifyCronAuth, mockDispatchSync, mockResolveSystemBillingAttribution } = vi.hoisted(
- () => ({
- mockVerifyCronAuth: vi.fn().mockReturnValue(null),
- mockDispatchSync: vi.fn().mockResolvedValue(undefined),
- mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }),
- })
-)
+const {
+ mockVerifyCronAuth,
+ mockDispatchSync,
+ mockResolveSystemBillingAttribution,
+ mockResolveSystemOrganizationBillingAttribution,
+} = vi.hoisted(() => ({
+ mockVerifyCronAuth: vi.fn().mockReturnValue(null),
+ mockDispatchSync: vi.fn().mockResolvedValue(undefined),
+ mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }),
+ mockResolveSystemOrganizationBillingAttribution: vi
+ .fn()
+ .mockResolvedValue({ workspaceId: null, organizationId: 'org-1' }),
+}))
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync }))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
+ resolveSystemOrganizationBillingAttribution: mockResolveSystemOrganizationBillingAttribution,
}))
import { GET } from '@/app/api/knowledge/connectors/sync/route'
@@ -131,6 +138,10 @@ beforeEach(() => {
mockVerifyCronAuth.mockReturnValue(null)
mockDispatchSync.mockResolvedValue(undefined)
mockResolveSystemBillingAttribution.mockResolvedValue({ workspaceId: 'ws-1' })
+ mockResolveSystemOrganizationBillingAttribution.mockResolvedValue({
+ workspaceId: null,
+ organizationId: 'org-1',
+ })
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
@@ -550,7 +561,7 @@ describe('connector sync scheduler authentication and dispatch', () => {
)
})
- it('skips a connector missing workspace billing context without failing the tick', async () => {
+ it('skips a connector missing resource billing context without failing the tick', async () => {
queueTableRows(schemaMock.knowledgeConnector, [
{ id: 'due-1', workspaceId: null },
{ id: 'due-2', workspaceId: 'ws-2' },
@@ -563,6 +574,42 @@ describe('connector sync scheduler authentication and dispatch', () => {
expect(mockDispatchSync).toHaveBeenCalledWith('due-2', expect.anything())
})
+ it('dispatches org sources with their canonical organization payer', async () => {
+ const nextSyncAt = new Date('2026-09-01T00:00:00Z')
+ queueTableRows(schemaMock.knowledgeConnector, [
+ { id: 'org-source', workspaceId: null, organizationId: 'org-1', nextSyncAt },
+ ])
+ await GET(cronRequest())
+ expect(dbChainMockFns.select).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: schemaMock.knowledgeBase.workspaceId,
+ organizationId: schemaMock.knowledgeBase.organizationId,
+ })
+ )
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1')
+ expect(mockDispatchSync).toHaveBeenCalledExactlyOnceWith('org-source', {
+ billingAttribution: { workspaceId: null, organizationId: 'org-1' },
+ expectedNextSyncAt: nextSyncAt,
+ requestId: expect.any(String),
+ requireRunnable: true,
+ })
+ })
+
+ it('does not infer a payer for ambiguous ownership or failed org billing', async () => {
+ queueTableRows(schemaMock.knowledgeConnector, [
+ { id: 'ambiguous', workspaceId: 'ws-1', organizationId: 'org-1' },
+ { id: 'org-source', workspaceId: null, organizationId: 'org-1' },
+ ])
+ mockResolveSystemOrganizationBillingAttribution.mockRejectedValue(
+ new Error('Owner unavailable')
+ )
+ await GET(cronRequest())
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1')
+ expect(mockDispatchSync).not.toHaveBeenCalled()
+ })
+
it('reports a tick with nothing due', async () => {
const response = await GET(cronRequest())
diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts
index 7d3d5afcbd6..e28e8561358 100644
--- a/apps/sim/app/api/knowledge/connectors/sync/route.ts
+++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts
@@ -4,10 +4,15 @@ import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
-import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution'
+import {
+ resolveSystemBillingAttribution,
+ resolveSystemOrganizationBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes'
import { dispatchSync } from '@/lib/knowledge/connectors/queue'
import {
CONNECTOR_AUTO_DISABLED_ERROR,
@@ -16,6 +21,7 @@ import {
CONNECTOR_SYNC_STALE_LOCK_TTL_MS,
MAX_CONSECUTIVE_FAILURES,
} from '@/lib/knowledge/connectors/sync-limits'
+import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock'
export const dynamic = 'force-dynamic'
@@ -298,13 +304,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
id: knowledgeConnector.id,
nextSyncAt: knowledgeConnector.nextSyncAt,
workspaceId: knowledgeBase.workspaceId,
+ organizationId: knowledgeBase.organizationId,
})
.from(knowledgeConnector)
.innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
.where(
and(
- inArray(knowledgeConnector.status, ['active', 'error']),
- eq(knowledgeConnector.accessMode, 'workspace'),
+ inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES),
+ inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES),
lte(knowledgeConnector.nextSyncAt, now),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt),
@@ -326,10 +333,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => {
try {
- if (!connector.workspaceId) {
- throw new Error(`Connector ${connector.id} is missing workspace billing context`)
- }
- const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId)
+ const scope = resourceScopeFromOwner(connector)
+ const billingAttribution =
+ scope.kind === 'organization'
+ ? await resolveSystemOrganizationBillingAttribution(scope.organizationId)
+ : await resolveSystemBillingAttribution(scope.workspaceId)
await dispatchSync(connector.id, {
billingAttribution,
expectedNextSyncAt: connector.nextSyncAt ?? undefined,
diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts
new file mode 100644
index 00000000000..e02b67e4203
--- /dev/null
+++ b/apps/sim/app/api/knowledge/search/route.test.ts
@@ -0,0 +1,64 @@
+/**
+ * @vitest-environment node
+ */
+import { authMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ search: vi.fn() }))
+vi.mock('@/lib/knowledge/application/workspace-search', () => ({
+ searchScopedKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search },
+}))
+
+import { POST } from '@/app/api/knowledge/search/route'
+
+describe('workspace search route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' },
+ session: { id: 'session-1' },
+ })
+ mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] })
+ })
+
+ it('passes the authenticated request cancellation signal through the existing operation', async () => {
+ const controller = new AbortController()
+ const request = new NextRequest('http://localhost/api/knowledge/search', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ workspaceId: 'workspace-1',
+ filters: { source: 'slack', documentIds: ['doc-1'] },
+ query: 'Orion',
+ }),
+ signal: controller.signal,
+ })
+ const response = await POST(request)
+ expect(response.status).toBe(200)
+ const call = mocks.search.mock.calls[0][0]
+ expect(call.principal).toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' })
+ expect(call.input).not.toHaveProperty('knowledgeBaseIds')
+ expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] })
+ expect(call.input.signal).toBe(request.signal)
+ controller.abort()
+ expect(call.input.signal.aborted).toBe(true)
+ await expect(response.json()).resolves.toEqual({
+ success: true,
+ data: { query: 'Orion', results: [] },
+ })
+ })
+
+ it('authenticates before parsing and never enters search for an anonymous request', async () => {
+ authMockFns.mockGetSession.mockResolvedValueOnce(null)
+ const response = await POST(
+ new NextRequest('http://localhost/api/knowledge/search', {
+ method: 'POST',
+ body: '{',
+ headers: { 'content-type': 'application/json' },
+ })
+ )
+ expect(response.status).toBe(401)
+ expect(mocks.search).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts
index 96d78f3d1d7..cc9ae79fb8b 100644
--- a/apps/sim/app/api/knowledge/search/route.ts
+++ b/apps/sim/app/api/knowledge/search/route.ts
@@ -6,7 +6,7 @@ import {
} from '@/lib/api/server/routes'
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
-import { searchKnowledge } from '@/lib/knowledge/application/search'
+import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
import { sourceAuthor } from '@/lib/knowledge/search/author'
export const POST = defineInternalJsonRoute({
@@ -14,16 +14,20 @@ export const POST = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: knowledgeOperations.search,
rateLimit: internalRateLimits.none({
- reason: 'A person typing queries; the embedding call is metered against their workspace',
+ reason:
+ 'A person typing queries; the embedding call is metered against the canonical search owner',
}),
errorPolicy: internalKnowledgeErrorPolicies.search,
- mapInput: ({ body }) => ({
+ mapInput: ({ body }, { request }) => ({
workspaceId: body.workspaceId,
- knowledgeBaseIds: body.knowledgeBaseIds,
+ organizationId: body.organizationId,
+ filters: body.filters,
query: body.query,
topK: body.topK,
+ surface: 'dashboard' as const,
+ signal: request.signal,
}),
- useCase: searchKnowledge,
+ useCase: searchScopedKnowledge,
present: ({ results, knowledgeBases }, { input }) => {
const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name]))
return {
diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts
index fd09d744f64..e5184169e8f 100644
--- a/apps/sim/app/api/knowledge/search/utils.test.ts
+++ b/apps/sim/app/api/knowledge/search/utils.test.ts
@@ -18,6 +18,14 @@ import * as documentsUtilsModule from '@/lib/knowledge/documents/utils'
import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
+vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({
+ PROVIDER_QUOTA_COOLDOWN_MS: 300_000,
+ ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {},
+ isProviderQuotaExhausted: vi.fn().mockResolvedValue(false),
+ recordProviderCooldown: vi.fn().mockResolvedValue(undefined),
+ waitForProviderAdmission: vi.fn().mockResolvedValue(undefined),
+}))
+
/**
* Spy on the real documents/utils namespace instead of vi.mock: the shared
* `@/lib/knowledge/embeddings` module may be cached bound to the real module,
@@ -196,6 +204,27 @@ describe('Knowledge Search Utils', () => {
})
describe('handleTagAndVectorSearch', () => {
+ it('returns only bounded ranked rows without first materializing every matching tag ID', async () => {
+ resetDbChainMock()
+ queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)])
+
+ const results = await handleTagAndVectorSearch({
+ knowledgeBaseIds: ['kb-1', 'kb-2'],
+ access: WORKSPACE_ACCESS_SCOPE,
+ topK: 2,
+ structuredFilters: [
+ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' },
+ ],
+ queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 },
+ distanceThreshold: 0.8,
+ })
+
+ expect(results.map((row) => row.id)).toEqual(['first', 'second'])
+ expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
+ expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance')
+ expect(dbChainMockFns.limit).toHaveBeenCalledWith(2)
+ })
+
it('should throw error when no filters provided', async () => {
const params = {
knowledgeBaseIds: ['kb-123'],
diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts
index c13ebd49c16..9f2319ee6d3 100644
--- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts
+++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts
@@ -14,11 +14,7 @@ export const POST = defineInternalJsonRoute({
operation: knowledgeOperations.simSearchConnect,
rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
- mapInput: ({ body }) => ({
- workspaceId: body.workspaceId,
- connectorType: body.connectorType,
- sourceConfig: body.sourceConfig,
- }),
+ mapInput: ({ body }) => body,
useCase: connectSimSearchConnector,
present: (result) => ({ success: true as const, data: result }),
})
diff --git a/apps/sim/app/api/knowledge/sim-search/index/route.ts b/apps/sim/app/api/knowledge/sim-search/index/route.ts
new file mode 100644
index 00000000000..3b891520185
--- /dev/null
+++ b/apps/sim/app/api/knowledge/sim-search/index/route.ts
@@ -0,0 +1,20 @@
+import { readSearchIndexContract } from '@/lib/api/contracts/knowledge/connectors'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
+import { knowledgeOperations } from '@/lib/knowledge/application/operations'
+import { readSearchIndex } from '@/lib/knowledge/application/sim-search'
+
+export const GET = defineInternalJsonRoute({
+ contract: readSearchIndexContract,
+ auth: internalSessionAuth,
+ operation: knowledgeOperations.readSearchIndex,
+ rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.index' }),
+ errorPolicy: internalKnowledgeErrorPolicies.connectors,
+ mapInput: ({ query }) => query,
+ useCase: readSearchIndex,
+ present: (data) => ({ success: true, data: { knowledgeBaseId: data.knowledgeBaseId } }),
+})
diff --git a/apps/sim/app/api/knowledge/sim-search/integrations/route.ts b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts
new file mode 100644
index 00000000000..439b5b6b082
--- /dev/null
+++ b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts
@@ -0,0 +1,40 @@
+import {
+ listSearchIntegrationsContract,
+ updateSearchIntegrationContract,
+} from '@/lib/api/contracts/knowledge/search-integrations'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
+import { knowledgeOperations } from '@/lib/knowledge/application/operations'
+import {
+ approveSearchIntegration,
+ listSearchIntegrations,
+} from '@/lib/knowledge/application/search-integrations'
+
+export const GET = defineInternalJsonRoute({
+ contract: listSearchIntegrationsContract,
+ auth: internalSessionAuth,
+ operation: knowledgeOperations.listSearchIntegrations,
+ rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.list' }),
+ errorPolicy: internalKnowledgeErrorPolicies.connectors,
+ mapInput: ({ query }) => query,
+ useCase: listSearchIntegrations,
+ present: (data) => ({ success: true as const, data }),
+})
+
+export const PUT = defineInternalJsonRoute({
+ contract: updateSearchIntegrationContract,
+ auth: internalSessionAuth,
+ operation: knowledgeOperations.approveSearchIntegration,
+ rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.approve' }),
+ errorPolicy: internalKnowledgeErrorPolicies.connectors,
+ mapInput: ({ body }) => body,
+ useCase: approveSearchIntegration,
+ present: ({ connectorType, approved }) => ({
+ success: true as const,
+ data: { connectorType, approved },
+ }),
+})
diff --git a/apps/sim/app/api/knowledge/sim-search/prepare/route.ts b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts
new file mode 100644
index 00000000000..ffad32fe0c5
--- /dev/null
+++ b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts
@@ -0,0 +1,20 @@
+import { prepareSearchSourceContract } from '@/lib/api/contracts/knowledge/connectors'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
+import { knowledgeOperations } from '@/lib/knowledge/application/operations'
+import { prepareSearchSource } from '@/lib/knowledge/application/sim-search'
+
+export const POST = defineInternalJsonRoute({
+ contract: prepareSearchSourceContract,
+ auth: internalSessionAuth,
+ operation: knowledgeOperations.prepareSearchSource,
+ rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.sources.prepare' }),
+ errorPolicy: internalKnowledgeErrorPolicies.connectors,
+ mapInput: ({ body }) => body,
+ useCase: prepareSearchSource,
+ present: (data) => ({ success: true as const, data }),
+})
diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts
new file mode 100644
index 00000000000..a90e2cc09d0
--- /dev/null
+++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts
@@ -0,0 +1,155 @@
+/** @vitest-environment node */
+import { authMockFns, createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ execute: vi.fn(), connect: vi.fn() }))
+vi.mock('@/lib/knowledge/application/sim-search', () => ({
+ connectSimSearchConnector: {
+ operation: { id: 'knowledge.simSearch.connect' },
+ execute: mocks.connect,
+ },
+}))
+vi.mock('@/lib/knowledge/application/search-sources', () => ({
+ listSearchSources: { operation: { id: 'knowledge.search.sources.list' }, execute: mocks.execute },
+}))
+vi.mock('@/lib/knowledge/application/search', () => ({
+ KnowledgeSearchProvenanceUnavailableError: class extends Error {},
+}))
+vi.mock('@/lib/knowledge/application/upload-sessions', () => ({
+ KnowledgeDocumentUnsupportedMediaTypeError: class extends Error {},
+}))
+
+import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization'
+import { POST as connectSource } from '@/app/api/knowledge/sim-search/connect/route'
+import { GET } from '@/app/api/knowledge/sim-search/sources/route'
+
+const WORKSPACE_ID = '7d28e5e2-fb03-4118-9c52-4ab77ccff369'
+const source = {
+ knowledgeBaseId: 'search-index',
+ connectorId: 'source',
+ connectorType: 'google_drive',
+ sourceDescription: 'Handbook',
+ accessMode: 'admin',
+ availability: 'available',
+ enabled: true,
+ isSyncing: false,
+ lastSyncAt: null,
+ hasSyncError: false,
+ viewerDocumentCount: 0,
+ viewerEmailVerified: true,
+ connectionRequired: false,
+ viewerMembership: null,
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'reader' },
+ session: { id: 'session' },
+ })
+ mocks.execute.mockResolvedValue({ sources: [source] })
+})
+
+describe('GET Search sources', () => {
+ it('preserves the explicit organization in source listing and member enrollment', async () => {
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost/api/knowledge/sim-search/sources?organizationId=${WORKSPACE_ID}`
+ )
+ )
+ expect(response.status).toBe(200)
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ input: { organizationId: WORKSPACE_ID },
+ })
+ )
+
+ mocks.connect.mockResolvedValue({
+ knowledgeBaseId: 'index',
+ connectorId: 'source',
+ url: 'http://localhost/credential-groups/enroll/token',
+ })
+ const body = { organizationId: WORKSPACE_ID, connectorType: 'gmail' }
+ const connected = await connectSource(createMockRequest('POST', body))
+ expect(connected.status).toBe(200)
+ expect(mocks.connect).toHaveBeenCalledWith(expect.objectContaining({ input: body }))
+ })
+
+ it('authenticates before parsing the workspace query', async () => {
+ authMockFns.mockGetSession.mockResolvedValue(null)
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(401)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('refuses a missing workspace before entering the use case', async () => {
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(400)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('passes the authenticated subject into the registered operation and projects only the contract fields', async () => {
+ mocks.execute.mockResolvedValue({
+ sources: [
+ {
+ ...source,
+ credentialId: 'secret',
+ sourceConfig: { token: 'secret' },
+ lastSyncError: 'private failure',
+ },
+ ],
+ })
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}`
+ )
+ )
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+ const body = await response.json()
+ expect(body).toMatchObject({ success: true, data: [source] })
+ expect(body.data[0]).toEqual(source)
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal: { kind: 'session', userId: 'reader', sessionId: 'session' },
+ input: { workspaceId: WORKSPACE_ID },
+ })
+ )
+ })
+
+ it('preserves authorization rejection and conceals source data', async () => {
+ mocks.execute.mockRejectedValue(new NoWorkspaceAccessError())
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}`
+ )
+ )
+ expect(response.status).toBe(404)
+ expect(await response.json()).not.toHaveProperty('data')
+ })
+
+ it('does not publish infrastructure errors or mistake failures for an empty list', async () => {
+ mocks.execute.mockRejectedValue(new Error('database private connection string'))
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}`
+ )
+ )
+ expect(response.status).toBe(500)
+ const body = await response.json()
+ expect(body.error).toBe('Internal server error')
+ expect(body).not.toHaveProperty('data')
+ })
+})
diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.ts
new file mode 100644
index 00000000000..fe64846acdd
--- /dev/null
+++ b/apps/sim/app/api/knowledge/sim-search/sources/route.ts
@@ -0,0 +1,23 @@
+import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
+import { knowledgeOperations } from '@/lib/knowledge/application/operations'
+import { listSearchSources } from '@/lib/knowledge/application/search-sources'
+
+export const GET = defineInternalJsonRoute({
+ contract: listSearchSourcesContract,
+ auth: internalSessionAuth,
+ operation: knowledgeOperations.listSearchSources,
+ rateLimit: internalRateLimits.none({
+ reason: 'Workspace source summaries for the Search page and indexing status polling',
+ }),
+ errorPolicy: internalKnowledgeErrorPolicies.connectors,
+ mapInput: ({ query }) => query,
+ useCase: listSearchSources,
+ present: ({ sources }) => ({ success: true as const, data: sources }),
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts
index 2fca7aa4ecc..47a3671dca8 100644
--- a/apps/sim/app/api/knowledge/utils.test.ts
+++ b/apps/sim/app/api/knowledge/utils.test.ts
@@ -19,6 +19,14 @@ import { env } from '@/lib/core/config/env'
import * as documentsUtilsModule from '@/lib/knowledge/documents/utils'
import * as workspacesUtilsModule from '@/lib/workspaces/utils'
+vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({
+ PROVIDER_QUOTA_COOLDOWN_MS: 300_000,
+ ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {},
+ isProviderQuotaExhausted: vi.fn().mockResolvedValue(false),
+ recordProviderCooldown: vi.fn().mockResolvedValue(undefined),
+ waitForProviderAdmission: vi.fn().mockResolvedValue(undefined),
+}))
+
const envSnapshot = { ...env }
afterAll(() => {
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
index 8dfa1b88a23..d1af1e73161 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
@@ -10,6 +10,7 @@ import {
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
const {
mockAuthenticateEnrollment,
@@ -30,7 +31,7 @@ vi.mock('@/lib/mcp/service', () => ({
mcpService: { discoverServerTools: mockDiscoverServerTools },
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
- authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment,
+ credentialGroupOAuthAttemptPrincipal: mockAuthenticateEnrollment,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth },
@@ -68,6 +69,8 @@ describe('MCP OAuth callback route', () => {
mockDiscoverServerTools.mockResolvedValue(undefined)
mockConsumeManagedAttempt.mockResolvedValue({
state: 'mcp_cg_state-1',
+ workspaceId: 'workspace-1',
+ email: 'invitee@example.com',
enrollmentId: 'enrollment-1',
credentialGroupId: 'group-1',
mcpServerId: 'server-1',
@@ -75,7 +78,7 @@ describe('MCP OAuth callback route', () => {
invitationToken: 'invitation-token',
createdAt: Date.now(),
})
- mockAuthenticateEnrollment.mockResolvedValue({
+ mockAuthenticateEnrollment.mockReturnValue({
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
@@ -159,7 +162,13 @@ describe('MCP OAuth callback route', () => {
expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback')
expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1')
- expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token')
+ expect(mockAuthenticateEnrollment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'workspace-1',
+ email: 'invitee@example.com',
+ invitationToken: 'invitation-token',
+ })
+ )
expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
@@ -187,4 +196,13 @@ describe('MCP OAuth callback route', () => {
expect(mockConsumeManagedAttempt).not.toHaveBeenCalled()
expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
})
+ it('reports a state protocol change without exchanging a code or loading an enrollment', async () => {
+ mockConsumeManagedAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError())
+ const response = await GET(
+ new NextRequest('http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_old&code=code')
+ )
+ expect(await response.text()).toContain('Reopen your invitation and connect again')
+ expect(mockAuthenticateEnrollment).not.toHaveBeenCalled()
+ expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index f76addcbb80..dd31f4a24b2 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -9,12 +9,13 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
+import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
import {
consumeCredentialGroupMcpOAuthAttempt,
isCredentialGroupMcpOAuthState,
} from '@/lib/credential-groups/mcp-oauth-state'
+import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import {
assertSafeOauthServerUrl,
@@ -84,7 +85,15 @@ async function completeManagedMcpCallback(params: {
code?: string
error?: string
}): Promise {
- const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state)
+ let attempt
+ try {
+ attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state)
+ } catch (error) {
+ if (error instanceof CredentialGroupOAuthStateVersionError) {
+ return htmlClose(error.message, false, 'invalid_state', undefined, params.state)
+ }
+ throw error
+ }
if (!attempt) {
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
}
@@ -97,12 +106,7 @@ async function completeManagedMcpCallback(params: {
})
}
try {
- const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
- if (!principal) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'unavailable',
- })
- }
+ const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
const result = await completePublicCredentialGroupMcpOAuth.execute({
principal,
input: { attempt, code: params.code },
@@ -199,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt)))
.limit(1)
)
- if (!server || !server.url) {
+ if (!server || !server.url || !server.workspaceId) {
return respond('Server no longer exists.', false, 'server_gone', serverId)
}
if (server.workspaceId !== row.workspaceId) {
@@ -211,6 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}
const serverUrl = server.url
+ const serverWorkspaceId = server.workspaceId
try {
assertSafeOauthServerUrl(serverUrl)
} catch {
@@ -262,7 +267,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
// forceRefresh: skip any stale cache from before re-auth.
await timedStep('discoverServerTools', 60_000, () =>
- mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, 'force')
+ mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
)
} catch (e) {
logger.warn('Post-auth tools refresh failed', toError(e).message)
diff --git a/apps/sim/app/api/mcp/search/[workspaceId]/route.ts b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts
new file mode 100644
index 00000000000..c6b7d737e3e
--- /dev/null
+++ b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts
@@ -0,0 +1,9 @@
+import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const handlers = createKnowledgeMcpHandlers('workspace')
+
+export const POST = handlers.POST
+export const GET = handlers.GET
+export const DELETE = handlers.DELETE
diff --git a/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts
new file mode 100644
index 00000000000..0f3758aaaf6
--- /dev/null
+++ b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts
@@ -0,0 +1,9 @@
+import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const handlers = createKnowledgeMcpHandlers('organization')
+
+export const POST = handlers.POST
+export const GET = handlers.GET
+export const DELETE = handlers.DELETE
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
index 5a95ce3523d..be62590983a 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
@@ -330,6 +330,7 @@ describe('MCP Serve Route', () => {
workflowId: 'wf-1',
userId: 'user-1',
triggerType: 'mcp',
+ principal: PERSONAL_API_KEY_PRINCIPAL,
useAuthenticatedUserAsActor: true,
deploymentVersionId: 'deployment-1',
includeFileBase64: false,
@@ -578,6 +579,7 @@ describe('MCP Serve Route', () => {
expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
+ principal: WORKSPACE_API_KEY_PRINCIPAL,
useAuthenticatedUserAsActor: false,
})
)
diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts
index 6351971fd8c..deff41844d0 100644
--- a/apps/sim/app/api/mothership/chat/route.ts
+++ b/apps/sim/app/api/mothership/chat/route.ts
@@ -5,11 +5,11 @@ import {
} from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
+import { handleUnifiedChatPost } from '@/lib/copilot/chat/post'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries'
-export { maxDuration }
+export const maxDuration = 3600
// Unified chat route surface.
export const GET = withRouteHandler((request: NextRequest) => {
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
index 4f407ffea71..740456f750b 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
@@ -14,6 +14,7 @@ import {
} from '@/lib/copilot/chat/fork-chat-files'
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
+import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats'
import {
rewriteMessageFileRefs,
rewriteResourceFileRefs,
@@ -32,6 +33,7 @@ import { removeChatResources } from '@/lib/copilot/resources/persistence'
import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
@@ -58,7 +60,7 @@ const logger = createLogger('ForkChatAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -76,6 +78,7 @@ export const POST = withRouteHandler(
userId: copilotChats.userId,
type: copilotChats.type,
workspaceId: copilotChats.workspaceId,
+ organizationId: copilotChats.organizationId,
title: copilotChats.title,
model: copilotChats.model,
resources: copilotChats.resources,
@@ -90,6 +93,13 @@ export const POST = withRouteHandler(
return createNotFoundResponse('Chat not found')
}
+ if (parent.organizationId) {
+ if (!principal) return createUnauthorizedResponse()
+ await authorizeOrganizationChat.execute({
+ principal,
+ input: { organizationId: parent.organizationId },
+ })
+ }
if (parent.workspaceId) {
await assertActiveWorkspaceAccess(parent.workspaceId, userId)
}
@@ -137,6 +147,7 @@ export const POST = withRouteHandler(
id: newId,
userId,
workspaceId: parent.workspaceId,
+ organizationId: parent.organizationId,
type: parent.type,
title,
model: parent.model,
@@ -277,6 +288,9 @@ export const POST = withRouteHandler(
...(failed > 0 ? { failedFileCopies: failed } : {}),
})
} catch (error) {
+ const code = asOrchestrationError(error)?.code
+ if (code === 'not_found' || code === 'forbidden')
+ return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts
index b59bcceac05..b98f6824e1f 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts
@@ -5,6 +5,7 @@ import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
+import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
@@ -12,6 +13,7 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
@@ -31,7 +33,7 @@ const logger = createLogger('RestoreMothershipChatAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -41,7 +43,10 @@ export const POST = withRouteHandler(
const { chatId } = parsed.data.params
const [chat] = await db
- .select({ workspaceId: copilotChats.workspaceId })
+ .select({
+ workspaceId: copilotChats.workspaceId,
+ organizationId: copilotChats.organizationId,
+ })
.from(copilotChats)
.where(
and(
@@ -56,6 +61,13 @@ export const POST = withRouteHandler(
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
+ if (chat.organizationId) {
+ if (!principal) return createUnauthorizedResponse()
+ await authorizeOrganizationChat.execute({
+ principal,
+ input: { organizationId: chat.organizationId },
+ })
+ }
if (chat.workspaceId) {
await assertActiveWorkspaceAccess(chat.workspaceId, userId)
}
@@ -76,6 +88,7 @@ export const POST = withRouteHandler(
)
.returning({
workspaceId: copilotChats.workspaceId,
+ organizationId: copilotChats.organizationId,
})
if (!restoredChat) {
@@ -100,6 +113,9 @@ export const POST = withRouteHandler(
return NextResponse.json({ success: true })
} catch (error) {
+ const code = asOrchestrationError(error)?.code
+ if (code === 'not_found' || code === 'forbidden')
+ return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
index 9fa07c1e067..6d321e169f3 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
@@ -36,7 +36,7 @@ const logger = createLogger('MothershipChatAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -45,7 +45,7 @@ export const GET = withRouteHandler(
if (!paramsResult.success) return paramsResult.response
const { chatId } = paramsResult.data.params
- const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
+ const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal })
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
@@ -154,7 +154,7 @@ export const GET = withRouteHandler(
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -163,6 +163,10 @@ export const PATCH = withRouteHandler(
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const { title, isUnread, pinned } = parsed.data.body
+ const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal })
+ if (!chat || chat.type !== 'mothership') {
+ return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
+ }
const updates: Record = {}
@@ -250,7 +254,7 @@ export const PATCH = withRouteHandler(
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -259,7 +263,7 @@ export const DELETE = withRouteHandler(
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
- const chat = await getAccessibleCopilotChatAuth(chatId, userId)
+ const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal })
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: true })
}
diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts
index 1ff8c0a60fd..5a67be8f8a6 100644
--- a/apps/sim/app/api/mothership/chats/read/route.test.ts
+++ b/apps/sim/app/api/mothership/chats/read/route.test.ts
@@ -5,13 +5,17 @@ import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock }
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockParseRequest } = vi.hoisted(() => ({
+const { mockParseRequest, mockGetAccessibleChat } = vi.hoisted(() => ({
mockParseRequest: vi.fn(),
+ mockGetAccessibleChat: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest }))
vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} }))
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({
+ getAccessibleCopilotChatAuth: mockGetAccessibleChat,
+}))
import { POST } from '@/app/api/mothership/chats/read/route'
@@ -29,7 +33,9 @@ describe('POST /api/mothership/chats/read', () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
+ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
})
+ mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1', userId: 'user-1' })
mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } })
})
@@ -40,6 +46,9 @@ describe('POST /api/mothership/chats/read', () => {
it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => {
const res = await POST(createRequest())
expect(res.status).toBe(200)
+ expect(mockGetAccessibleChat).toHaveBeenCalledWith('chat-1', 'user-1', {
+ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
+ })
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
const whereArg = dbChainMockFns.where.mock.calls[0][0] as {
@@ -58,6 +67,13 @@ describe('POST /api/mothership/chats/read', () => {
)
})
+ it('does not update a chat the caller can no longer access', async () => {
+ mockGetAccessibleChat.mockResolvedValueOnce(null)
+ const res = await POST(createRequest())
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ })
+
it('does not touch the database when unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: null,
diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts
index beffb8c821d..1c2cc149f72 100644
--- a/apps/sim/app/api/mothership/chats/read/route.ts
+++ b/apps/sim/app/api/mothership/chats/read/route.ts
@@ -5,6 +5,7 @@ import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
@@ -16,7 +17,7 @@ const logger = createLogger('MarkTaskReadAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
@@ -24,6 +25,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(markMothershipChatReadContract, request, {})
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.body
+ const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal })
+ if (!chat) return NextResponse.json({ success: true })
await db
.update(copilotChats)
diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts
index acdbfddb2a9..bc1828f2579 100644
--- a/apps/sim/app/api/mothership/chats/route.ts
+++ b/apps/sim/app/api/mothership/chats/route.ts
@@ -8,6 +8,10 @@ import {
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
+import {
+ createOrganizationChat,
+ listOrganizationChats,
+} from '@/lib/copilot/chat/organization-chats'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants'
import {
@@ -16,6 +20,7 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
@@ -31,21 +36,34 @@ const logger = createLogger('MothershipChatsAPI')
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const queryResult = await parseRequest(listMothershipChatsContract, request, {})
if (!queryResult.success) return queryResult.response
- const { workspaceId, scope } = queryResult.data.query
+ const { workspaceId, organizationId, scope } = queryResult.data.query
+
+ if (organizationId) {
+ if (!principal) return createUnauthorizedResponse()
+ const data = await listOrganizationChats.execute({
+ principal,
+ input: { organizationId, scope },
+ })
+ return NextResponse.json({ success: true, data })
+ }
+ if (!workspaceId) throw new Error('Conversation owner is required')
await assertActiveWorkspaceAccess(workspaceId, userId)
const data = await listMothershipChats(userId, workspaceId, scope)
return NextResponse.json({ success: true, data })
} catch (error) {
+ const code = asOrchestrationError(error)?.code
+ if (code === 'not_found' || code === 'forbidden')
+ return createForbiddenResponse('Organization access denied')
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
@@ -60,15 +78,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const validation = await parseRequest(createMothershipChatContract, request, {})
if (!validation.success) return validation.response
- const { workspaceId } = validation.data.body
+ const { workspaceId, organizationId } = validation.data.body
+
+ if (organizationId) {
+ if (!principal) return createUnauthorizedResponse()
+ const chat = await createOrganizationChat.execute({ principal, input: { organizationId } })
+ return NextResponse.json({ success: true, id: chat.id })
+ }
+ if (!workspaceId) throw new Error('Conversation owner is required')
await assertActiveWorkspaceAccess(workspaceId, userId)
const now = new Date()
@@ -98,6 +123,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ success: true, id: chat.id })
} catch (error) {
+ const code = asOrchestrationError(error)?.code
+ if (code === 'not_found' || code === 'forbidden')
+ return createForbiddenResponse('Organization access denied')
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 4c2c1093142..2e1cd9fd38c 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -237,7 +237,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
workspaceAccess,
secretMountPolicy,
}),
- buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
+ buildIntegrationToolSchemas(userId, undefined, workspaceId),
mothershipToolsPromise,
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
diff --git a/apps/sim/app/api/organization-credentials/[id]/route.ts b/apps/sim/app/api/organization-credentials/[id]/route.ts
new file mode 100644
index 00000000000..c1b7f569d07
--- /dev/null
+++ b/apps/sim/app/api/organization-credentials/[id]/route.ts
@@ -0,0 +1,23 @@
+import { updateOrganizationCredentialContract } from '@/lib/api/contracts/organization-credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
+import {
+ organizationCredentialOperations,
+ updateOrganizationCredential,
+} from '@/lib/credentials/application/organization-credentials'
+import { toOrganizationCredential } from '@/lib/credentials/application/presentation'
+
+export const PATCH = defineInternalJsonRoute({
+ contract: updateOrganizationCredentialContract,
+ auth: internalSessionAuth,
+ operation: organizationCredentialOperations.update,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve credential update behavior' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ body, params }) => ({ ...body, credentialId: params.id }),
+ useCase: updateOrganizationCredential,
+ present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }),
+})
diff --git a/apps/sim/app/api/organization-credentials/draft/route.ts b/apps/sim/app/api/organization-credentials/draft/route.ts
new file mode 100644
index 00000000000..467335d73a6
--- /dev/null
+++ b/apps/sim/app/api/organization-credentials/draft/route.ts
@@ -0,0 +1,22 @@
+import { createOrganizationCredentialDraftContract } from '@/lib/api/contracts/organization-credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
+import {
+ organizationCredentialOperations,
+ saveOrganizationCredentialDraft,
+} from '@/lib/credentials/application/organization-credentials'
+
+export const POST = defineInternalJsonRoute({
+ contract: createOrganizationCredentialDraftContract,
+ auth: internalSessionAuth,
+ operation: organizationCredentialOperations.saveDraft,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth draft behavior' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ body }) => body,
+ useCase: saveOrganizationCredentialDraft,
+ present: (result) => result,
+})
diff --git a/apps/sim/app/api/organization-credentials/oauth/route.ts b/apps/sim/app/api/organization-credentials/oauth/route.ts
new file mode 100644
index 00000000000..220f8767f4c
--- /dev/null
+++ b/apps/sim/app/api/organization-credentials/oauth/route.ts
@@ -0,0 +1,30 @@
+import { listOrganizationOAuthCredentialsContract } from '@/lib/api/contracts/organization-credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
+import {
+ listOrganizationCredentials,
+ organizationCredentialOperations,
+} from '@/lib/credentials/application/organization-credentials'
+import type { OAuthProvider } from '@/lib/oauth/types'
+
+export const GET = defineInternalJsonRoute({
+ contract: listOrganizationOAuthCredentialsContract,
+ auth: internalSessionAuth,
+ operation: organizationCredentialOperations.list,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth credential listing behavior' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ query }) => ({ ...query, type: 'oauth' as const }),
+ useCase: listOrganizationCredentials,
+ present: ({ credentials }) => ({
+ credentials: credentials.map((row) => ({
+ id: row.id,
+ name: row.displayName,
+ provider: row.providerId as OAuthProvider,
+ type: 'oauth' as const,
+ })),
+ }),
+})
diff --git a/apps/sim/app/api/organization-credentials/route.ts b/apps/sim/app/api/organization-credentials/route.ts
new file mode 100644
index 00000000000..58cab694ecc
--- /dev/null
+++ b/apps/sim/app/api/organization-credentials/route.ts
@@ -0,0 +1,38 @@
+import {
+ createOrganizationCredentialContract,
+ listOrganizationCredentialsContract,
+} from '@/lib/api/contracts/organization-credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
+import {
+ createOrganizationCredential,
+ listOrganizationCredentials,
+ organizationCredentialOperations,
+} from '@/lib/credentials/application/organization-credentials'
+import { toOrganizationCredential } from '@/lib/credentials/application/presentation'
+
+export const GET = defineInternalJsonRoute({
+ contract: listOrganizationCredentialsContract,
+ auth: internalSessionAuth,
+ operation: organizationCredentialOperations.list,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve credential listing behavior' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ query }) => query,
+ useCase: listOrganizationCredentials,
+ present: ({ credentials }) => ({ credentials: credentials.map(toOrganizationCredential) }),
+})
+export const POST = defineInternalJsonRoute({
+ contract: createOrganizationCredentialContract,
+ auth: internalSessionAuth,
+ operation: organizationCredentialOperations.create,
+ rateLimit: internalRateLimits.none({ reason: 'Preserve credential creation behavior' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ body }) => body,
+ useCase: createOrganizationCredential,
+ present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }),
+ statusForResult: ({ created }) => (created ? 201 : 200),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts
new file mode 100644
index 00000000000..acda6a7d872
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts
@@ -0,0 +1,25 @@
+import { updateOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationAccountOperations,
+ updateOrganizationAccountsSettings,
+} from '@/lib/credential-groups/application/organization-accounts'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const PATCH = defineInternalJsonRoute({
+ contract: updateOrganizationAccountsContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountOperations.update,
+ rateLimit: internalRateLimits.none({ reason: 'Administrator account configuration mutation' }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update connected accounts'),
+ mapInput: ({ params, body }) => ({
+ organizationId: params.id,
+ credentialGroupId: params.groupId,
+ update: body,
+ }),
+ useCase: updateOrganizationAccountsSettings,
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts
new file mode 100644
index 00000000000..146a97011f1
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts
@@ -0,0 +1,77 @@
+/** @vitest-environment node */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() }))
+vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
+vi.mock('@/lib/credential-groups/application/slack-managed-users', () => ({
+ startSlackCredentialGroupConfiguration: {
+ get operation() {
+ return credentialGroupOperations.startSlackConfiguration
+ },
+ execute: mocks.execute,
+ },
+}))
+
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
+import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route'
+
+const body = {
+ appId: 'A123',
+ teamId: 'T123',
+ clientId: 'fixture-client-id',
+ clientSecret: 'fixture-client-secret',
+}
+const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) }
+function request(input: unknown = body) {
+ return createMockRequest(
+ 'POST',
+ input,
+ undefined,
+ 'http://localhost:3000/api/organizations/org-a/connected-accounts/group-a/slack-managed-users'
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } })
+ mocks.execute.mockResolvedValue({
+ authorizationUrl: 'https://slack.com/oauth/v2/authorize',
+ state: 'opaque-state',
+ })
+})
+
+describe('organization Slack setup route', () => {
+ it('authenticates before parsing setup secrets', async () => {
+ mocks.session.mockResolvedValue(null)
+ const response = await POST(request({}), context)
+ expect(response.status).toBe(401)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('maps the canonical route id to organization ownership without a workspace alias', async () => {
+ const response = await POST(request(), context)
+ expect(response.status).toBe(200)
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal: { kind: 'session', sessionId: 'session', userId: 'actor' },
+ input: { ...body, organizationId: 'org-a', credentialGroupId: 'group-a' },
+ })
+ )
+ })
+
+ it('rejects a client-supplied workspace owner', async () => {
+ const response = await POST(request({ ...body, workspaceId: 'workspace-a' }), context)
+ expect(response.status).toBe(400)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('preserves refusal when current organization authority is insufficient', async () => {
+ mocks.execute.mockRejectedValue(
+ new OrchestrationError('forbidden', 'Organization admin required')
+ )
+ const response = await POST(request(), context)
+ expect(response.status).toBe(403)
+ })
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts
new file mode 100644
index 00000000000..32f1f8dcba4
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts
@@ -0,0 +1,34 @@
+import { startOrganizationSlackConfigurationContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ extendInternalErrorPolicy,
+ internalErrorResponse,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
+import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
+import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const POST = defineInternalJsonRoute({
+ contract: startOrganizationSlackConfigurationContract,
+ auth: internalSessionAuth,
+ operation: credentialGroupOperations.startSlackConfiguration,
+ rateLimit: internalRateLimits.none({
+ reason: 'Slack applies provider authorization limits and setup requires an organization admin',
+ }),
+ errorPolicy: extendInternalErrorPolicy(
+ createCredentialGroupInternalErrorPolicy('Failed to configure Slack'),
+ (error) =>
+ error instanceof SlackManagedUsersError
+ ? internalErrorResponse(400, { error: error.message })
+ : null
+ ),
+ mapInput: ({ params, body }) => ({
+ ...body,
+ organizationId: params.id,
+ credentialGroupId: params.groupId,
+ }),
+ useCase: startSlackCredentialGroupConfiguration,
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts
new file mode 100644
index 00000000000..1b20890214e
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts
@@ -0,0 +1,26 @@
+import { startOrganizationAccountConnectionContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationAccountOperations,
+ startOrganizationAccountConnection,
+} from '@/lib/credential-groups/application/organization-accounts'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const POST = defineInternalJsonRoute({
+ contract: startOrganizationAccountConnectionContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountOperations.connect,
+ rateLimit: internalRateLimits.none({
+ reason: 'Bounded current-member self-enrollment; no email delivery',
+ }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to connect account'),
+ mapInput: ({ params, body }) => ({
+ organizationId: params.id,
+ optionId: body.optionId,
+ }),
+ useCase: startOrganizationAccountConnection,
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts
new file mode 100644
index 00000000000..ef9d7e49571
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts
@@ -0,0 +1,40 @@
+import {
+ configureOrganizationMcpContract,
+ getOrganizationDatabricksSetupContract,
+} from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ configureOrganizationMcp,
+ configureOrganizationMcpOperation,
+} from '@/lib/credential-groups/application/configure-organization-mcp'
+import {
+ getOrganizationDatabricksSetup,
+ getOrganizationDatabricksSetupOperation,
+} from '@/lib/credential-groups/application/organization-databricks-setup'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationDatabricksSetupContract,
+ auth: internalSessionAuth,
+ operation: getOrganizationDatabricksSetupOperation,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: getOrganizationDatabricksSetup,
+ present: ({ server }) => ({ server }),
+})
+
+export const PUT = defineInternalJsonRoute({
+ contract: configureOrganizationMcpContract,
+ auth: internalSessionAuth,
+ operation: configureOrganizationMcpOperation,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: configureOrganizationMcp,
+ present: ({ mcpServer }) => ({ mcpServer }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts
new file mode 100644
index 00000000000..d79a286c00f
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts
@@ -0,0 +1,22 @@
+import { updateOrganizationAccountIndexingContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ updateOrganizationAccountIndexing,
+ updateOrganizationAccountIndexingOperation,
+} from '@/lib/credential-groups/application/organization-account-indexing'
+
+export const PUT = defineInternalJsonRoute({
+ contract: updateOrganizationAccountIndexingContract,
+ auth: internalSessionAuth,
+ operation: updateOrganizationAccountIndexingOperation,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-account-indexing' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: updateOrganizationAccountIndexing,
+ present: ({ enabled, knowledgeBaseIds }) => ({ enabled, knowledgeBaseIds }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts
new file mode 100644
index 00000000000..54055821f39
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts
@@ -0,0 +1,22 @@
+import { removeOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationAccountManagementOperations,
+ removeOrganizationAccountMcpProvider,
+} from '@/lib/credential-groups/application/organization-account-management'
+
+export const DELETE = defineInternalJsonRoute({
+ contract: removeOrganizationAccountMcpProviderContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.removeMcp,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id, connectorId: params.connectorId }),
+ useCase: removeOrganizationAccountMcpProvider,
+ present: () => ({ success: true as const }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts
new file mode 100644
index 00000000000..101a8e6c3f7
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts
@@ -0,0 +1,22 @@
+import { addOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ addOrganizationAccountMcpProvider,
+ organizationAccountManagementOperations,
+} from '@/lib/credential-groups/application/organization-account-management'
+
+export const POST = defineInternalJsonRoute({
+ contract: addOrganizationAccountMcpProviderContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.addMcp,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: addOrganizationAccountMcpProvider,
+ present: ({ mcpServer }) => ({ mcpServer }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts
new file mode 100644
index 00000000000..838defa8935
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts
@@ -0,0 +1,22 @@
+import { resendOrganizationAccountInvitationContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationAccountManagementOperations,
+ resendOrganizationAccountInvitation,
+} from '@/lib/credential-groups/application/organization-account-management'
+
+export const POST = defineInternalJsonRoute({
+ contract: resendOrganizationAccountInvitationContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.resend,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }),
+ useCase: resendOrganizationAccountInvitation,
+ present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts
new file mode 100644
index 00000000000..936ad572ccb
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts
@@ -0,0 +1,22 @@
+import { revokeOrganizationAccountEnrollmentContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationAccountManagementOperations,
+ revokeOrganizationAccountEnrollment,
+} from '@/lib/credential-groups/application/organization-account-management'
+
+export const DELETE = defineInternalJsonRoute({
+ contract: revokeOrganizationAccountEnrollmentContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.revoke,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }),
+ useCase: revokeOrganizationAccountEnrollment,
+ present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts
new file mode 100644
index 00000000000..61e8cd3fd0e
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts
@@ -0,0 +1,36 @@
+import {
+ inviteOrganizationAccountPeopleContract,
+ listOrganizationAccountPeopleContract,
+} from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ inviteOrganizationAccountPeople,
+ listOrganizationAccountPeople,
+ organizationAccountManagementOperations,
+} from '@/lib/credential-groups/application/organization-account-management'
+
+export const GET = defineInternalJsonRoute({
+ contract: listOrganizationAccountPeopleContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.people,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
+ useCase: listOrganizationAccountPeople,
+})
+
+export const POST = defineInternalJsonRoute({
+ contract: inviteOrganizationAccountPeopleContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountManagementOperations.invite,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: inviteOrganizationAccountPeople,
+ present: ({ results, sentCount, failedCount }) => ({ results, sentCount, failedCount }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts
new file mode 100644
index 00000000000..afa8338b32b
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts
@@ -0,0 +1,43 @@
+import {
+ ensureOrganizationAccountsContract,
+ getOrganizationAccountsContract,
+} from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ ensureOrganizationAccounts,
+ getOrganizationAccountsSettings,
+ organizationAccountOperations,
+} from '@/lib/credential-groups/application/organization-accounts'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+const errorPolicy = createCredentialGroupInternalErrorPolicy(
+ 'Failed to load connected accounts',
+ 'Organization not found'
+)
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationAccountsContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountOperations.read,
+ rateLimit: internalRateLimits.none({
+ reason: 'Authenticated organization account metadata read',
+ }),
+ errorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: getOrganizationAccountsSettings,
+})
+export const POST = defineInternalJsonRoute({
+ contract: ensureOrganizationAccountsContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountOperations.ensure,
+ rateLimit: internalRateLimits.none({
+ reason: 'Idempotent administrator account-container setup',
+ }),
+ errorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: ensureOrganizationAccounts,
+ present: ({ credentialGroup }) => ({ credentialGroup }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts
new file mode 100644
index 00000000000..8ae3315a131
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts
@@ -0,0 +1,40 @@
+import {
+ getOrganizationAccountWorkspaceAccessContract,
+ updateOrganizationAccountWorkspaceAccessContract,
+} from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ getOrganizationAccountWorkspaceAccess,
+ organizationAccountAccessOperations,
+ updateOrganizationAccountWorkspaceAccess,
+} from '@/lib/credential-groups/application/organization-access'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationAccountWorkspaceAccessContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountAccessOperations.read,
+ rateLimit: internalRateLimits.none({
+ reason: 'Bounded administrator workspace access settings read',
+ }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: getOrganizationAccountWorkspaceAccess,
+})
+
+export const PUT = defineInternalJsonRoute({
+ contract: updateOrganizationAccountWorkspaceAccessContract,
+ auth: internalSessionAuth,
+ operation: organizationAccountAccessOperations.update,
+ rateLimit: internalRateLimits.none({
+ reason: 'Administrator policy revision protects bounded workspace access updates',
+ }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
+ useCase: updateOrganizationAccountWorkspaceAccess,
+ present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }),
+})
diff --git a/apps/sim/app/api/settings/allowed-integrations/route.test.ts b/apps/sim/app/api/settings/allowed-integrations/route.test.ts
new file mode 100644
index 00000000000..6028ed121d7
--- /dev/null
+++ b/apps/sim/app/api/settings/allowed-integrations/route.test.ts
@@ -0,0 +1,78 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ getIntegrationAvailability: vi.fn(),
+ getOAuthServiceAvailability: vi.fn(),
+ getAllOAuthServices: vi.fn(),
+}))
+vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
+vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: () => null }))
+vi.mock('@/lib/integrations/availability.server', () => ({
+ getIntegrationAvailability: mocks.getIntegrationAvailability,
+ getOAuthServiceAvailability: mocks.getOAuthServiceAvailability,
+}))
+vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: mocks.getAllOAuthServices }))
+
+import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common'
+import { GET } from '@/app/api/settings/allowed-integrations/route'
+
+describe('allowed integrations response', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mocks.getIntegrationAvailability.mockReturnValue([
+ { type: 'github_v2', state: 'ready', oauthAvailable: false, missingFields: [] },
+ ])
+ mocks.getAllOAuthServices.mockReturnValue([
+ { providerId: 'github-repositories', authType: 'oauth' },
+ ])
+ mocks.getOAuthServiceAvailability.mockReturnValue([
+ { providerId: 'github-repositories', available: false },
+ ])
+ })
+
+ it('authenticates before projecting deployment capabilities', async () => {
+ mocks.getSession.mockResolvedValue(null)
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ undefined,
+ 'http://localhost/api/settings/allowed-integrations'
+ ),
+ {}
+ )
+ expect(response.status).toBe(401)
+ expect(mocks.getIntegrationAvailability).not.toHaveBeenCalled()
+ expect(mocks.getOAuthServiceAvailability).not.toHaveBeenCalled()
+ expect(mocks.getAllOAuthServices).not.toHaveBeenCalled()
+ })
+
+ it('returns block and OAuth service readiness as distinct contract fields', async () => {
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ undefined,
+ 'http://localhost/api/settings/allowed-integrations'
+ ),
+ {}
+ )
+ expect(response.status).toBe(200)
+ const body = await response.json()
+ expect(getAllowedIntegrationsContract.response.schema.safeParse(body).success).toBe(true)
+ expect(body).toEqual({
+ allowedIntegrations: null,
+ integrationAvailability: [{ type: 'github_v2', state: 'ready', oauthAvailable: false }],
+ oauthServiceAvailability: [{ providerId: 'github-repositories', available: false }],
+ })
+ expect(mocks.getOAuthServiceAvailability).toHaveBeenCalledWith(
+ mocks.getAllOAuthServices.mock.results[0].value
+ )
+ })
+})
diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts
index c5acc2582ca..ba7d83a103b 100644
--- a/apps/sim/app/api/settings/allowed-integrations/route.ts
+++ b/apps/sim/app/api/settings/allowed-integrations/route.ts
@@ -2,7 +2,11 @@ import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getIntegrationAvailability } from '@/lib/integrations/availability.server'
+import {
+ getIntegrationAvailability,
+ getOAuthServiceAvailability,
+} from '@/lib/integrations/availability.server'
+import { getAllOAuthServices } from '@/lib/oauth/utils'
export const GET = withRouteHandler(async () => {
const session = await getSession()
@@ -15,5 +19,6 @@ export const GET = withRouteHandler(async () => {
integrationAvailability: getIntegrationAvailability().map(
({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable })
),
+ oauthServiceAvailability: getOAuthServiceAvailability(getAllOAuthServices()),
})
})
diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts
new file mode 100644
index 00000000000..c9d66c778e7
--- /dev/null
+++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts
@@ -0,0 +1,18 @@
+import { reconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts'
+
+export const POST = defineInternalJsonRoute({
+ contract: reconnectPersonalOrganizationAccountContract,
+ auth: internalSessionAuth,
+ operation: reconnectPersonalOrganizationAccount.operation,
+ rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => params,
+ useCase: reconnectPersonalOrganizationAccount,
+})
diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts
new file mode 100644
index 00000000000..56076c3e7ed
--- /dev/null
+++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts
@@ -0,0 +1,19 @@
+import { disconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { disconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts'
+
+export const DELETE = defineInternalJsonRoute({
+ contract: disconnectPersonalOrganizationAccountContract,
+ auth: internalSessionAuth,
+ operation: disconnectPersonalOrganizationAccount.operation,
+ rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => params,
+ useCase: disconnectPersonalOrganizationAccount,
+ present: () => ({ success: true as const }),
+})
diff --git a/apps/sim/app/api/users/me/organization-accounts/route.ts b/apps/sim/app/api/users/me/organization-accounts/route.ts
new file mode 100644
index 00000000000..64d1129abeb
--- /dev/null
+++ b/apps/sim/app/api/users/me/organization-accounts/route.ts
@@ -0,0 +1,18 @@
+import { listPersonalOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { listPersonalOrganizationAccounts } from '@/lib/credential-groups/application/personal-organization-accounts'
+
+export const GET = defineInternalJsonRoute({
+ contract: listPersonalOrganizationAccountsContract,
+ auth: internalSessionAuth,
+ operation: listPersonalOrganizationAccounts.operation,
+ rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ query }) => query,
+ useCase: listPersonalOrganizationAccounts,
+})
diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts
index c3237cd200a..57b0cb0d912 100644
--- a/apps/sim/app/api/v1/admin/dashboard/actor.ts
+++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts
@@ -1,16 +1,18 @@
import { db } from '@sim/db'
-import { user } from '@sim/db/schema'
-import { eq, or } from 'drizzle-orm'
+import { foldedEmail, user } from '@sim/db/schema'
+import { normalizeEmail } from '@sim/utils/string'
+import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import type { AdminMutationActor } from '@/lib/admin/dashboard'
export async function getAdminAuditActor(request: NextRequest): Promise {
- const email = request.headers.get('x-admin-email')?.trim().toLowerCase()
+ const rawEmail = request.headers.get('x-admin-email')
+ const email = rawEmail ? normalizeEmail(rawEmail) : ''
if (!email) return { id: null, name: 'Admin API', email: null }
const [admin] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
- .where(or(eq(user.email, email), eq(user.normalizedEmail, email)))
+ .where(eq(foldedEmail(user.email), email))
.limit(1)
return admin ?? { id: null, name: 'Admin Panel', email }
}
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts
index 216d88bbd71..eefad3d2827 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts
@@ -1,21 +1,34 @@
/**
* @vitest-environment node
*/
-import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { recordAudit, recordAuditBatch } from '@sim/audit'
+import {
+ createMockRequest,
+ dbChainMockFns,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted(
- () => ({
- mockDetachOrganizationWorkspacesTx: vi.fn(),
- mockDelete: vi.fn(),
- mockAuthenticateAdminRequest: vi.fn(),
- })
-)
+const {
+ mockDetachOrganizationWorkspacesTx,
+ mockEnqueueResourceCleanup,
+ mockAuthenticateAdminRequest,
+} = vi.hoisted(() => ({
+ mockDetachOrganizationWorkspacesTx: vi.fn(),
+ mockEnqueueResourceCleanup: vi.fn(),
+ mockAuthenticateAdminRequest: vi.fn(),
+}))
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx,
}))
+vi.mock('@/lib/organizations/resource-cleanup', () => ({
+ enqueueOrganizationResourceCleanup: mockEnqueueResourceCleanup,
+}))
+
vi.mock('@/app/api/v1/admin/auth', () => ({
authenticateAdminRequest: mockAuthenticateAdminRequest,
}))
@@ -61,7 +74,7 @@ describe('admin organization DELETE', () => {
/** Returned rather than written, so the caller can emit them post-commit. */
auditEntries: [],
})
- mockDelete.mockClear()
+ mockEnqueueResourceCleanup.mockResolvedValue(undefined)
})
afterAll(resetDbChainMock)
@@ -115,7 +128,7 @@ describe('admin organization DELETE', () => {
expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID)
})
- it('detaches workspaces before deleting the organization', async () => {
+ it('detaches workspaces and enqueues resource cleanup before deleting in the same transaction', async () => {
queueOrganization()
queueTableRows(schemaMock.subscription, [])
queueTableRows(schemaMock.member, [{ value: 3 }])
@@ -130,6 +143,15 @@ describe('admin organization DELETE', () => {
* through so the detach and the delete commit together.
*/
expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID)
+ const tx = mockDetachOrganizationWorkspacesTx.mock.calls[0][0]
+ expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith(tx, ORG_ID)
+ expect(mockDetachOrganizationWorkspacesTx.mock.invocationCallOrder[0]).toBeLessThan(
+ mockEnqueueResourceCleanup.mock.invocationCallOrder[0]
+ )
+ expect(mockEnqueueResourceCleanup.mock.invocationCallOrder[0]).toBeLessThan(
+ dbChainMockFns.delete.mock.invocationCallOrder[0]
+ )
+ expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
const body = await response.json()
expect(body.data).toMatchObject({
@@ -140,4 +162,39 @@ describe('admin organization DELETE', () => {
workspacesDetached: 2,
})
})
+
+ it('aborts the transaction before cascade and audit when durable cleanup cannot be queued', async () => {
+ queueOrganization()
+ queueTableRows(schemaMock.subscription, [])
+ queueTableRows(schemaMock.member, [{ value: 3 }])
+ mockEnqueueResourceCleanup.mockRejectedValueOnce(new Error('outbox unavailable'))
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ expect(response.status).toBe(500)
+ expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledTimes(1)
+ expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith(
+ mockDetachOrganizationWorkspacesTx.mock.calls[0][0],
+ ORG_ID
+ )
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(recordAudit).not.toHaveBeenCalled()
+ expect(recordAuditBatch).not.toHaveBeenCalled()
+ })
+
+ it('does not emit success audit after a cascade failure', async () => {
+ queueOrganization()
+ queueTableRows(schemaMock.subscription, [])
+ queueTableRows(schemaMock.member, [{ value: 3 }])
+ dbChainMockFns.delete.mockImplementationOnce(() => {
+ throw new Error('cascade unavailable')
+ })
+
+ const response = await DELETE(deleteRequest('acme-inc'), routeContext)
+
+ expect(response.status).toBe(500)
+ expect(mockEnqueueResourceCleanup).toHaveBeenCalledTimes(1)
+ expect(recordAudit).not.toHaveBeenCalled()
+ expect(recordAuditBatch).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
index 9849dc47761..15ca0aca873 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
@@ -58,6 +58,7 @@ import {
TERMINAL_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { enqueueOrganizationResourceCleanup } from '@/lib/organizations/resource-cleanup'
import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -299,6 +300,7 @@ export const DELETE = withRouteHandler(
*/
const { detachedWorkspaceIds, auditEntries } = await db.transaction(async (tx) => {
const detached = await detachOrganizationWorkspacesTx(tx, organizationId)
+ await enqueueOrganizationResourceCleanup(tx, organizationId)
await tx.delete(organization).where(eq(organization.id, organizationId))
return detached
})
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
index d63e5f2ae4f..56d10d72095 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
@@ -75,8 +75,8 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
- // Read at module scope by `lib/uploads/utils/file-utils`, which the route now
- // reaches transitively through the knowledge orchestration module.
+ /** Shared upload/connector limits are read by the knowledge orchestration imports. */
+ MAX_FILE_SIZE: 100 * 1024 * 1024,
SUPPORTED_ARCHIVE_EXTENSIONS: [],
}))
diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts
index 0740600766f..2ea89aebe20 100644
--- a/apps/sim/app/api/v2/chat/route.ts
+++ b/apps/sim/app/api/v2/chat/route.ts
@@ -335,7 +335,7 @@ export const POST = withRouteHandler(
const [workspaceContext, integrationTools, entitlements, billingAttribution] =
await Promise.all([
generateWorkspaceContext(workspaceId, userId, { workspaceAccess, secretMountPolicy }),
- buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
+ buildIntegrationToolSchemas(userId, undefined, workspaceId),
computeWorkspaceEntitlements(workspaceId, userId),
// Hosted execution refuses to run without an attribution snapshot;
// the executor path receives it as a header, this path resolves it
diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts
index fe52256bca6..54187076a46 100644
--- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts
+++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts
@@ -325,9 +325,10 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunk
})
describe('chunk operation policy', () => {
- it('denies workspace API keys on every chunk operation', () => {
+ it('allows ACL-filtered chunk listing while retaining the other chunk policies', () => {
+ expect(knowledgeOperations.listChunks.workspaceApiKey).toBe('allow')
+ expect(knowledgeOperations.listChunks.principalKinds).toContain('workspace_api_key')
for (const operation of [
- knowledgeOperations.listChunks,
knowledgeOperations.readChunk,
knowledgeOperations.createChunk,
knowledgeOperations.updateChunk,
diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
index e18e32b78e3..75f47e6f119 100644
--- a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
+++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
@@ -113,6 +113,13 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() }))
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
+vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({
+ createStorageAdapter: () => ({
+ consumeTokensAtomically: async () => ({ allowed: true, retryAfterMs: 0 }),
+ getCooldownUntil: async () => null,
+ setCooldownUntil: async () => undefined,
+ }),
+}))
vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null }))
vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt }))
@@ -267,7 +274,8 @@ describe('Knowledge search provenance through the V2 route and reranker HTTP bou
expect(mocks.generateEmbedding).toHaveBeenCalledWith(
requestInput.query,
expect.anything(),
- 'workspace-1'
+ 'workspace-1',
+ undefined
)
}
)
diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts
index 562c995d3aa..1688466dcc6 100644
--- a/apps/sim/app/api/v2/knowledge/search/route.test.ts
+++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts
@@ -87,6 +87,7 @@ describe('POST /api/v2/knowledge/search', () => {
expect(mockSearch).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
+ surface: 'api',
workspaceId: WORKSPACE_ID,
knowledgeBaseIds: ['kb-1'],
query: 'hello',
diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts
index 4ede73f7b7a..28a122f0a5b 100644
--- a/apps/sim/app/api/v2/knowledge/search/route.ts
+++ b/apps/sim/app/api/v2/knowledge/search/route.ts
@@ -28,6 +28,7 @@ export const POST = defineV2JsonRoute({
: [body.knowledgeBaseIds],
query: body.query,
topK: body.topK,
+ surface: 'api' as const,
tagFilters: body.tagFilters,
searchMode: body.searchMode,
rerankerEnabled: body.rerankerEnabled,
diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts
index 16743d93a6a..bf926e6a871 100644
--- a/apps/sim/app/api/wand/route.ts
+++ b/apps/sim/app/api/wand/route.ts
@@ -130,7 +130,7 @@ async function updateUserStatsForWand(
await recordUsage({
userId: billingAttribution.actorUserId,
- workspaceId: billingAttribution.workspaceId,
+ workspaceId: billingAttribution.workspaceId ?? undefined,
...toBillingContext(billingAttribution),
entries: [
{
diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts
index b4acb132c12..a955c51a990 100644
--- a/apps/sim/app/api/webhooks/outbox/process/route.ts
+++ b/apps/sim/app/api/webhooks/outbox/process/route.ts
@@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
+import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox'
import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
@@ -35,6 +36,7 @@ const handlers = {
...invitationMigrationOutboxHandlers,
...directGrantOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
+ ...organizationResourceCleanupOutboxHandlers,
...workspaceFileLiveDocOutboxHandlers,
...workspaceFileStorageCleanupOutboxHandlers,
...workflowDeploymentOutboxHandlers,
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts
index d47add6d93a..647fe51daa1 100644
--- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts
@@ -1,5 +1,4 @@
import {
- deleteCredentialGroupContract,
getCredentialGroupContract,
updateCredentialGroupContract,
} from '@/lib/api/contracts/credential-groups'
@@ -9,7 +8,6 @@ import {
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
- deleteCredentialGroupSettings,
getCredentialGroupSettings,
updateCredentialGroupSettings,
} from '@/lib/credential-groups/application/manage-groups'
@@ -49,17 +47,3 @@ export const PATCH = defineInternalJsonRoute({
useCase: updateCredentialGroupSettings,
present: ({ credentialGroup }) => ({ credentialGroup }),
})
-
-export const DELETE = defineInternalJsonRoute({
- contract: deleteCredentialGroupContract,
- auth: internalSessionAuth,
- operation: credentialGroupOperations.delete,
- rateLimit,
- errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'),
- mapInput: ({ params }) => ({
- assertedWorkspaceId: params.id,
- credentialGroupId: params.groupId,
- }),
- useCase: deleteCredentialGroupSettings,
- present: () => ({ success: true as const }),
-})
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts
index 21a9e594ede..fb02424e456 100644
--- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts
@@ -33,6 +33,7 @@ export const POST = defineInternalJsonRoute({
slackBotCredentialId: body.slackBotCredentialId,
clientId: body.clientId,
clientSecret: body.clientSecret,
+ requiredScopes: body.requiredScopes,
}),
useCase: startSlackCredentialGroupConfiguration,
})
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts
new file mode 100644
index 00000000000..4f6d7ba70bb
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts
@@ -0,0 +1,76 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ ensure: vi.fn(), getSession: vi.fn() }))
+vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
+vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
+ ensureWorkspaceAccounts: {
+ operation: { id: 'credential_groups.workspace.ensure' },
+ execute: mocks.ensure,
+ },
+}))
+
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { POST } from '@/app/api/workspaces/[id]/credential-groups/ensure/route'
+
+const workspaceId = '11111111-1111-4111-8111-111111111111'
+const credentialGroup = {
+ id: '22222222-2222-4222-8222-222222222222',
+ workspaceId,
+ name: 'Connected accounts',
+ description: null,
+ options: [],
+ mcpServers: [],
+ status: 'active',
+ createdAt: '2026-09-04T00:00:00.000Z',
+ updatedAt: '2026-09-04T00:00:00.000Z',
+}
+const context = { params: Promise.resolve({ id: workspaceId }) }
+const request = () =>
+ new NextRequest(`http://localhost:3000/api/workspaces/${workspaceId}/credential-groups/ensure`, {
+ method: 'POST',
+ })
+
+describe('workspace connected accounts setup route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getSession.mockResolvedValue({
+ user: { id: 'admin-1' },
+ session: { id: 'session-1' },
+ })
+ mocks.ensure.mockResolvedValue({ credentialGroup, created: true })
+ })
+
+ it('authenticates before validating workspace parameters', async () => {
+ mocks.getSession.mockResolvedValue(null)
+ const response = await POST(request(), { params: Promise.resolve({ id: '' }) })
+ expect(response.status).toBe(401)
+ expect(mocks.ensure).not.toHaveBeenCalled()
+ })
+
+ it.each([true, false])(
+ 'returns the same account shape when newly created is %s',
+ async (created) => {
+ mocks.ensure.mockResolvedValue({ credentialGroup, created })
+ const input = request()
+ const response = await POST(input, context)
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ credentialGroup })
+ expect(mocks.ensure).toHaveBeenCalledWith({
+ principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
+ input: { workspaceId },
+ request: input,
+ })
+ }
+ )
+
+ it('preserves the application authorization refusal', async () => {
+ mocks.ensure.mockRejectedValue(new OrchestrationError('forbidden', 'Admin access required'))
+ const response = await POST(request(), context)
+ expect(response.status).toBe(403)
+ expect(await response.json()).toEqual({ error: 'Admin access required' })
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts
new file mode 100644
index 00000000000..f4bc337bbd2
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts
@@ -0,0 +1,23 @@
+import { ensureWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { ensureWorkspaceAccounts } from '@/lib/credential-groups/application/manage-groups'
+import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const POST = defineInternalJsonRoute({
+ contract: ensureWorkspaceAccountsContract,
+ auth: internalSessionAuth,
+ operation: credentialGroupOperations.ensureWorkspaceAccounts,
+ rateLimit: internalRateLimits.none({ reason: 'Idempotent, admin-only workspace account setup' }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy(
+ 'Failed to set up connected accounts',
+ 'Workspace not found'
+ ),
+ mapInput: ({ params }) => ({ workspaceId: params.id }),
+ useCase: ensureWorkspaceAccounts,
+ present: ({ credentialGroup }) => ({ credentialGroup }),
+})
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts
index 6259951e256..0016c90d4a2 100644
--- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts
@@ -6,7 +6,6 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
- create: vi.fn(),
getSession: vi.fn(),
list: vi.fn(),
}))
@@ -14,30 +13,20 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
- createCredentialGroupSettings: {
- operation: { id: 'credential_groups.create' },
- execute: mocks.create,
- },
- listCredentialGroupSettings: {
- operation: { id: 'credential_groups.settings.list' },
+ getWorkspaceAccountsSettings: {
+ operation: { id: 'credential_groups.workspace.read' },
execute: mocks.list,
},
}))
import { OrchestrationError } from '@/lib/core/orchestration/types'
-import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
-import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route'
+import { GET } from '@/app/api/workspaces/[id]/credential-groups/route'
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
const context = { params: Promise.resolve({ id: WORKSPACE_ID }) }
-function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest {
- return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, {
- method,
- ...(body
- ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }
- : {}),
- })
+function createRequest(): NextRequest {
+ return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`)
}
describe('credential groups collection route', () => {
@@ -47,24 +36,15 @@ describe('credential groups collection route', () => {
user: { id: 'user-1' },
session: { id: 'session-1' },
})
- mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] })
- })
-
- it('authenticates before parsing the request body', async () => {
- mocks.getSession.mockResolvedValue(null)
-
- const response = await POST(createRequest('POST', {}), context)
-
- expect(response.status).toBe(401)
- expect(mocks.create).not.toHaveBeenCalled()
+ mocks.list.mockResolvedValue({ credentialGroup: null, availableProviders: ['gmail'] })
})
it('enters the application use case with the authenticated session principal', async () => {
- const request = createRequest('GET')
+ const request = createRequest()
const response = await GET(request, context)
expect(response.status).toBe(200)
- expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] })
+ expect(await response.json()).toEqual({ credentialGroup: null, availableProviders: ['gmail'] })
expect(mocks.list).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: WORKSPACE_ID },
@@ -77,28 +57,9 @@ describe('credential groups collection route', () => {
new OrchestrationError('not_found', 'Credential Groups are not available')
)
- const response = await GET(createRequest('GET'), context)
+ const response = await GET(createRequest(), context)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'Credential Groups are not available' })
})
-
- it('fails fast when managed Gmail OAuth is not configured', async () => {
- mocks.create.mockRejectedValue(
- new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured')
- )
-
- const response = await POST(
- createRequest('POST', {
- name: 'Support inboxes',
- options: [{ provider: 'gmail', label: 'Gmail', required: true }],
- }),
- context
- )
-
- expect(response.status).toBe(503)
- expect(await response.json()).toEqual({
- error: 'Managed Gmail authorization is not configured',
- })
- })
})
diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts
index c776f985698..27b7c06beea 100644
--- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts
@@ -1,45 +1,24 @@
-import {
- createCredentialGroupContract,
- listCredentialGroupsContract,
-} from '@/lib/api/contracts/credential-groups'
+import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
-import {
- createCredentialGroupSettings,
- listCredentialGroupSettings,
-} from '@/lib/credential-groups/application/manage-groups'
+import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
export const GET = defineInternalJsonRoute({
- contract: listCredentialGroupsContract,
+ contract: getWorkspaceAccountsContract,
auth: internalSessionAuth,
- operation: credentialGroupOperations.listSettings,
+ operation: credentialGroupOperations.workspaceSettings,
rateLimit: internalRateLimits.none({
- reason: 'Preserve existing internal Credential Group list behavior',
+ reason: 'Workspace account settings do not require additional admission limits',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
- 'Failed to list credential groups',
+ 'Failed to load connected accounts',
'Workspace not found'
),
mapInput: ({ params }) => ({ workspaceId: params.id }),
- useCase: listCredentialGroupSettings,
-})
-
-export const POST = defineInternalJsonRoute({
- contract: createCredentialGroupContract,
- auth: internalSessionAuth,
- operation: credentialGroupOperations.create,
- rateLimit: internalRateLimits.none({
- reason: 'Preserve existing internal Credential Group create behavior',
- }),
- errorPolicy: createCredentialGroupInternalErrorPolicy(
- 'Failed to create credential group',
- 'Workspace not found'
- ),
- mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }),
- useCase: createCredentialGroupSettings,
+ useCase: getWorkspaceAccountsSettings,
})
diff --git a/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts
new file mode 100644
index 00000000000..16ec78bb6c8
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts
@@ -0,0 +1,18 @@
+import { getWorkspaceOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts'
+
+export const GET = defineInternalJsonRoute({
+ contract: getWorkspaceOrganizationAccountsContract,
+ auth: internalSessionAuth,
+ operation: getWorkspaceOrganizationAccounts.operation,
+ rateLimit: internalRateLimits.none({ reason: 'Read-only workspace organization account status' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ workspaceId: params.id }),
+ useCase: getWorkspaceOrganizationAccounts,
+})
diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts
index 5e261f4b326..7fce432cca5 100644
--- a/apps/sim/app/api/workspaces/[id]/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/route.ts
@@ -78,11 +78,10 @@ export const PATCH = withRouteHandler(
try {
const body = parsed.data.body
- const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
+ const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
if (
name === undefined &&
- color === undefined &&
logoUrl === undefined &&
billedAccountUserId === undefined &&
allowPersonalApiKeys === undefined
@@ -106,10 +105,6 @@ export const PATCH = withRouteHandler(
updateData.name = name
}
- if (color !== undefined) {
- updateData.color = color
- }
-
if (logoUrl !== undefined) {
updateData.logoUrl = logoUrl
}
@@ -198,7 +193,6 @@ export const PATCH = withRouteHandler(
metadata: {
changes: {
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
- ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
...(logoUrl !== undefined && {
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
}),
diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.test.ts b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts
new file mode 100644
index 00000000000..21a928e499f
--- /dev/null
+++ b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() }))
+vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
+vi.mock('@/lib/invitations/application/send-invitation-batch', () => {
+ const operation = {
+ id: 'invitations.send_batch',
+ capability: 'invitations.send',
+ principalKinds: ['session'],
+ }
+ return {
+ invitationOperations: { sendBatch: operation },
+ sendInvitationBatch: { operation, execute: mocks.execute },
+ }
+})
+
+import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations'
+import { POST } from '@/app/api/workspaces/invitations/batch/route'
+
+function request(body: unknown) {
+ return createMockRequest(
+ 'POST',
+ body,
+ undefined,
+ 'http://localhost:3000/api/workspaces/invitations/batch'
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } })
+ mocks.execute.mockResolvedValue({
+ success: true,
+ successful: ['person@example.com'],
+ added: [],
+ failed: [],
+ invitations: [],
+ })
+})
+
+describe('invitation batch route', () => {
+ it('authenticates before parsing or calling the operation', async () => {
+ mocks.session.mockResolvedValue(null)
+ const response = await POST(request({}), { params: Promise.resolve({}) })
+ expect(response.status).toBe(401)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('forwards a canonical session principal and explicit organization-only input', async () => {
+ const body = {
+ organizationId: 'org-target',
+ workspaceIds: [],
+ emails: ['person@example.com'],
+ membership: 'member',
+ }
+ const response = await POST(request(body), { params: Promise.resolve({}) })
+ expect(response.status).toBe(200)
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal: { kind: 'session', sessionId: 'session', userId: 'actor' },
+ input: body,
+ })
+ )
+ })
+
+ it('rejects empty unscoped workspace lists before use-case execution', async () => {
+ const response = await POST(request({ workspaceIds: [], emails: ['person@example.com'] }), {
+ params: Promise.resolve({}),
+ })
+ expect(response.status).toBe(400)
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('preserves authorization refusal status and error shape', async () => {
+ mocks.execute.mockRejectedValue(
+ new WorkspaceInvitationError({
+ message: 'Only organization owners and admins can invite members.',
+ status: 403,
+ })
+ )
+ const response = await POST(
+ request({ organizationId: 'org', workspaceIds: [], emails: ['person@example.com'] }),
+ { params: Promise.resolve({}) }
+ )
+ expect(response.status).toBe(403)
+ expect(await response.json()).toEqual({
+ error: 'Only organization owners and admins can invite members.',
+ })
+ })
+})
diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts
index b10dcdee454..905fb499ae4 100644
--- a/apps/sim/app/api/workspaces/invitations/batch/route.ts
+++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts
@@ -1,134 +1,43 @@
-import { createLogger } from '@sim/logger'
-import { normalizeEmail } from '@sim/utils/string'
-import { type NextRequest, NextResponse } from 'next/server'
import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { ForbiddenOperationError } from '@/lib/core/application'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- createWorkspaceInvitation,
- prepareWorkspaceInvitationContext,
- WorkspaceInvitationError,
- type WorkspaceInvitationResult,
-} from '@/lib/invitations/workspace-invitations'
+ defineInternalJsonRoute,
+ internalErrorResponse,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ invitationOperations,
+ sendInvitationBatch,
+} from '@/lib/invitations/application/send-invitation-batch'
+import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations'
import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic'
-const logger = createLogger('WorkspaceInvitationBatchAPI')
-
-interface BatchInvitationFailure {
- email: string
- error: string
-}
-
-function batchErrorResponse(error: unknown) {
- if (error instanceof WorkspaceInvitationError) {
- return NextResponse.json(
- {
- error: error.message,
- ...(error.email ? { email: error.email } : {}),
- ...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}),
- },
- { status: error.status }
- )
- }
-
- if (error instanceof InvitationsNotAllowedError) {
- return NextResponse.json({ error: error.message }, { status: 403 })
- }
-
- logger.error('Error creating workspace invitation batch:', error)
- return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 })
-}
-
-export const POST = withRouteHandler(async (req: NextRequest) => {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- try {
- const parsed = await parseRequest(batchWorkspaceInvitationsContract, req, {})
- if (!parsed.success) return parsed.response
- const { body } = parsed.data
-
- const context = await prepareWorkspaceInvitationContext({
- workspaceIds: body.workspaceIds,
- inviterId: session.user.id,
- inviterName: session.user.name || session.user.email || 'A user',
- inviterEmail: session.user.email,
- })
-
- const successful: string[] = []
- const added: string[] = []
- const failed: BatchInvitationFailure[] = []
- const invitations: WorkspaceInvitationResult[] = []
- const seenEmails = new Set()
-
- for (const rawEmail of body.emails) {
- const normalizedEmail = normalizeEmail(rawEmail)
- if (seenEmails.has(normalizedEmail)) {
- failed.push({
- email: normalizedEmail,
- error: `${normalizedEmail} appears more than once in this invitation batch`,
- })
- continue
- }
- seenEmails.add(normalizedEmail)
-
- try {
- const invitation = await createWorkspaceInvitation({
- context,
- email: rawEmail,
- permission: body.permission,
- membership: body.membership,
- request: req,
- })
- if (invitation.instantAdd) {
- // Only report an actual insertion; an `unchanged` outcome means the
- // user already had access (rare race) and is a silent no-op.
- if (invitation.outcome === 'added') added.push(invitation.email)
- } else {
- successful.push(invitation.email)
- }
- invitations.push(invitation)
- } catch (error) {
- if (error instanceof WorkspaceInvitationError) {
- failed.push({ email: error.email ?? normalizedEmail, error: error.message })
- continue
- }
- /** A directory-managed address is refused with its reason, like any other per-email refusal. */
- if (error instanceof ForbiddenOperationError) {
- failed.push({ email: normalizedEmail, error: error.message })
- continue
- }
-
- /**
- * One bad address must not discard the invitations that already
- * succeeded, so unexpected failures are reported per email rather than
- * aborting the batch.
- */
- logger.error('Unexpected workspace invitation batch item failure:', {
- email: normalizedEmail,
- error,
- })
- failed.push({
- email: normalizedEmail,
- error: 'Failed to create invitation',
+export const POST = defineInternalJsonRoute({
+ contract: batchWorkspaceInvitationsContract,
+ auth: internalSessionAuth,
+ operation: invitationOperations.sendBatch,
+ rateLimit: internalRateLimits.none({
+ reason: 'Preserve the existing bounded invitation batch behavior.',
+ }),
+ errorPolicy: {
+ project(error) {
+ if (error instanceof WorkspaceInvitationError) {
+ return internalErrorResponse(error.status, {
+ error: error.message,
+ ...(error.email ? { email: error.email } : {}),
+ ...(error.upgradeRequired !== undefined
+ ? { upgradeRequired: error.upgradeRequired }
+ : {}),
})
}
- }
-
- return NextResponse.json({
- success: failed.length === 0,
- successful,
- added,
- failed,
- invitations,
- })
- } catch (error) {
- return batchErrorResponse(error)
- }
+ if (error instanceof InvitationsNotAllowedError)
+ return internalErrorResponse(403, { error: error.message })
+ return null
+ },
+ unhandled: () => internalErrorResponse(500, { error: 'Failed to create invitation batch' }),
+ },
+ mapInput: ({ body }) => body,
+ useCase: sendInvitationBatch,
})
diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts
index ccbef3c9db2..2aa469b8ffa 100644
--- a/apps/sim/app/api/workspaces/invitations/route.test.ts
+++ b/apps/sim/app/api/workspaces/invitations/route.test.ts
@@ -119,7 +119,9 @@ describe('POST /api/workspaces/invitations/batch', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
+ queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }])
mockGetSession.mockResolvedValue({
+ session: { id: 'session-1' },
user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' },
})
mockGetWorkspaceWithOwner.mockResolvedValue({
@@ -171,6 +173,7 @@ describe('POST /api/workspaces/invitations/batch', () => {
afterAll(() => {
resetDbChainMock()
+ queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }])
})
it('keeps unexpected database details out of per-email failures', async () => {
@@ -190,7 +193,7 @@ describe('POST /api/workspaces/invitations/batch', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(false)
expect(data.failed).toEqual([
- { email: 'new@example.com', error: 'Failed to create invitation' },
+ { email: 'new@example.com', error: 'Failed to create invitation. Please try again.' },
])
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts
index 50033c1d51c..c5b398fa3f1 100644
--- a/apps/sim/app/api/workspaces/route.ts
+++ b/apps/sim/app/api/workspaces/route.ts
@@ -123,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const parsed = await parseRequest(createWorkspaceContract, req, {})
if (!parsed.success) return parsed.response
- const { name, color, skipDefaultWorkflow } = parsed.data.body
+ const { name, skipDefaultWorkflow } = parsed.data.body
const activeOrganizationId = getActiveOrganizationId(session)
const creationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
@@ -153,7 +153,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
userId: session.user.id,
name,
skipDefaultWorkflow,
- explicitColor: color,
organizationId: creationPolicy.organizationId,
workspaceMode: creationPolicy.workspaceMode,
billedAccountUserId: creationPolicy.billedAccountUserId,
@@ -188,7 +187,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
description: `Created workspace "${newWorkspace.name}"`,
metadata: {
name: newWorkspace.name,
- color: newWorkspace.color,
workspaceMode: newWorkspace.workspaceMode,
organizationId: newWorkspace.organizationId,
},
diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx
index 4841914b303..6191d134b6c 100644
--- a/apps/sim/app/credential-groups/complete/page.tsx
+++ b/apps/sim/app/credential-groups/complete/page.tsx
@@ -6,12 +6,31 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
}
-export default function CredentialGroupCompletePage() {
+const OAUTH_FAILURE_MESSAGES = {
+ denied: 'Authorization was canceled. Return to the chat to try again.',
+ account_mismatch: 'Choose the account matching your Sim email address.',
+ permissions_required: 'All requested permissions are required to connect this account.',
+ configuration_changed: 'The connection settings changed. Return to the chat to try again.',
+ rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
+ unavailable: 'This connection is unavailable. Return to the chat to try again.',
+ failed: 'Account authorization did not complete. Return to the chat to try again.',
+} as const
+
+export default async function CredentialGroupCompletePage({
+ searchParams,
+}: {
+ searchParams: Promise<{ oauth?: string | string[] }>
+}) {
+ const { oauth } = await searchParams
+ const error =
+ typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth)
+ ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES]
+ : undefined
return (
)
diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx
index ab2fcffbfba..24c77bee3cb 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx
@@ -1,15 +1,15 @@
'use client'
-import { chipVariants } from '@sim/emcn'
+import { type ChipLinkProps, chipVariants } from '@sim/emcn'
-interface OAuthConnectLinkProps {
+interface OAuthConnectLinkProps extends Pick {
href: string
reconnect?: boolean
}
-export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) {
+export function OAuthConnectLink({ href, reconnect = false, variant }: OAuthConnectLinkProps) {
return (
-
+
{reconnect ? 'Reconnect' : 'Connect'}
)
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
new file mode 100644
index 00000000000..7b5d08d9439
--- /dev/null
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
@@ -0,0 +1,257 @@
+/** @vitest-environment jsdom */
+import type { ReactNode } from 'react'
+import { authMockFns } from '@sim/testing'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments'
+
+const mocks = vi.hoisted(() => ({
+ authenticate: vi.fn(),
+ read: vi.fn(),
+ rateLimit: vi.fn(),
+}))
+
+vi.mock('next/headers', () => ({ headers: async () => new Headers() }))
+vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
+ authenticateCredentialGroupEnrollment: mocks.authenticate,
+}))
+vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
+ readPublicCredentialGroupEnrollment: { execute: mocks.read },
+}))
+vi.mock('@/lib/credential-groups/rate-limit', () => ({
+ enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit,
+}))
+vi.mock('@/lib/credential-groups/providers', () => ({
+ CREDENTIAL_GROUP_PROVIDER_IDS: ['confluence', 'slack'],
+ CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['confluence'],
+ getCredentialGroupProviderService: (provider: string) => ({
+ providerId: provider,
+ name: provider === 'confluence' ? 'Confluence' : 'Slack',
+ icon: () => null,
+ }),
+}))
+vi.mock('@/lib/credential-groups/managed-mcp-connector-icons', () => ({
+ getManagedMcpConnectorIcon: () => () => null,
+}))
+vi.mock('@/app/(auth)/components', () => ({
+ AuthHeader: ({ title, description }: { title: string; description: string }) => (
+
+ {title}
+ {description}
+
+ ),
+ SupportFooter: () => null,
+}))
+vi.mock('@/app/(landing)/components', () => ({
+ LogoShell: ({ children }: { children: ReactNode }) => {children} ,
+}))
+vi.mock('@/app/credential-groups/enroll/[token]/oauth-toast', () => ({
+ CredentialGroupOAuthToast: ({ message }: { message: string }) => (
+ {message}
+ ),
+}))
+
+import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
+import CredentialGroupEnrollmentPage from '@/app/credential-groups/enroll/[token]/page'
+
+const principal = {
+ kind: 'credential_group_enrollment',
+ workspaceId: 'canonical-workspace',
+ credentialGroupId: 'accounts',
+ enrollmentId: 'enrollment',
+ email: 'member@example.test',
+ invitationTokenHash: 'hash',
+} as const
+let enrollment: PublicCredentialGroupEnrollment
+
+async function render(searchParams: Record = {}) {
+ const page = await CredentialGroupEnrollmentPage({
+ params: Promise.resolve({ token: 'invitation' }),
+ searchParams: Promise.resolve(searchParams),
+ })
+ document.body.innerHTML = renderToStaticMarkup(page)
+}
+
+function oauthLinks() {
+ return Array.from(document.querySelectorAll('a')).filter((link) =>
+ link.getAttribute('href')?.includes('/oauth/')
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'member', email: 'member@example.test', emailVerified: true },
+ session: { id: 'session-1' },
+ })
+ mocks.authenticate.mockResolvedValue(principal)
+ mocks.rateLimit.mockResolvedValue(null)
+ enrollment = {
+ inviterName: 'Admin',
+ workspaceName: 'Company',
+ credentialGroupName: 'Accounts',
+ status: 'in_progress',
+ options: [
+ {
+ id: 'site-one',
+ label: 'First Confluence site',
+ provider: 'confluence',
+ status: 'active',
+ required: false,
+ connections: [],
+ },
+ {
+ id: 'site-two',
+ label: 'Second Confluence site',
+ provider: 'confluence',
+ status: 'active',
+ required: false,
+ connections: [],
+ },
+ {
+ id: 'slack',
+ label: 'Slack',
+ provider: 'slack',
+ status: 'active',
+ required: true,
+ connections: [],
+ },
+ ],
+ mcpServers: [
+ {
+ id: 'mcp-one',
+ name: 'Unrelated MCP',
+ description: null,
+ managedConnectorId: 'linear',
+ connection: null,
+ },
+ ],
+ }
+ mocks.read.mockImplementation(async () => ({ enrollment }))
+})
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+describe('focused Search enrollment', () => {
+ it('retains the generic invitation choices and Submit without Search context', async () => {
+ await render()
+ expect(oauthLinks()).toHaveLength(3)
+ expect(document.body.textContent).toContain('Unrelated MCP')
+ expect(document.querySelector('form')?.getAttribute('action')).toBe(
+ '/api/credential-groups/enroll/invitation/complete'
+ )
+ expect(document.querySelector('button')?.textContent).toBe('Submit')
+ expect(document.body.textContent).not.toContain('Return to Search')
+ expect(document.body.textContent).not.toContain('Setup guide')
+ expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} })
+ })
+
+ it('shows only the exact requested option and derives the return workspace from the principal', async () => {
+ await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' })
+ expect(document.querySelector('h1')?.textContent).toBe('Connect your Confluence account')
+ expect(document.body.textContent).toContain('Second Confluence site')
+ expect(document.body.textContent).not.toContain('First Confluence site')
+ expect(document.body.textContent).not.toContain('Slack')
+ expect(document.body.textContent).not.toContain('Unrelated MCP')
+ expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([
+ '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=search',
+ ])
+ expect(document.querySelector('form')).toBeNull()
+ expect(
+ Array.from(document.querySelectorAll('a'))
+ .find((link) => link.textContent === 'Return to Search')
+ ?.getAttribute('href')
+ ).toBe('/workspace/canonical-workspace/search')
+ const guide = Array.from(document.querySelectorAll('a')).find(
+ (link) => link.textContent === 'Setup guide'
+ )
+ expect(guide?.getAttribute('href')).toBe('https://docs.sim.ai/search/confluence')
+ expect(guide?.getAttribute('target')).toBe('_blank')
+ expect(guide?.getAttribute('rel')).toBe('noopener noreferrer')
+ expect(mocks.read).toHaveBeenCalledWith({ principal, input: { optionId: 'site-two' } })
+ })
+
+ it.each(['missing', '', 'site-two', ['site-one', 'site-two']])(
+ 'does not substitute a different account when focus is unusable: %s',
+ async (optionId) => {
+ enrollment.options[1]!.status = 'disabled'
+ await render({
+ returnTo: 'search',
+ optionId: Array.isArray(optionId) ? [...optionId] : optionId,
+ })
+ expect(document.body.textContent).toContain('Ask a workspace admin')
+ expect(oauthLinks()).toHaveLength(0)
+ expect(document.querySelector('form')).toBeNull()
+ expect(document.body.textContent).toContain('Return to Search')
+ }
+ )
+
+ it('reports provider configuration failures with a clear path back to Search', async () => {
+ mocks.read.mockRejectedValue(
+ new CredentialGroupProviderConfigurationError('Slack configuration missing')
+ )
+ await render({ returnTo: 'search', optionId: 'slack' })
+ expect(document.body.textContent).toContain('Connection unavailable')
+ expect(document.body.textContent).toContain('Ask a workspace admin')
+ expect(document.querySelector('a')?.getAttribute('href')).toBe(
+ '/workspace/canonical-workspace/search'
+ )
+ })
+
+ it('shows Connected from current credential state without requiring generic completion', async () => {
+ enrollment.options[1]!.connections = [
+ {
+ email: principal.email,
+ displayName: null,
+ avatarUrl: null,
+ status: 'connected',
+ grantedAt: '2026-09-05T12:00:00Z',
+ },
+ ]
+ await render({ returnTo: 'search', optionId: 'site-two' })
+ expect(document.body.textContent).toContain(`${principal.email} · Connected`)
+ expect(document.querySelector('h1')?.textContent).toBe('Confluence connected')
+ expect(oauthLinks()).toHaveLength(0)
+ expect(document.querySelector('form')).toBeNull()
+ expect(document.body.textContent).toContain('Return to Search')
+ })
+
+ it('does not treat a success query marker as a connected account', async () => {
+ await render({
+ returnTo: 'search',
+ optionId: 'site-two',
+ connected: 'site-two',
+ mcp: 'connected',
+ mcpServerId: 'mcp-one',
+ })
+ expect(oauthLinks()[0]?.textContent).toBe('Connect')
+ expect(document.body.textContent).toContain('Not connected')
+ expect(document.querySelector('[role="status"]')).toBeNull()
+ })
+
+ it('keeps the same focused Reconnect action after canceled authorization', async () => {
+ enrollment.options[1]!.connections = [
+ {
+ email: principal.email,
+ displayName: null,
+ avatarUrl: null,
+ status: 'needs_reauth',
+ grantedAt: '2026-09-05T12:00:00Z',
+ },
+ ]
+ await render({ returnTo: 'search', optionId: 'site-two', oauth: 'denied' })
+ expect(oauthLinks()).toHaveLength(1)
+ expect(oauthLinks()[0]?.textContent).toBe('Reconnect')
+ expect(oauthLinks()[0]?.getAttribute('href')).toContain('/site-two?returnTo=search')
+ })
+
+ it('does not resolve enrollment metadata or trust a return workspace after authentication fails', async () => {
+ mocks.authenticate.mockResolvedValue(null)
+ await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' })
+ expect(document.body.textContent).toContain('Invitation unavailable')
+ expect(mocks.read).not.toHaveBeenCalled()
+ expect(document.querySelector('a')).toBeNull()
+ })
+})
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
index 25a2a5ba955..790319a44f2 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
@@ -1,14 +1,22 @@
import { type ReactNode, Suspense } from 'react'
-import { Chip } from '@sim/emcn'
+import { Chip, ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
+import type { ResourceOwner } from '@/lib/core/resource-scope'
+import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
+import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments'
import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons'
+import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
-import { SupportFooter } from '@/app/(auth)/components'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors'
+import { AuthHeader, SupportFooter } from '@/app/(auth)/components'
import { LogoShell } from '@/app/(landing)/components'
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
@@ -44,18 +52,42 @@ function PageShell({ children }: PageShellProps) {
)
}
-function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) {
+interface UnavailableInvitationProps {
+ rateLimited?: boolean
+ message?: string
+}
+
+function UnavailableInvitation({ rateLimited = false, message }: UnavailableInvitationProps) {
return (
-
- {rateLimited ? 'Too many requests' : 'Invitation unavailable'}
-
-
- {rateLimited
- ? 'This link has been opened too many times. Wait a few minutes and try again.'
- : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'}
-
+
+
+
+ )
+}
+
+interface UnavailableSearchConnectionProps {
+ owner: ResourceOwner
+}
+
+function UnavailableSearchConnection({ owner }: UnavailableSearchConnectionProps) {
+ return (
+
+
+
+ Return to Search
)
@@ -92,19 +124,45 @@ export default async function CredentialGroupEnrollmentPage({
const { token } = await params
if (!token || token.length > 128) return
-
+ const resolvedSearchParams = await searchParams
+ const session = await getSession()
+ if (!session?.user) {
+ const callback = new URLSearchParams()
+ for (const key of ['returnTo', 'optionId']) {
+ const value = getSearchParam(resolvedSearchParams, key)
+ if (value) callback.set(key, value)
+ }
+ const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}`
+ redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`)
+ }
+ if (!session.user.emailVerified)
+ return (
+
+ )
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) return
+ const returnToSearch = resolvedSearchParams.returnTo === 'search'
+ const requestedOptionId = resolvedSearchParams.optionId
+ const focusedOptionId =
+ typeof requestedOptionId === 'string' && requestedOptionId.length <= 128
+ ? requestedOptionId
+ : ''
const enrollmentResult = await readPublicCredentialGroupEnrollment
- .execute({ principal, input: {} })
+ .execute({ principal, input: returnToSearch ? { optionId: focusedOptionId } : {} })
.catch((error: unknown) => {
+ if (error instanceof CredentialGroupEnrollmentError)
+ return { enrollment: null, enrollmentError: error.message }
if (asOrchestrationError(error)?.code === 'not_found') return null
+ if (returnToSearch && error instanceof CredentialGroupProviderConfigurationError)
+ return { enrollment: null }
throw error
})
if (!enrollmentResult) return
+ if ('enrollmentError' in enrollmentResult)
+ return
const { enrollment } = enrollmentResult
+ if (!enrollment) return
- const resolvedSearchParams = await searchParams
const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth')
const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected')
const connectedMcpServerId =
@@ -112,29 +170,44 @@ export default async function CredentialGroupEnrollmentPage({
? getSearchParam(resolvedSearchParams, 'mcpServerId')
: undefined
const oauthMessage =
- oauthStatus && oauthStatus in OAUTH_MESSAGES
+ oauthStatus && Object.hasOwn(OAUTH_MESSAGES, oauthStatus)
? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES]
: null
const activeOptions = enrollment.options.filter((option) => option.status === 'active')
+ const focusedOption = returnToSearch
+ ? activeOptions.find((option) => option.id === focusedOptionId)
+ : undefined
+ if (returnToSearch && !focusedOption) return
+ const visibleOptions = focusedOption ? [focusedOption] : activeOptions
+ const focusedConnected = focusedOption?.connections[0]?.status === 'connected'
+ const focusedProviderId = focusedOption
+ ? getCredentialGroupProviderService(focusedOption.provider).providerId
+ : undefined
+ const docsUrl = focusedProviderId
+ ? SEARCH_CONNECTORS.find((connector) => connector.providerIds.includes(focusedProviderId))?.meta
+ .searchDocsUrl
+ : undefined
const connectedOption = connectedOptionId
? activeOptions.find((option) => option.id === connectedOptionId)
: undefined
const connectedMcpServer = connectedMcpServerId
? enrollment.mcpServers.find((server) => server.id === connectedMcpServerId)
: undefined
- const notification = connectedMcpServerId
- ? {
- message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`,
- variant: 'success' as const,
- }
- : connectedOptionId
+ const notification =
+ !returnToSearch && connectedMcpServerId
? {
- message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
+ message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`,
variant: 'success' as const,
}
- : oauthMessage
- ? { message: oauthMessage, variant: 'error' as const }
- : null
+ : connectedOptionId &&
+ (!returnToSearch || (connectedOptionId === focusedOption?.id && focusedConnected))
+ ? {
+ message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
+ variant: 'success' as const,
+ }
+ : oauthMessage
+ ? { message: oauthMessage, variant: 'error' as const }
+ : null
return (
{notification && (
@@ -142,28 +215,25 @@ export default async function CredentialGroupEnrollmentPage({
)}
-
+
- {activeOptions.map((option) => {
+ {visibleOptions.map((option) => {
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
const connection = option.connections[0]
return (
@@ -171,51 +241,82 @@ export default async function CredentialGroupEnrollmentPage({
key={option.id}
icon={
}
title={option.label}
- description={connection?.email ?? 'Not connected'}
- trailing={
-
- }
- />
- )
- })}
- {enrollment.mcpServers.map((server) => {
- const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId)
- return (
-
}
- title={server.name}
description={
- server.connection?.status === 'connected'
- ? 'Connected'
- : server.connection
- ? 'Reconnect required'
- : server.description || 'Not connected'
+ returnToSearch && connection?.status === 'connected'
+ ? `${connection.email} · Connected`
+ : (connection?.email ?? 'Not connected')
}
trailing={
-
+ returnToSearch && connection?.status === 'connected' ? undefined : (
+
+ )
}
/>
)
})}
+ {!returnToSearch &&
+ enrollment.mcpServers.map((server) => {
+ const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId)
+ return (
+
}
+ title={server.name}
+ description={
+ server.connection?.status === 'connected'
+ ? 'Connected'
+ : server.connection
+ ? 'Reconnect required'
+ : server.description || 'Not connected'
+ }
+ trailing={
+
+ }
+ />
+ )
+ })}
-
+ {returnToSearch ? (
+
+ {docsUrl && (
+
+ Setup guide
+
+ )}
+
+ Return to Search
+
+
+ ) : (
+
+ )}
)
}
+
+function searchReturnPath(owner: ResourceOwner): string {
+ const scope = resourceScopeFromOwner(owner)
+ return scope.kind === 'workspace'
+ ? `/workspace/${encodeURIComponent(scope.workspaceId)}/search`
+ : organizationRoutes(scope.organizationId).integrations
+}
diff --git a/apps/sim/app/desktop/connect/switch-account.tsx b/apps/sim/app/desktop/connect/switch-account.tsx
index 4f132144855..d2be918b9e9 100644
--- a/apps/sim/app/desktop/connect/switch-account.tsx
+++ b/apps/sim/app/desktop/connect/switch-account.tsx
@@ -14,7 +14,7 @@ interface SwitchAccountProps {
* callback.
*
* A plain link to `/login` would not work: the middleware bounces `/login` back
- * to `/workspace` while any session cookie is set, so the wrong account has to
+ * to the app entry while any session cookie is set, so the wrong account has to
* be cleared before the login page is reachable at all. For the same reason a
* failed sign-out must not navigate — it would land the user right back where
* they started with no explanation.
diff --git a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
index c5a0d690700..371c0b5420b 100644
--- a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
+++ b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
@@ -9,6 +9,7 @@ import {
type EnterpriseOwnerClaimDetails,
} from '@/lib/api/contracts/enterprise-owner-claims'
import { client, useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useEnterpriseOwnerClaimDetails } from '@/hooks/queries/enterprise-owner-claims'
@@ -265,7 +266,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris
label: 'Sign in to Enterprise',
onClick: async () => {
await client.signOut()
- router.push(authLink('/login', '/workspace'))
+ router.push(authLink('/login', APP_ENTRY_PATH))
},
},
]
diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx
new file mode 100644
index 00000000000..90760f97c44
--- /dev/null
+++ b/apps/sim/app/home/page.test.tsx
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment node
+ */
+import { authMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRedirect, mockResolveAppEntryPath } = vi.hoisted(() => ({
+ mockRedirect: vi.fn((path: string) => {
+ throw new Error(`NEXT_REDIRECT:${path}`)
+ }),
+ mockResolveAppEntryPath: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: mockRedirect,
+}))
+
+vi.mock('@/lib/navigation/resolve-app-entry', () => ({
+ resolveAppEntryPath: mockResolveAppEntryPath,
+}))
+
+import AppEntryPage from '@/app/home/page'
+
+const mockGetSession = authMockFns.mockGetSession
+
+describe('AppEntryPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('sends a signed-out visitor to login without resolving an entry', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login')
+ expect(mockResolveAppEntryPath).not.toHaveBeenCalled()
+ })
+
+ it('forwards a signed-in viewer to their resolved entry', async () => {
+ const session = { user: { id: 'viewer' } }
+ mockGetSession.mockResolvedValue(session)
+ mockResolveAppEntryPath.mockResolvedValue('/o/org-1/home')
+
+ await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
+ expect(mockResolveAppEntryPath).toHaveBeenCalledWith(session)
+ })
+})
diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx
new file mode 100644
index 00000000000..1965f6894c2
--- /dev/null
+++ b/apps/sim/app/home/page.tsx
@@ -0,0 +1,18 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
+
+/**
+ * The signed-in app's front door. Nothing renders here: the viewer is forwarded to
+ * their organization's home, or to their workspaces when they belong to none. Every
+ * default post-auth destination points at this route, so where a viewer lands is
+ * decided once, on the server, with their membership in hand.
+ */
+export default async function AppEntryPage() {
+ const session = await getSession()
+ if (!session?.user) {
+ redirect('/login')
+ }
+
+ redirect(await resolveAppEntryPath(session))
+}
diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx
index ffc75e40866..f762c4ae795 100644
--- a/apps/sim/app/invite/[id]/invite.tsx
+++ b/apps/sim/app/invite/[id]/invite.tsx
@@ -10,6 +10,7 @@ import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
import { client, useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useInvitationDetails } from '@/hooks/queries/invitations'
@@ -459,7 +460,7 @@ export default function Invite({ registrationDisabled }: InviteProps) {
description={error.message}
icon='users'
actions={[
- { label: 'Manage Team Settings', onClick: () => router.push('/workspace') },
+ { label: 'Manage Team Settings', onClick: () => router.push(APP_ENTRY_PATH) },
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index 14425f998c3..ab09b550b13 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -108,19 +108,22 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
} catch (e) {}
+ // The organization surface (/o/...) shares the workspace chrome and
+ // needs the same variables set before first paint.
try {
var path = window.location.pathname;
- if (path.indexOf('/workspace/') === -1) {
+ if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0) {
return;
}
} catch (e) {
return;
}
- // Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts:
- // the upper bound can never fall below the 238px minimum, so a narrow
- // window yields a width >= MIN instead of a sub-minimum sliver.
- var defaultSidebarWidth = 238;
+ // Sidebar width. Mirror getMaxSidebarWidth() in stores/sidebar/store.ts:
+ // 30% of the viewport capped at 400px, and never below the 256px
+ // minimum, so a narrow window yields a width >= MIN instead of a
+ // sub-minimum sliver.
+ var defaultSidebarWidth = 256;
try {
// Collapse comes from the cookie (independent of localStorage
// parsing); the persisted width is read defensively below. Match the
@@ -146,10 +149,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// collapsed, because the desktop hover-peek renders the sidebar at
// its restore width while --sidebar-width still reads collapsed.
var width = state && state.sidebarWidth;
- var maxSidebarWidth = Math.max(238, window.innerWidth * 0.3);
+ var maxSidebarWidth = Math.max(256, Math.min(400, window.innerWidth * 0.3));
var expandedWidth =
typeof width === 'number' && isFinite(width)
- ? Math.min(Math.max(width, 238), maxSidebarWidth)
+ ? Math.min(Math.max(width, 256), maxSidebarWidth)
: defaultSidebarWidth;
document.documentElement.style.setProperty(
'--sidebar-expanded-width',
diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts
index 23e600614a0..a0e5f077e0c 100644
--- a/apps/sim/app/manifest.ts
+++ b/apps/sim/app/manifest.ts
@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next'
+import { WORKSPACES_PATH } from '@/lib/navigation/paths'
import { getBrandConfig } from '@/ee/whitelabeling'
export const dynamic = 'force-dynamic'
@@ -43,7 +44,7 @@ export default function manifest(): MetadataRoute.Manifest {
name: 'Create Workflow',
short_name: 'New',
description: 'Create a new AI workflow',
- url: '/workspace',
+ url: WORKSPACES_PATH,
},
],
lang: 'en-US',
diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
new file mode 100644
index 00000000000..d05f2568244
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
+import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
+
+export const metadata: Metadata = { title: 'Chat' }
+
+export default async function OrganizationChatPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string; chatId: string }>
+}) {
+ const { organizationId, chatId } = await params
+ const session = await getSession()
+ if (!session?.user?.id) notFound()
+ const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ if (!context) notFound()
+ if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
+ const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
+ principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
+ })
+ if (!chat || chat.type !== 'mothership' || chat.organizationId !== organizationId) notFound()
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
new file mode 100644
index 00000000000..6a4bb7647f1
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
@@ -0,0 +1,27 @@
+import { ChipLink } from '@sim/emcn'
+import { CircleAlert } from '@sim/emcn/icons'
+import { WORKSPACES_PATH } from '@/lib/navigation/paths'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
+
+export function OrganizationAccessDenied() {
+ return (
+
+
+
+
+
+
+
+
Organization access denied
+
+ You are not a member of this organization. Ask an organization admin to add you, or head
+ back to your workspaces.
+
+
+
+ View your workspaces
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
new file mode 100644
index 00000000000..ffdb2e714bd
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
@@ -0,0 +1,6 @@
+export {
+ OrganizationPage,
+ type OrganizationPageTab,
+ PAGE_COLUMN_CLASS,
+} from '@/app/o/[organizationId]/components/organization-page/organization-page'
+export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
new file mode 100644
index 00000000000..f3f43caf421
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
@@ -0,0 +1,174 @@
+'use client'
+
+import { type ReactNode, useRef, useState } from 'react'
+import {
+ Button,
+ Chip,
+ ChipInput,
+ cn,
+ scrollFadeAttributes,
+ scrollFadeClass,
+ scrollFadeXClass,
+ useScrollEdges,
+} from '@sim/emcn'
+import { Search, X } from '@sim/emcn/icons'
+import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
+import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+
+/** The home surface's reading column, so every organization page shares its width. */
+export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
+
+export interface OrganizationPageTab {
+ id: string
+ label: string
+}
+
+interface OrganizationPageProps {
+ title: string
+ description?: string
+ /** Header tabs; the first is the default. Omit for a page with one view. */
+ tabs?: readonly OrganizationPageTab[]
+ /** The page's primary action, a chip. Omit for a page without one. */
+ action?: ReactNode
+ children?: ReactNode
+}
+
+/**
+ * The shell every organization page renders into: the top bar the workspace pages
+ * wear, then a fixed page header — title, description, tabs, search, and the
+ * optional action — over a scroll region that fades at both edges the way the
+ * sidebar does. Pages supply their content and logic; nothing else.
+ *
+ * The shell paints at once and never waits on data: a page renders each piece —
+ * a tab, a list, a count — the moment it is known and nothing before, with no
+ * skeleton standing in for it. Pass `tabs` only once they are known; the row
+ * simply gains them.
+ */
+export function OrganizationPage({
+ title,
+ description,
+ tabs,
+ action,
+ children,
+}: OrganizationPageProps) {
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+ const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
+ const tabsRef = useRef(null)
+ const tabEdges = useScrollEdges(tabsRef, { axis: 'x' })
+
+ const { tab, search, setTab, setSearch } = useOrganizationPageFilters()
+ const defaultTab = tabs?.[0]?.id
+ const activeTab = tab ?? defaultTab
+
+ /**
+ * The field stays open while it holds text, across tab switches and reloads,
+ * since the text lives in the URL; this only remembers an empty field the
+ * viewer opened and has not dismissed.
+ */
+ const [searchOpened, setSearchOpened] = useState(false)
+ const searchOpen = searchOpened || search.length > 0
+
+ const closeSearch = () => {
+ setSearch('')
+ setSearchOpened(false)
+ }
+
+ return (
+
+ {/* Reserved even while empty so the page header sits where the workspace's does. */}
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+ {/* The row yields to the controls beside it and scrolls sideways under a fade
+ once it can no longer fit; the scrollbar itself never shows. */}
+
+ {tabs?.map((item) => {
+ const active = item.id === activeTab
+ return (
+ setTab(item.id === defaultTab ? null : item.id)}
+ className='min-w-[44px] shrink-0 text-center'
+ >
+ {item.label}
+
+ )
+ })}
+
+
+ {searchOpen ? (
+ setSearch(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape') closeSearch()
+ }}
+ endAdornment={
+
+
+
+ }
+ />
+ ) : (
+ setSearchOpened(true)} />
+ )}
+ {action}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts
new file mode 100644
index 00000000000..2be9ae79290
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts
@@ -0,0 +1,22 @@
+import { parseAsString } from 'nuqs/server'
+
+/**
+ * Co-located, typed URL query-param definitions for an organization page's
+ * header. Both are view state the page's content filters by, so a link carries
+ * them and a tab switch keeps them.
+ *
+ * - `tab` is the active header tab. Absent, the page shows its first tab, so the
+ * key only appears once the viewer leaves it.
+ * - `q` is the search field's text, written raw (consumers trim on read) and
+ * debounced on the way to the URL by `useDebouncedSearchSetter`.
+ */
+export const organizationPageParsers = {
+ tab: parseAsString,
+ q: parseAsString.withDefault(''),
+} as const
+
+/** Tabs and search are filter-like view changes, not navigation: replace, and clear at the default. */
+export const organizationPageUrlKeys = {
+ history: 'replace',
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts
new file mode 100644
index 00000000000..371ae5527af
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts
@@ -0,0 +1,21 @@
+import { useCallback } from 'react'
+import { useQueryStates } from 'nuqs'
+import {
+ organizationPageParsers,
+ organizationPageUrlKeys,
+} from '@/app/o/[organizationId]/components/organization-page/search-params'
+import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
+
+/**
+ * The header filters of the organization page the caller sits on. The shell
+ * drives them; a page's content reads `tab` and `search` to filter its list, so
+ * the same criteria apply whichever tab is showing.
+ */
+export function useOrganizationPageFilters() {
+ const [{ tab, q }, setFilters] = useQueryStates(organizationPageParsers, organizationPageUrlKeys)
+
+ const setTab = useCallback((next: string | null) => setFilters({ tab: next }), [setFilters])
+ const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ q: value }, options))
+
+ return { tab, search: q, setTab, setSearch }
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx
new file mode 100644
index 00000000000..c95da914201
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx
@@ -0,0 +1,121 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+
+const hoverState = vi.hoisted(() => ({ isOpen: false }))
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({
+ useHoverMenu: () => ({
+ isOpen: hoverState.isOpen,
+ open: vi.fn(),
+ close: vi.fn(),
+ setLocked: vi.fn(),
+ triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() },
+ contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() },
+ }),
+}))
+
+import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section'
+
+const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({
+ id: `chat-${index + 1}`,
+ name: `Chat ${index + 1}`,
+ href: `/o/org-1/chat/chat-${index + 1}`,
+}))
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ hoverState.isOpen = false
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+async function render(props: Partial[0]> = {}) {
+ await act(async () => {
+ root.render(
+ {}}
+ onMoreClick={() => {}}
+ {...props}
+ />
+ )
+ })
+}
+
+describe('ChatsSection', () => {
+ it('lists every chat with no paging control', async () => {
+ await render()
+
+ expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8)
+ expect(container.textContent).not.toContain('See more')
+ })
+
+ it('marks the chat on the current route active', async () => {
+ await render({ pathname: '/o/org-1/chat/chat-3' })
+
+ const current = container.querySelector('a[href="/o/org-1/chat/chat-3"]')
+ const other = container.querySelector('a[href="/o/org-1/chat/chat-4"]')
+ expect(current?.className).toContain('surface-active')
+ expect(other?.className).not.toContain('surface-active')
+ })
+
+ it('reports the row href when its options button is pressed', async () => {
+ const onMoreClick = vi.fn()
+ await render({ onMoreClick })
+
+ const button = container.querySelector(
+ 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]'
+ )
+ await act(async () => button?.click())
+
+ expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2')
+ })
+
+ it('shows the empty state when there are no chats', async () => {
+ await render({ chats: [] })
+ expect(container.textContent).toContain('No chats yet')
+ })
+
+ it('renders the flyout rows while collapsed', async () => {
+ hoverState.isOpen = true
+ await render({ isCollapsed: true })
+
+ expect(container.querySelector('[aria-label="Chats"]')).not.toBeNull()
+ /* Radix portals the flyout to the body. */
+ expect(document.body.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8)
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
new file mode 100644
index 00000000000..042bca94ad8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
@@ -0,0 +1,183 @@
+'use client'
+
+import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn'
+import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons'
+import Link from 'next/link'
+import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
+import {
+ CollapsedSidebarMenu,
+ SidebarSection,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import {
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_SECTION_GAP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
+
+/** Stands in for a chip row while the list loads, so it carries no margin either. */
+function ChatRowSkeleton() {
+ return (
+
+
+
+ )
+}
+
+interface ChatRowProps {
+ chat: OrganizationChat
+ isCurrentRoute: boolean
+ isMenuOpen: boolean
+ onContextMenu: (e: React.MouseEvent, href: string) => void
+ onMoreClick: (e: React.MouseEvent, href: string) => void
+}
+
+function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) {
+ /**
+ * The trailing slot fits one glyph, and the dot wins over the pin: it reports
+ * transient state (a run in progress, or an unread reply elsewhere), while pinning
+ * is persistent and already conveyed by the row sorting to the top of the list.
+ */
+ const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread))
+
+ return (
+ onContextMenu(e, chat.href)}
+ >
+
+
+ {showStatusDot && (
+
+ )}
+ {!showStatusDot && chat.isPinned && (
+
+ )}
+
{
+ e.preventDefault()
+ e.stopPropagation()
+ onMoreClick(e, chat.href)
+ }}
+ className={cn(
+ 'absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
+ isMenuOpen && 'opacity-100'
+ )}
+ >
+
+
+
+
+ )
+}
+
+interface ChatsSectionProps {
+ chats: OrganizationChat[]
+ isLoading: boolean
+ isCollapsed: boolean
+ pathname: string | null
+ /** Href of the row whose options menu is open, so it stays highlighted meanwhile. */
+ menuOpenHref: string | null
+ onContextMenu: (e: React.MouseEvent, href: string) => void
+ onMoreClick: (e: React.MouseEvent, href: string) => void
+}
+
+/**
+ * The organization's chats, the section beneath Workspaces, spaced from it by the
+ * section gap exactly as the workspace sidebar spaces its own sections. Expanded, a
+ * collapsible list of every chat — no paging, the scroll region carries the length;
+ * collapsed, a hover flyout off the rail glyph.
+ */
+export function ChatsSection({
+ chats,
+ isLoading,
+ isCollapsed,
+ pathname,
+ menuOpenHref,
+ onContextMenu,
+ onMoreClick,
+}: ChatsSectionProps) {
+ const hover = useHoverMenu()
+
+ return (
+
+ {isCollapsed ? (
+
+ }
+ hover={hover}
+ ariaLabel='Chats'
+ >
+ {isLoading ? (
+
+
+ Loading...
+
+ ) : chats.length === 0 ? (
+ No chats yet
+ ) : (
+ chats.map((chat) => {
+ const isCurrentRoute = pathname === chat.href
+ return (
+
+ onContextMenu(e, chat.href)}>
+
+
+
+ )
+ })
+ )}
+
+
+ ) : (
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {chats.length === 0 && (
+
+ No chats yet
+
+ )}
+ {chats.map((chat) => (
+
+ ))}
+ >
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
new file mode 100644
index 00000000000..a2ee28fffc9
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
@@ -0,0 +1 @@
+export { ChatsSection } from './chats-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
new file mode 100644
index 00000000000..525cf120d1d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
@@ -0,0 +1,5 @@
+export { ChatsSection } from './chats-section'
+export { OrganizationFooter } from './organization-footer'
+export { OrganizationHeader } from './organization-header'
+export { WorkspacesRailFlyout } from './workspaces-rail-flyout'
+export { WorkspacesSection } from './workspaces-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
new file mode 100644
index 00000000000..95078897371
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
@@ -0,0 +1 @@
+export { OrganizationFooter } from './organization-footer'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
new file mode 100644
index 00000000000..3abcc00a6f2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
@@ -0,0 +1,240 @@
+'use client'
+
+import type { DesktopUpdateState } from '@sim/desktop-bridge'
+import {
+ Chip,
+ chipContentLabelClass,
+ chipPrimaryFillTokens,
+ chipVariants,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuItemLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ OverflowText,
+ Skeleton,
+} from '@sim/emcn'
+import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { SlackIcon } from '@/components/icons'
+import { getDesktopUpdates } from '@/lib/desktop'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { getUserColor } from '@/lib/workspaces/colors'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import {
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_RAIL_CHIP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { useUserProfile } from '@/hooks/queries/user-profile'
+import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
+
+function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean {
+ return state.status === 'available' || state.status === 'downloading' || state.status === 'ready'
+}
+
+function desktopUpdateActionLabel(state: DesktopUpdateState): string {
+ if (state.status === 'downloading') {
+ return state.percent === undefined
+ ? 'Downloading update…'
+ : `Downloading update ${state.percent}%`
+ }
+ return state.status === 'ready' ? 'Restart to update' : 'Update'
+}
+
+/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */
+function DesktopUpdateIcon({ className }: { className?: string }) {
+ return (
+
+ {/* Download's default viewBox is asymmetric around its paths. Center the
+ artwork itself, not merely its SVG box, inside the avatar-sized circle. */}
+
+
+ )
+}
+
+interface OrganizationFooterProps {
+ /**
+ * True while the scroll region above still hides rows beyond its bottom edge —
+ * the same test the divider under the pinned nav applies at the top. The bar's
+ * top rule is drawn only then, so a list that fits meets the footer with no line.
+ */
+ showDivider: boolean
+ isCollapsed: boolean
+ showCollapsedTooltips: boolean
+ onOpenDocs: () => void
+ onJoinSlack: () => void
+}
+
+/**
+ * Pinned bottom bar of the organization sidebar: the viewer's avatar and name,
+ * which open their account settings, plus a help menu. Same two elements and the
+ * same layout as the workspace footer — expanded they share one row with help hard
+ * right, collapsed they stack as icon chips with help on top.
+ *
+ * Collapsed reverses the flex direction instead of reordering the DOM, which keeps
+ * both elements (and the help menu's trigger) alive across a toggle.
+ */
+export function OrganizationFooter({
+ showDivider,
+ isCollapsed,
+ showCollapsedTooltips,
+ onOpenDocs,
+ onJoinSlack,
+}: OrganizationFooterProps) {
+ const { organization } = useOrganizationContext()
+ const { data: profile } = useUserProfile()
+ const updateState = useDesktopUpdateState()
+
+ const name = profile ? profile.name?.trim() || profile.email : ''
+ const updateAvailable = hasAvailableDesktopUpdate(updateState)
+
+ const handleUpdateSelect = () => {
+ const updates = getDesktopUpdates()
+ if (updateState.status === 'ready') {
+ updates?.install()
+ } else if (updateState.status === 'available') {
+ updates?.check()
+ }
+ }
+
+ /**
+ * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a
+ * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`,
+ * which would blank the avatar exactly where it is the only thing left to see.
+ */
+ const avatar = !profile ? (
+
+ ) : profile.image ? (
+
+ ) : (
+
+ {name.charAt(0).toUpperCase()}
+
+ )
+
+ /**
+ * Expanded, the chip hugs its content (`max-w-full` so a long name truncates
+ * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and
+ * `min-w-0` lets the hidden label give up its box so the chip never overflows it.
+ * The name is the button's accessible name — no `aria-label`, which would
+ * override the visible text.
+ */
+ const profileMenu = (
+
+
+
+
+ {avatar}
+ {profile ? (
+
+ ) : (
+ /* Fixed width — the chip hugs its content, so a flexible bar would collapse to nothing. */
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ /**
+ * One node across both states; only `fullWidth` changes, so the same Radix menu
+ * survives the transition. `shrink-0` keeps the chip off the avatar while the rail
+ * is briefly narrower than the row — the aside's clip hides it until there is room.
+ */
+ const helpMenu = (
+
+
+
+
+
+
+ {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
+
+ {updateAvailable && (
+ <>
+
+
+ {desktopUpdateActionLabel(updateState)}
+
+
+ >
+ )}
+
+
+ Docs
+
+
+
+ Join Slack
+
+
+
+ )
+
+ return (
+
+ {/* Expanded, claims the row's free width so the help button lands hard right.
+ `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the
+ chip's 30px rather than a line box padded by the strut's half-leading. */}
+
{profileMenu}
+ {helpMenu}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
new file mode 100644
index 00000000000..7040e44e82d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
@@ -0,0 +1 @@
+export { OrganizationHeader } from './organization-header'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
new file mode 100644
index 00000000000..c007e5d395d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import {
+ Chip,
+ ChipChevronDown,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@sim/emcn'
+import { PanelLeft, Settings } from '@sim/emcn/icons'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import { getOrganizationSettingsHref } from '@/components/settings/navigation'
+import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
+import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface'
+import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+
+function getOrganizationInitial(name: string): string {
+ return (name.trim()[0] || 'O').toUpperCase()
+}
+
+interface OrganizationHeaderProps {
+ organization: OrganizationSurfaceOrganization
+ isCollapsed: boolean
+ /** Expands the rail; the collapsed header is itself the expand control. */
+ onExpandSidebar: () => void
+}
+
+/**
+ * The top-left organization chip. Expanded, it names the organization and opens
+ * its card — the mark at tile size, the name, how many people belong, and the way
+ * into its settings; collapsed, it becomes the rail's expand control, swapping
+ * the mark for a panel glyph on hover exactly as the workspace header does. The
+ * mark is the organization's uploaded logo or its initial on the neutral tile.
+ */
+export function OrganizationHeader({
+ organization,
+ isCollapsed,
+ onExpandSidebar,
+}: OrganizationHeaderProps) {
+ const initial = getOrganizationInitial(organization.name)
+
+ if (isCollapsed) {
+ return (
+
+ }
+ />
+
+ )
+ }
+
+ const { memberCount } = organization
+
+ return (
+
+
+
+ }
+ rightAdornment={ }
+ >
+ {organization.name}
+
+
+
+ {/* The item rows' `px-2` and the rail chips' icon-to-label gap, so the card sits on the menu's own grid. */}
+
+
+
+
+ {organization.name}
+
+
+ {memberCount} {memberCount === 1 ? 'member' : 'members'}
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts
new file mode 100644
index 00000000000..fe0024ab47c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts
@@ -0,0 +1 @@
+export { WorkspacesRailFlyout } from './workspaces-rail-flyout'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx
new file mode 100644
index 00000000000..b6729ecbf07
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const workspacesState = vi.hoisted(() => ({
+ workspaces: [] as { id: string; name: string }[],
+ isLoading: false,
+}))
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({
+ useOrganizationWorkspaces: () => workspacesState,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu',
+ () => ({
+ CollapsedResourceFlyout: ({
+ entries,
+ isLoading,
+ emptyLabel,
+ }: {
+ entries: { id: string; name: string; href: string }[]
+ isLoading: boolean
+ emptyLabel: string
+ }) =>
+ isLoading ? (
+ Loading...
+ ) : entries.length === 0 ? (
+ {emptyLabel}
+ ) : (
+ entries.map((entry) => (
+
+ {entry.name}
+
+ ))
+ ),
+ })
+)
+
+import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout'
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ workspacesState.workspaces = []
+ workspacesState.isLoading = false
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+async function render() {
+ await act(async () => {
+ root.render( )
+ })
+}
+
+describe('WorkspacesRailFlyout', () => {
+ it('lists every workspace as a link into it', async () => {
+ workspacesState.workspaces = [
+ { id: 'ws-1', name: 'Design' },
+ { id: 'ws-2', name: 'Ops' },
+ ]
+ await render()
+
+ const links = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href'))
+ expect(links).toEqual(['/workspace/ws-1', '/workspace/ws-2'])
+ expect(container.textContent).toContain('Design')
+ })
+
+ it('shows the empty label when the organization has no workspaces', async () => {
+ await render()
+ expect(container.textContent).toContain('No workspaces yet')
+ })
+
+ it('shows the loading row while the list resolves', async () => {
+ workspacesState.isLoading = true
+ await render()
+ expect(container.textContent).toContain('Loading...')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx
new file mode 100644
index 00000000000..b36e478cf14
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders'
+import { CollapsedResourceFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu'
+
+interface WorkspacesRailFlyoutProps {
+ organizationId: string
+}
+
+/**
+ * Rail flyout body for the Workspaces tab: a jump list of the organization's
+ * workspaces, one row each, the way the workspace sidebar's Tables and Files tabs
+ * list theirs. Mounts only while the rail menu is open, so the workspace query
+ * runs only when someone hovers the chip.
+ */
+export function WorkspacesRailFlyout({ organizationId }: WorkspacesRailFlyoutProps) {
+ const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId)
+
+ const entries: FlyoutEntry[] = workspaces.map((workspace) => ({
+ kind: 'item',
+ id: workspace.id,
+ name: workspace.name,
+ pinned: false,
+ href: `/workspace/${workspace.id}`,
+ }))
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts
new file mode 100644
index 00000000000..48d480cdd44
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts
@@ -0,0 +1 @@
+export { WorkspacesSection } from './workspaces-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx
new file mode 100644
index 00000000000..7505e56a100
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx
@@ -0,0 +1,139 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+ isOpen: false,
+ workspaces: [] as { id: string; name: string; logoUrl: null }[],
+ isLoading: false,
+}))
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({
+ useHoverMenu: () => ({
+ isOpen: state.isOpen,
+ open: vi.fn(),
+ close: vi.fn(),
+ setLocked: vi.fn(),
+ triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() },
+ contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() },
+ }),
+}))
+vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({
+ useOrganizationWorkspaces: () => ({ workspaces: state.workspaces, isLoading: state.isLoading }),
+}))
+
+import { WorkspacesSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section'
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ state.isOpen = false
+ state.isLoading = false
+ state.workspaces = Array.from({ length: 8 }, (_, index) => ({
+ id: `ws-${index + 1}`,
+ name: `Workspace ${index + 1}`,
+ logoUrl: null,
+ }))
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+async function render(props: Partial[0]> = {}) {
+ await act(async () => {
+ root.render(
+ {}}
+ {...props}
+ />
+ )
+ })
+}
+
+function rows() {
+ return container.querySelectorAll('a[href^="/workspace/"]')
+}
+
+function pager() {
+ return Array.from(container.querySelectorAll('button')).find((button) =>
+ /See (more|less)/.test(button.textContent ?? '')
+ )
+}
+
+describe('WorkspacesSection', () => {
+ it('shows the first page and pages the rest in like the sidebar chats', async () => {
+ await render()
+ expect(rows()).toHaveLength(5)
+ expect(pager()?.textContent).toBe('See more')
+
+ await act(async () => pager()?.click())
+ expect(rows()).toHaveLength(8)
+ expect(pager()?.textContent).toBe('See less')
+
+ await act(async () => pager()?.click())
+ expect(rows()).toHaveLength(5)
+ })
+
+ it('offers no pager when the list fits the first page', async () => {
+ state.workspaces = state.workspaces.slice(0, 3)
+ await render()
+ expect(rows()).toHaveLength(3)
+ expect(pager()).toBeUndefined()
+ })
+
+ it('marks the workspace on the current route active', async () => {
+ await render({ pathname: '/workspace/ws-2' })
+ expect(container.querySelector('a[href="/workspace/ws-2"]')?.className).toContain(
+ 'surface-active'
+ )
+ expect(container.querySelector('a[href="/workspace/ws-1"]')?.className).not.toContain(
+ 'surface-active'
+ )
+ })
+
+ it('shows the empty state only once the list has resolved', async () => {
+ state.workspaces = []
+ state.isLoading = true
+ await render()
+ expect(container.textContent).not.toContain('No workspaces yet')
+
+ state.isLoading = false
+ await render()
+ expect(container.textContent).toContain('No workspaces yet')
+ })
+
+ it('renders the flyout while collapsed', async () => {
+ state.isOpen = true
+ await render({ isCollapsed: true })
+ expect(container.querySelector('[aria-label="Workspaces"]')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx
new file mode 100644
index 00000000000..5e64b48fa75
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx
@@ -0,0 +1,98 @@
+'use client'
+
+import { useState } from 'react'
+import { chipVariants, cn, OverflowText } from '@sim/emcn'
+import { Workspaces } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import { getWorkspaceInitial } from '@/lib/workspaces/initials'
+import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout'
+import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import {
+ CollapsedSidebarMenu,
+ SidebarSection,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
+
+/** Rows shown at first, and added per "See more" — the workspace sidebar's Chats paging. */
+const PAGE_SIZE = 5
+
+interface WorkspacesSectionProps {
+ organizationId: string
+ isCollapsed: boolean
+ pathname: string | null
+ onContextMenu: (e: React.MouseEvent, href: string) => void
+}
+
+/**
+ * The organization's workspaces the viewer belongs to: the first section of the
+ * scroll region, so it carries no section gap — the divider padding above it is
+ * the whole distance, exactly as the workspace sidebar spaces its own Chats.
+ * Expanded, five rail chips and a muted "See more" that pages the rest in, the way
+ * the workspace sidebar pages its Chats; collapsed, a hover flyout off the rail glyph.
+ */
+export function WorkspacesSection({
+ organizationId,
+ isCollapsed,
+ pathname,
+ onContextMenu,
+}: WorkspacesSectionProps) {
+ const hover = useHoverMenu()
+ const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId)
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
+ const hasMore = workspaces.length > visibleCount
+
+ return (
+
+ {isCollapsed ? (
+
+ }
+ hover={hover}
+ ariaLabel='Workspaces'
+ >
+
+
+
+ ) : (
+
+ {!isLoading && workspaces.length === 0 && (
+
+ No workspaces yet
+
+ )}
+ {workspaces.slice(0, visibleCount).map((workspace) => {
+ const href = `/workspace/${workspace.id}`
+ return (
+
onContextMenu(e, href)}
+ >
+
+
+
+ )
+ })}
+ {workspaces.length > PAGE_SIZE && (
+
setVisibleCount((count) => (hasMore ? count + PAGE_SIZE : PAGE_SIZE))}
+ className={cn(
+ chipVariants({ fullWidth: true }),
+ 'text-[var(--text-muted)] text-small'
+ )}
+ >
+ {hasMore ? 'See more' : 'See less'}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
new file mode 100644
index 00000000000..c96914ad41e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
@@ -0,0 +1,4 @@
+export { useCollapsedTooltips } from './use-collapsed-tooltips'
+export type { OrganizationChat } from './use-organization-chats'
+export { useOrganizationChats } from './use-organization-chats'
+export { useOrganizationWorkspaces } from './use-organization-workspaces'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts
new file mode 100644
index 00000000000..b63dc15c591
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react'
+
+/** How long the rail takes to settle after collapsing before row tooltips arm. */
+const COLLAPSED_TOOLTIP_DELAY_MS = 200
+
+/**
+ * Whether collapsed-rail tooltips should render. Arming is delayed past the rail's
+ * width animation so a tooltip never flashes beside a label that is still fading
+ * out; disarming is immediate so the expanded rail never shows one.
+ */
+export function useCollapsedTooltips(isCollapsed: boolean): boolean {
+ const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
+
+ useEffect(() => {
+ if (isCollapsed) {
+ const timer = setTimeout(() => setShowCollapsedTooltips(true), COLLAPSED_TOOLTIP_DELAY_MS)
+ return () => clearTimeout(timer)
+ }
+ setShowCollapsedTooltips(false)
+ }, [isCollapsed])
+
+ return isCollapsed && showCollapsedTooltips
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
new file mode 100644
index 00000000000..0bff17ebe9e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
@@ -0,0 +1,26 @@
+import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats'
+
+export interface OrganizationChat {
+ id: string
+ name: string
+ href: string
+ /** A run is in progress. */
+ isActive?: boolean
+ /** Has a reply the viewer has not opened. */
+ isUnread?: boolean
+ isPinned?: boolean
+}
+
+/** Lists only the current member's private organization conversations. */
+export function useOrganizationChats(organizationId: string) {
+ const query = useOrganizationMothershipChats(organizationId)
+ const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({
+ id: chat.id,
+ name: chat.name,
+ href: `/o/${organizationId}/chat/${chat.id}`,
+ isActive: chat.isActive,
+ isUnread: chat.isUnread,
+ isPinned: chat.isPinned,
+ }))
+ return { chats, isLoading: query.isPending }
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
new file mode 100644
index 00000000000..6c54a696a36
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
@@ -0,0 +1,14 @@
+import { useWorkspacesQuery } from '@/hooks/queries/workspace'
+
+/**
+ * The organization's workspaces the viewer belongs to, for the sidebar's
+ * Workspaces section. Read from the viewer's workspace list — the same query the
+ * workspace switcher uses — narrowed to those the organization owns.
+ */
+export function useOrganizationWorkspaces(organizationId: string) {
+ const { data = [], isLoading } = useWorkspacesQuery()
+
+ const workspaces = data.filter((workspace) => workspace.organizationId === organizationId)
+
+ return { workspaces, isLoading }
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
new file mode 100644
index 00000000000..9963f275118
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
@@ -0,0 +1 @@
+export { OrganizationSidebar } from './organization-sidebar'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
new file mode 100644
index 00000000000..9b3b0a6a6c6
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
@@ -0,0 +1,33 @@
+import { Home, Integration, Search } from '@sim/emcn/icons'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+
+type OrganizationNavRoute = 'home' | 'search' | 'integrations'
+
+interface OrganizationNavEntry {
+ id: string
+ label: string
+ icon: SidebarNavItemData['icon']
+ route: OrganizationNavRoute
+}
+
+/**
+ * The pinned block at the top of the organization sidebar, in display order.
+ * Hrefs are resolved per organization by {@link buildOrganizationNavItems}.
+ */
+const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [
+ { id: 'home', label: 'Home', icon: Home, route: 'home' },
+ { id: 'search', label: 'Search', icon: Search, route: 'search' },
+ { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
+]
+
+export function buildOrganizationNavItems(
+ organizationId: string,
+ searchAvailable: boolean
+): SidebarNavItemData[] {
+ const routes = organizationRoutes(organizationId)
+ return ORGANIZATION_NAV_ENTRIES.filter(() => searchAvailable).map(({ route, ...entry }) => ({
+ ...entry,
+ href: routes[route],
+ }))
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
new file mode 100644
index 00000000000..8be529333e1
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
@@ -0,0 +1,340 @@
+'use client'
+
+import { type ComponentProps, memo, useCallback, useRef, useState } from 'react'
+import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
+import { PanelLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { usePathname } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import { isMacPlatform } from '@/lib/core/utils/platform'
+import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { captureEvent } from '@/lib/posthog/client'
+import {
+ ChatsSection,
+ OrganizationFooter,
+ OrganizationHeader,
+ WorkspacesSection,
+} from '@/app/o/[organizationId]/components/organization-sidebar/components'
+import {
+ useCollapsedTooltips,
+ useOrganizationChats,
+} from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar'
+import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
+import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
+import {
+ isNavItemActive,
+ NavItemContextMenu,
+ SidebarNavChip,
+ SidebarTooltip,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_SECTION_GAP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
+import { useContextMenu } from '@/hooks/use-context-menu'
+import { useSidebarStore } from '@/stores/sidebar/store'
+
+const logger = createLogger('OrganizationSidebar')
+
+/**
+ * Opts a control out of the desktop shell's window-drag region. The header row is
+ * draggable chrome, so anything clickable inside it has to say so or the click is
+ * swallowed by the drag handler.
+ */
+const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
+
+interface OrganizationChatsProps
+ extends Omit, 'chats' | 'isLoading'> {
+ organizationId: string
+}
+
+function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) {
+ const { chats, isLoading } = useOrganizationChats(organizationId)
+ return
+}
+
+/**
+ * The organization surface's rail: the same chrome as the workspace sidebar —
+ * header row, pinned nav block, a divided scroll region of sections, and the
+ * pinned footer — hosted by the same `WorkspaceChrome`, so collapse, resize, and
+ * the desktop hover-peek all behave identically. Collapse and peek state come from
+ * the chrome through {@link useSidebarChrome}.
+ */
+export const OrganizationSidebar = memo(function OrganizationSidebar() {
+ const { isCollapsed: railCollapsed, isPeeking } = useSidebarChrome()
+ /** The peek card always renders the expanded layout, whatever the rail's state. */
+ const isCollapsed = railCollapsed && !isPeeking
+
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+
+ const pathname = usePathname()
+ const posthog = usePostHog()
+ const { organization, searchAccess } = useOrganizationContext()
+ const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
+ const { handlePointerDown } = useSidebarResize()
+ const showCollapsedTooltips = useCollapsedTooltips(isCollapsed)
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
+
+ const isMac = isMacPlatform()
+ const navItems = buildOrganizationNavItems(organization.id, searchAccess.memberScoped)
+ const settingsPath = organizationRoutes(organization.id).settings
+ const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`)
+
+ /**
+ * One menu serves every href-bearing row (nav items, workspaces, chats): the
+ * actions — open in a new tab, copy the link — only need the destination.
+ */
+ const [menuHref, setMenuHref] = useState(null)
+ const {
+ isOpen: isHrefMenuOpen,
+ position: hrefMenuPosition,
+ menuRef: hrefMenuRef,
+ handleContextMenu: openHrefMenu,
+ closeMenu: closeHrefMenu,
+ } = useContextMenu()
+
+ const handleHrefContextMenu = useCallback(
+ (e: React.MouseEvent, href: string) => {
+ setMenuHref(href)
+ openHrefMenu(e)
+ },
+ [openHrefMenu]
+ )
+
+ /** Anchors the menu to the row's options button rather than the pointer. */
+ const handleChatMoreClick = useCallback(
+ (e: React.MouseEvent, href: string) => {
+ if (isHrefMenuOpen) {
+ closeHrefMenu()
+ return
+ }
+ const rect = e.currentTarget.getBoundingClientRect()
+ setMenuHref(href)
+ openHrefMenu({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ clientX: rect.right,
+ clientY: rect.top,
+ } as React.MouseEvent)
+ },
+ [isHrefMenuOpen, closeHrefMenu, openHrefMenu]
+ )
+
+ const handleHrefMenuClose = () => {
+ closeHrefMenu()
+ setMenuHref(null)
+ }
+
+ const handleOpenInNewTab = () => {
+ if (menuHref) window.open(menuHref, '_blank', 'noopener,noreferrer')
+ }
+
+ const handleCopyLink = async () => {
+ if (!menuHref) return
+ try {
+ await navigator.clipboard.writeText(`${window.location.origin}${menuHref}`)
+ } catch (error) {
+ logger.error('Failed to copy link to clipboard', { error })
+ }
+ }
+
+ const handleOpenDocs = () => {
+ window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
+ captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
+ }
+
+ const handleOpenSlackCommunity = () => {
+ window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer')
+ captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' })
+ }
+
+ const handleEdgeKeyDown = (e: React.KeyboardEvent) => {
+ if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault()
+ toggleCollapsed()
+ }
+ }
+
+ useRegisterGlobalCommands(() =>
+ createCommands([
+ {
+ id: 'toggle-sidebar',
+ handler: () => {
+ toggleCollapsed()
+ },
+ },
+ ])
+ )
+
+ return (
+
+
+
+ {/* The peek card already sits below the lane; reserving it again doubles the offset. */}
+ {!isPeeking && (
+
+ )}
+
+
+ {isSettings ? (
+
+ ) : (
+ <>
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
+
+ {navItems.map((item) => {
+ const active = isNavItemActive(item, pathname)
+ return (
+
+ handleHrefContextMenu(e, item.href as string)}
+ />
+
+ )
+ })}
+
+
+
+
+
+ {searchAccess.memberScoped && (
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+
+ {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that
+ out-specifies the `[data-peek]` rule, stranding the card at a stale width. */}
+ {!isPeeking && (
+
+ )}
+
+ )
+})
diff --git a/apps/sim/app/o/[organizationId]/error.tsx b/apps/sim/app/o/[organizationId]/error.tsx
new file mode 100644
index 00000000000..42dca68dcb2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/error.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import {
+ type ErrorBoundaryProps,
+ ErrorState,
+} from '@/app/workspace/[workspaceId]/components/error/error'
+
+export default function OrganizationError({ error, reset }: ErrorBoundaryProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
new file mode 100644
index 00000000000..e0992209015
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import { Button, cn } from '@sim/emcn'
+import { ArrowUp } from '@sim/emcn/icons'
+import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
+
+const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors'
+const SEND_BUTTON_ACTIVE =
+ 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
+const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
+
+interface ComposerProps {
+ value: string
+ /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */
+ isInitialView: boolean
+ isSending: boolean
+ onChange: (value: string) => void
+ onSubmit: () => void
+ onStop: () => void
+}
+
+/**
+ * The organization home composer: a question to the Assistant. Wears the
+ * workspace chat input's chrome — the framed field and the send control — and
+ * carries only the controls that are wired for the organization.
+ */
+export function Composer({
+ value,
+ isInitialView,
+ isSending,
+ onChange,
+ onSubmit,
+ onStop,
+}: ComposerProps) {
+ const canSubmit = value.trim().length > 0
+ const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
+ const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
+
+ return (
+
+
+
+
+
+ {isSending ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/index.ts b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
new file mode 100644
index 00000000000..c99ba66e037
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
@@ -0,0 +1 @@
+export { Composer } from './composer'
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
new file mode 100644
index 00000000000..46909b51c4f
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import { useState } from 'react'
+import { cn, Expandable, ExpandableContent } from '@sim/emcn'
+import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
+import Link from 'next/link'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { useApiKeys } from '@/hooks/queries/api-keys'
+import { useSearchSources } from '@/hooks/queries/kb/connectors'
+
+type StepId = 'connect-integration' | 'connect-sim-search'
+
+interface GetStartedStep {
+ id: StepId
+ label: string
+}
+
+/** The onboarding steps, in the order a new organization works through them. */
+const STEPS: readonly GetStartedStep[] = [
+ { id: 'connect-integration', label: 'Connect an integration' },
+ { id: 'connect-sim-search', label: 'Connect Sim Search MCP' },
+]
+
+const ROW_CLASS =
+ 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]'
+
+/**
+ * A step's leading mark: an empty ring until the step is done, then the
+ * completion blue filled behind a check. The check is drawn here at the ring's
+ * own scale rather than with the 24-unit house icon — scaled to 10px, that
+ * stroke thins to a hair and its optical center drifts above the box. The svg
+ * fills the ring's 14px content box (16px less the 1px border on each side), so
+ * the path's center is the ring's center, and its stroke lands at ~1px — the
+ * weight the house icons render at 16px.
+ */
+function StepMark({ complete }: { complete: boolean }) {
+ return (
+
+ {complete && (
+
+
+
+ )}
+
+ )
+}
+
+/**
+ * The organization home's onboarding list under the composer. Same chrome as
+ * the workspace home's suggested actions: a hover-revealed disclosure header
+ * over hairline-separated rows. Each step leads to the page that completes it,
+ * and reads as done from the organization's real state: a source the viewer can
+ * search and a personal API key for the MCP server.
+ */
+export function GetStarted() {
+ const { organization, viewer } = useOrganizationContext()
+ const routes = organizationRoutes(organization.id)
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+ const { data: sources } = useSearchSources(scope)
+ const { data: apiKeys } = useApiKeys('', 'personal')
+
+ const hrefs: Record = {
+ 'connect-integration': viewer.isAdmin
+ ? routes.settingsSection('integrations')
+ : routes.integrations,
+ 'connect-sim-search': routes.settingsSection('search-mcp'),
+ }
+ const completed: Record = {
+ 'connect-integration':
+ sources?.some(
+ (source) => source.viewerMembership === 'connected' || !source.connectionRequired
+ ) ?? false,
+ 'connect-sim-search': (apiKeys?.personalKeys.length ?? 0) > 0,
+ }
+
+ const [expanded, setExpanded] = useState(true)
+ /**
+ * Collapsible animations are enabled only after the first user toggle, so
+ * the initially-open, server-rendered panel appears at full height on first
+ * paint instead of replaying the open animation and shifting the input
+ * above it.
+ */
+ const [animationsEnabled, setAnimationsEnabled] = useState(false)
+
+ const handleToggleExpanded = () => {
+ setAnimationsEnabled(true)
+ setExpanded((prev) => !prev)
+ }
+
+ return (
+
+ {/* Full width so the whole line toggles, not just the label and chevron. */}
+
+ Get started
+ {/*
+ * Revealed by hovering anywhere in the section — the group sits on the
+ * section wrapper rather than this row, so the rows below arm it just as
+ * the header does. Focus is keyed off the toggle instead, the only element
+ * here that can hold it, and matters because globals clear focus outlines.
+ * One transition covers the fade and the rotation so the two cannot drift
+ * apart. Mirrors the sidebar's section headers.
+ */}
+
+
+
+
+ {/* 6px, matching a sidebar section header to its first item — both headers
+ are an 18px box around 12px text, so equal padding reads as equal
+ distance. Padding an inner wrapper rather than the animated element:
+ `collapsible-up`/`-down` interpolate height alone, so a margin here
+ would hold its full value through the close and then vanish on unmount,
+ snapping the content below up. */}
+
+ {STEPS.map((step, i) => {
+ const complete = completed[step.id]
+ return (
+
0 && 'border-t')}
+ >
+
+
+ {step.label}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts b/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts
new file mode 100644
index 00000000000..105c611b0ac
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts
@@ -0,0 +1 @@
+export { GetStarted } from './get-started'
diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
new file mode 100644
index 00000000000..d8a7d799875
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
@@ -0,0 +1,155 @@
+/** @vitest-environment jsdom */
+import { act, type ComponentProps } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ context: vi.fn(),
+ chat: vi.fn(),
+ composer: vi.fn(),
+ renderer: vi.fn(),
+ markRead: vi.fn(),
+ send: vi.fn(),
+ consume: vi.fn(),
+ sources: vi.fn(),
+ apiKeys: vi.fn(),
+}))
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { id: 'reader' } } }),
+}))
+vi.mock('@/lib/core/utils/browser-storage', () => ({
+ MothershipHandoffStorage: { consume: mocks.consume },
+}))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: mocks.context,
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ useChat: mocks.chat }))
+vi.mock('@/hooks/queries/mothership-chats', () => ({
+ useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }),
+}))
+vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer }))
+vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchSources: mocks.sources }))
+vi.mock('@/hooks/queries/api-keys', () => ({ useApiKeys: mocks.apiKeys }))
+vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({
+ MothershipChat: mocks.renderer,
+}))
+
+import type { Composer } from '@/app/o/[organizationId]/home/components/composer'
+import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
+
+let root: Root
+let container: HTMLDivElement
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.context.mockReturnValue({
+ organization: { id: 'organization-a' },
+ searchAccess: { memberScoped: true },
+ viewer: { isAdmin: false },
+ })
+ mocks.sources.mockReturnValue({ data: [] })
+ mocks.apiKeys.mockReturnValue({ data: { personalKeys: [] } })
+ mocks.chat.mockReturnValue({ messages: [], isChatHistoryPending: true, sendMessage: mocks.send })
+ mocks.composer.mockReturnValue(Question composer
)
+ mocks.renderer.mockReturnValue(Chat history
)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+function composerProps(): ComponentProps {
+ return mocks.composer.mock.lastCall![0]
+}
+
+describe('organization home', () => {
+ it.each([undefined, 'chat-a'])(
+ 'does not mount Home or chat %s when Search is disabled',
+ async (chatId) => {
+ mocks.context.mockReturnValue({ searchAccess: { memberScoped: false } })
+ await act(async () => root.render( ))
+ expect(container.textContent).toBe('')
+ expect(mocks.composer).not.toHaveBeenCalled()
+ expect(mocks.chat).not.toHaveBeenCalled()
+ expect(mocks.consume).not.toHaveBeenCalled()
+ expect(mocks.renderer).not.toHaveBeenCalled()
+ }
+ )
+
+ it('greets the viewer over the composer and steps while the history query is pending', async () => {
+ await act(async () => root.render( ))
+ expect(container.textContent).toContain('What should we get done, Ada?')
+ expect(container.textContent).toContain('Question composer')
+ expect(container.textContent).toContain('Get started')
+ expect(mocks.renderer).not.toHaveBeenCalled()
+ expect(mocks.chat).toHaveBeenCalledWith({ organizationId: 'organization-a' }, undefined)
+ })
+ it('keeps history loading scoped to an actual routed chat', async () => {
+ await act(async () => root.render( ))
+ expect(mocks.renderer).toHaveBeenCalledWith(
+ expect.objectContaining({ isLoading: true }),
+ undefined
+ )
+ expect(container.textContent).not.toContain('Get started')
+ expect(mocks.consume).not.toHaveBeenCalled()
+ })
+ it.each([
+ { isAdmin: true, integrationHref: '/o/organization-a/settings/integrations' },
+ { isAdmin: false, integrationHref: '/o/organization-a/integrations' },
+ ])(
+ 'routes onboarding for admin=$isAdmin without a workspace creation requirement',
+ async ({ isAdmin, integrationHref }) => {
+ mocks.context.mockReturnValue({
+ organization: { id: 'organization-a' },
+ searchAccess: { memberScoped: true },
+ viewer: { isAdmin },
+ })
+ await act(async () => root.render( ))
+ expect(
+ Array.from(container.querySelectorAll('a')).map((link) => ({
+ label: link.textContent,
+ href: link.getAttribute('href'),
+ }))
+ ).toEqual([
+ { label: 'Connect an integration', href: integrationHref },
+ { label: 'Connect Sim Search MCP', href: '/o/organization-a/settings/search-mcp' },
+ ])
+ expect(container.textContent).not.toContain('Create a workspace')
+ expect(mocks.sources).toHaveBeenCalledWith({
+ kind: 'organization',
+ organizationId: 'organization-a',
+ })
+ }
+ )
+ it('sends the member question as an assistant turn and clears the draft', async () => {
+ await act(async () => root.render( ))
+ await act(async () => composerProps().onChange('Find our launch plan'))
+ await act(async () => composerProps().onSubmit())
+ expect(mocks.send).toHaveBeenCalledExactlyOnceWith(
+ 'Find our launch plan',
+ undefined,
+ undefined,
+ { requestMode: 'assistant' }
+ )
+ expect(composerProps().value).toBe('')
+ })
+ it('ignores a blank submission', async () => {
+ await act(async () => root.render( ))
+ await act(async () => composerProps().onChange(' '))
+ await act(async () => composerProps().onSubmit())
+ expect(mocks.send).not.toHaveBeenCalled()
+ })
+ it('resumes a scoped handoff with the original search filters', async () => {
+ const assistantSearch = { documentIds: ['document-a'] }
+ mocks.consume.mockReturnValueOnce({ message: 'Summarize', assistantSearch })
+ await act(async () => root.render( ))
+ expect(mocks.consume).toHaveBeenCalledWith({ organizationId: 'organization-a' })
+ expect(mocks.send).toHaveBeenCalledWith('Summarize', undefined, undefined, {
+ requestMode: 'assistant',
+ assistantSearch,
+ })
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx
new file mode 100644
index 00000000000..aaee012dc96
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useSession } from '@/lib/auth/auth-client'
+import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
+import { Composer } from '@/app/o/[organizationId]/home/components/composer'
+import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
+import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
+import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
+
+interface OrganizationHomeProps {
+ userName?: string
+ chatId?: string
+}
+
+/** Search and private Assistant chats for the routed organization. */
+export function OrganizationHome(props: OrganizationHomeProps) {
+ const { searchAccess } = useOrganizationContext()
+ if (!searchAccess.memberScoped) return null
+ return
+}
+
+function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
+ const { organization } = useOrganizationContext()
+ const { data: session } = useSession()
+ const [draft, setDraft] = useState('')
+ const chat = useChat({ organizationId: organization.id }, chatId)
+ const { sendMessage } = chat
+ const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id })
+ const firstName = userName?.split(' ')[0] ?? ''
+
+ useEffect(() => {
+ if (chat.resolvedChatId && !chat.isSending && !chat.isReconnecting)
+ markRead(chat.resolvedChatId)
+ }, [chat.resolvedChatId, chat.isSending, chat.isReconnecting, markRead])
+
+ useEffect(() => {
+ if (chatId) return
+ const handoff = MothershipHandoffStorage.consume({ organizationId: organization.id })
+ if (handoff?.message) {
+ void sendMessage(handoff.message, undefined, undefined, {
+ requestMode: 'assistant',
+ ...(handoff.resumeUserMessageId
+ ? { resumeUserMessageId: handoff.resumeUserMessageId }
+ : {}),
+ ...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
+ })
+ }
+ }, [chatId, organization.id, sendMessage])
+
+ const send = (message: string) => {
+ void sendMessage(message, undefined, undefined, { requestMode: 'assistant' })
+ }
+
+ const submit = () => {
+ const message = draft.trim()
+ if (!message) return
+ setDraft('')
+ send(message)
+ }
+
+ const hasChat = Boolean(chatId || chat.messages.length)
+ const composer = (
+ {
+ void chat.stopGeneration()
+ }}
+ />
+ )
+
+ return (
+
+ {hasChat ? (
+
{
+ void chat.stopGeneration()
+ }}
+ messageQueue={chat.messageQueue}
+ editingQueuedId={chat.editingQueuedId}
+ dispatchingHeadId={chat.dispatchingHeadId}
+ onRemoveQueuedMessage={chat.removeFromQueue}
+ onSendQueuedMessage={chat.sendNow}
+ onEditQueuedMessage={(id) => {
+ const queued = chat.editQueuedMessage(id)
+ if (queued) setDraft(queued.content)
+ return queued
+ }}
+ onCancelQueueEdit={chat.cancelQueueEdit}
+ userId={session?.user?.id}
+ chatId={chat.resolvedChatId}
+ composer={composer}
+ />
+ ) : (
+
+ {/* Asymmetric padding biases the group up so the full cluster (heading + input + steps) sits at the optical center */}
+
+
+ What should we get done{firstName ? `, ${firstName}` : ''}?
+
+
+ {composer}
+ {/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx
new file mode 100644
index 00000000000..dcb449256f8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx
@@ -0,0 +1,97 @@
+/** @vitest-environment node */
+import { authMockFns } from '@sim/testing'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ context: vi.fn(), chat: vi.fn() }))
+
+vi.mock('next/navigation', () => ({
+ redirect: (path: string) => {
+ throw new Error(`redirect:${path}`)
+ },
+ notFound: () => {
+ throw new Error('not-found')
+ },
+}))
+vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context }))
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mocks.chat }))
+vi.mock('@/app/o/[organizationId]/search/search', () => ({
+ OrganizationSearch: () => Organization Search
,
+}))
+vi.mock('@/app/o/[organizationId]/integrations/integrations', () => ({
+ OrganizationIntegrations: () => Integrations
,
+}))
+vi.mock('@/app/o/[organizationId]/home/organization-home', () => ({
+ OrganizationHome: () => Organization Assistant
,
+}))
+
+import OrganizationChatPage from '@/app/o/[organizationId]/chat/[chatId]/page'
+import OrganizationHomePage from '@/app/o/[organizationId]/home/page'
+import OrganizationIntegrationsPage from '@/app/o/[organizationId]/integrations/page'
+import OrganizationPage from '@/app/o/[organizationId]/page'
+import OrganizationSearchPage from '@/app/o/[organizationId]/search/page'
+
+const params = Promise.resolve({ organizationId: 'org-1', chatId: 'chat-1' })
+const session = { user: { id: 'viewer', name: 'Taylor' }, session: { id: 'session-1' } }
+
+describe('organization Search page gates', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue(session)
+ mocks.context.mockResolvedValue({ searchAccess: { memberScoped: true } })
+ mocks.chat.mockResolvedValue({ type: 'mothership', organizationId: 'org-1' })
+ })
+
+ it.each([
+ ['Home', () => OrganizationHomePage({ params })],
+ ['Search', () => OrganizationSearchPage({ params })],
+ ['chat', () => OrganizationChatPage({ params })],
+ ['organization entry', () => OrganizationPage({ params })],
+ ] as const)('redirects %s to workspace settings when Search is disabled', async (_name, open) => {
+ mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false, sourceMirrored: true } })
+ await expect(open()).rejects.toThrow('redirect:/workspace?redirect=settings')
+ expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer')
+ expect(mocks.chat).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['Home', () => OrganizationHomePage({ params })],
+ ['Search', () => OrganizationSearchPage({ params })],
+ ['chat', () => OrganizationChatPage({ params })],
+ ] as const)('denies %s to nonmembers before loading content', async (_name, open) => {
+ mocks.context.mockResolvedValue(null)
+ await expect(open()).rejects.toThrow('not-found')
+ expect(mocks.chat).not.toHaveBeenCalled()
+ })
+
+ it('hides Integrations when Search is disabled', async () => {
+ mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } })
+ await expect(OrganizationIntegrationsPage({ params })).rejects.toThrow('not-found')
+ })
+
+ it('renders Home when the organization gate is enabled', async () => {
+ expect(renderToStaticMarkup(await OrganizationHomePage({ params }))).toContain(
+ 'Organization Assistant'
+ )
+ })
+
+ it('retains chat authorization and asserted organization checks when enabled', async () => {
+ expect(renderToStaticMarkup(await OrganizationChatPage({ params }))).toContain(
+ 'Organization Assistant'
+ )
+ expect(mocks.chat).toHaveBeenCalledWith('chat-1', 'viewer', {
+ principal: { kind: 'session', userId: 'viewer', sessionId: 'session-1' },
+ })
+ mocks.chat.mockResolvedValue({ type: 'mothership', organizationId: 'another-org' })
+ await expect(OrganizationChatPage({ params })).rejects.toThrow('not-found')
+ })
+
+ it('lands enabled organizations on Home', async () => {
+ await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/home')
+ })
+
+ it('propagates availability failures instead of rendering the Assistant', async () => {
+ mocks.context.mockRejectedValue(new Error('Availability unavailable'))
+ await expect(OrganizationHomePage({ params })).rejects.toThrow('Availability unavailable')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/home/page.tsx b/apps/sim/app/o/[organizationId]/home/page.tsx
new file mode 100644
index 00000000000..addddff1e9a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
+
+export const metadata: Metadata = {
+ title: 'Home',
+}
+
+export default async function OrganizationHomePage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user?.id) notFound()
+ const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ if (!context) notFound()
+ if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
+
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
new file mode 100644
index 00000000000..4369d9e8843
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
@@ -0,0 +1,222 @@
+/** @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 type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors'
+
+const mocks = vi.hoisted(() => ({
+ context: vi.fn(),
+ sources: vi.fn(),
+ integrations: vi.fn(),
+ filters: vi.fn(),
+ setSource: vi.fn(),
+ connect: vi.fn(),
+}))
+
+vi.mock('@/hooks/queries/search-integrations', () => ({
+ useSearchIntegrations: mocks.integrations,
+}))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ integrationAvailability: new Map(),
+ oauthServiceAvailability: new Map([['google-email', true]]),
+ isIntegrationAvailabilityReady: true,
+ }),
+}))
+vi.mock('nuqs', () => ({
+ useQueryState: () => [null, mocks.setSource],
+ parseAsString: { withOptions: () => ({}) },
+ parseAsStringLiteral: () => ({ withOptions: () => ({}) }),
+}))
+vi.mock('@/app/o/[organizationId]/components/organization-page', () => ({
+ OrganizationPage: ({ action, children }: { action?: ReactNode; children?: ReactNode }) => (
+ <>
+ {action}
+ {children}
+ >
+ ),
+}))
+vi.mock(
+ '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters',
+ () => ({
+ useOrganizationPageFilters: mocks.filters,
+ })
+)
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: mocks.context,
+}))
+vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({
+ IntegrationTile: () => null,
+}))
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ useSearchSources: mocks.sources,
+ searchSourceKeys: { list: (scope: unknown) => ['sources', scope] },
+}))
+vi.mock('@/hooks/use-member-enrollment', () => ({
+ CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']),
+ useMemberEnrollment: () => ({
+ connect: mocks.connect,
+ connectSearchSource: mocks.connect,
+ isAwaiting: () => false,
+ isPending: false,
+ error: null,
+ }),
+}))
+vi.mock('@/hooks/use-oauth-return', () => ({
+ useDesktopOAuthConnectListener: () => undefined,
+ useOAuthReturnRouter: () => undefined,
+}))
+
+import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations'
+
+const scope = { kind: 'organization', organizationId: 'organization-a' } as const
+const memberSource: SearchSourceSummary = {
+ knowledgeBaseId: 'search-index',
+ connectorId: 'member-source',
+ connectorType: 'gmail',
+ sourceDescription: 'Gmail',
+ accessMode: 'members',
+ availability: 'available',
+ enabled: true,
+ isSyncing: false,
+ lastSyncAt: null,
+ hasSyncError: false,
+ viewerDocumentCount: 0,
+ viewerEmailVerified: true,
+ connectionRequired: true,
+ viewerMembership: 'not_enrolled',
+}
+const centralSource: SearchSourceSummary = {
+ ...memberSource,
+ connectorId: 'central-source',
+ connectorType: 'google_drive',
+ sourceDescription: 'Engineering',
+ accessMode: 'admin',
+ viewerDocumentCount: 4,
+ connectionRequired: false,
+ viewerMembership: null,
+}
+
+describe('organization integrations role and source paths', () => {
+ let root: Root
+ let container: HTMLDivElement
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.context.mockReturnValue({
+ organization: { id: scope.organizationId },
+ viewer: { isAdmin: false },
+ searchAccess: { memberScoped: true, sourceMirrored: true },
+ })
+ mocks.integrations.mockReturnValue({ data: [], isPending: false })
+ mocks.sources.mockReturnValue({ data: [memberSource, centralSource], isPending: false })
+ mocks.filters.mockReturnValue({ tab: null, search: '', setSearch: vi.fn() })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ })
+
+ async function render() {
+ await act(async () => root.render( ))
+ }
+
+ function buttons(label: string) {
+ return Array.from(document.querySelectorAll('button')).filter(
+ (button) => button.textContent?.trim() === label
+ )
+ }
+
+ it('uses the actual organization and only asks members to connect identity-dependent sources', async () => {
+ await render()
+ expect(mocks.sources).toHaveBeenCalledWith(scope)
+ expect(buttons('Add source')).toHaveLength(0)
+ expect(buttons('Manage')).toHaveLength(0)
+ expect(buttons('Connect account')).toHaveLength(1)
+ expect(document.body.textContent).toContain('4 searchable documents')
+ await act(async () => buttons('Connect account')[0].click())
+ expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source')
+ })
+
+ it('offers an approved integration before any source is configured', async () => {
+ mocks.sources.mockReturnValue({ data: [], isPending: false })
+ mocks.integrations.mockReturnValue({
+ data: [{ connectorType: 'gmail', approved: true }],
+ isPending: false,
+ })
+ await render()
+ expect(document.body.textContent).toContain('Approved')
+ expect(buttons('Connect account')).toHaveLength(1)
+ await act(async () => buttons('Connect account')[0].click())
+ expect(mocks.connect).toHaveBeenCalledWith(
+ scope,
+ expect.objectContaining({ type: 'gmail' }),
+ undefined
+ )
+ })
+ it('withholds connection when an integration is deactivated', async () => {
+ mocks.sources.mockReturnValue({
+ data: [{ ...memberSource, approved: false }],
+ isPending: false,
+ })
+ await render()
+ expect(buttons('Connect account')).toHaveLength(0)
+ expect(document.body.textContent).toContain('Deactivated by an organization admin')
+ })
+ it('asks an admin to configure Slack before members can connect an approved source', async () => {
+ mocks.sources.mockReturnValue({ data: [], isPending: false })
+ mocks.integrations.mockReturnValue({
+ data: [{ connectorType: 'slack', approved: true }],
+ isPending: false,
+ })
+ await render()
+ expect(buttons('Connect account')).toHaveLength(0)
+ expect(document.body.textContent).toContain('An admin needs to finish source setup')
+ })
+ it('shows an organization admin exactly what a member sees, with no setup or management', async () => {
+ mocks.context.mockReturnValue({
+ organization: { id: scope.organizationId },
+ viewer: { isAdmin: true },
+ searchAccess: { memberScoped: true, sourceMirrored: true },
+ })
+ await render()
+ expect(buttons('Add source')).toHaveLength(0)
+ expect(buttons('Manage')).toHaveLength(0)
+ expect(document.querySelector('[aria-label$="source actions"]')).toBeNull()
+ expect(buttons('Connect account')).toHaveLength(1)
+ })
+
+ it('lists only the sources the viewer connected under Mine', async () => {
+ mocks.filters.mockReturnValue({ tab: 'mine', search: '', setSearch: vi.fn() })
+ await render()
+ expect(document.body.textContent).toContain('You haven’t connected any sources yet.')
+ mocks.sources.mockReturnValue({
+ data: [{ ...memberSource, viewerMembership: 'connected' }, centralSource],
+ isPending: false,
+ })
+ await render()
+ expect(document.body.textContent).toContain('Gmail')
+ expect(document.body.textContent).not.toContain('Engineering')
+ })
+
+ it('does not offer connection to an unavailable source or setup to a member with no sources', async () => {
+ mocks.context.mockReturnValue({
+ organization: { id: scope.organizationId },
+ viewer: { isAdmin: false },
+ searchAccess: { memberScoped: false, sourceMirrored: false },
+ })
+ await render()
+ expect(buttons('Connect account')).toHaveLength(0)
+ expect(document.body.textContent).toContain('Not available in this organization')
+ mocks.sources.mockReturnValue({ data: [], isPending: false })
+ await render()
+ expect(document.body.textContent).toContain('Ask an organization admin to get started')
+ expect(buttons('Add source')).toHaveLength(0)
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx
new file mode 100644
index 00000000000..6f97d138d01
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx
@@ -0,0 +1,192 @@
+'use client'
+
+import { useMemo } from 'react'
+import { Chip } from '@sim/emcn'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import {
+ connectorDisplayName,
+ getConnectorAccessAvailability,
+ SEARCH_CONNECTORS,
+ SEARCH_SOURCE_TYPES,
+} from '@/lib/sim-search/connectors'
+import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
+import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import {
+ RESOURCE_LIST_STACK,
+ SettingsResourceRow,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { searchSourceKeys, useSearchSources } from '@/hooks/queries/kb/connectors'
+import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
+import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
+import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+/** Every source the organization searches, or only the ones the viewer has connected. */
+const TABS = [
+ { id: 'all', label: 'All' },
+ { id: 'mine', label: 'Mine' },
+] as const
+
+/**
+ * The organization's sources as every member sees them — the same list and the
+ * same actions whatever the viewer's role. Setting sources up and managing them
+ * is an organization admin's job, done in the organization's settings.
+ */
+export function OrganizationIntegrations() {
+ useOAuthReturnRouter()
+ useDesktopOAuthConnectListener()
+ const { organization, searchAccess } = useOrganizationContext()
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+ const sources = useSearchSources(scope)
+ const integrations = useSearchIntegrations(organization.id)
+ const availability = usePermissionConfig()
+ const { tab, search } = useOrganizationPageFilters()
+ const membershipQueryKeys = useMemo(
+ () => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })],
+ [organization.id]
+ )
+ const connectedConnectorIds = useMemo(
+ () =>
+ new Set(
+ sources.data
+ ?.filter((source) => source.viewerMembership === 'connected')
+ .map((source) => source.connectorId)
+ ),
+ [sources.data]
+ )
+ const enrollment = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds })
+ const query = search.trim().toLowerCase()
+ const mineOnly = tab === 'mine'
+ const visibleSources =
+ sources.data?.filter(
+ (source) =>
+ (!mineOnly || source.viewerMembership === 'connected') &&
+ `${connectorDisplayName(source.connectorType)} ${source.sourceDescription}`
+ .toLowerCase()
+ .includes(query)
+ ) ?? []
+
+ const approvedTypes = new Set(
+ integrations.data
+ ?.filter((integration) => integration.approved)
+ .map((integration) => integration.connectorType)
+ )
+ const configuredTypes = new Set(sources.data?.map((source) => source.connectorType))
+ const unconfigured = mineOnly
+ ? []
+ : SEARCH_SOURCE_TYPES.filter(
+ ([type, meta]) =>
+ approvedTypes.has(type) &&
+ !configuredTypes.has(type) &&
+ meta.name.toLowerCase().includes(query)
+ )
+ const failedQuery = sources.isError ? sources : integrations.isError ? integrations : null
+
+ return (
+
+
+ {failedQuery ? (
+
void failedQuery.refetch()}
+ variant='inline'
+ />
+ ) : visibleSources.length > 0 || unconfigured.length > 0 ? (
+ <>
+ {unconfigured.map(([type, meta]) => {
+ const connector = SEARCH_CONNECTORS.find((item) => item.type === type)
+ const access = getConnectorAccessAvailability(
+ meta,
+ availability.integrationAvailability,
+ {
+ memberAccessAvailable: searchAccess.memberScoped,
+ mirroredAccessAvailable: searchAccess.sourceMirrored,
+ oauthServiceAvailability: availability.oauthServiceAvailability,
+ isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady,
+ }
+ )
+ const canConnect = connector && type !== 'slack' && access.members
+ return (
+ }
+ title={meta.name}
+ description={
+ canConnect
+ ? 'Approved · Connect your account to search this source'
+ : 'Approved · An admin needs to finish source setup'
+ }
+ trailing={
+ canConnect ? (
+ enrollment.connectSearchSource(scope, connector, undefined)}
+ >
+ Connect account
+
+ ) : undefined
+ }
+ />
+ )
+ })}
+ {visibleSources.map((source) => (
+ enrollment.connect(source.knowledgeBaseId, source.connectorId)}
+ />
+ ))}
+ >
+ ) : sources.isPending || integrations.isPending ? null : (
+
+ {query
+ ? 'No matching sources.'
+ : mineOnly
+ ? 'You haven’t connected any sources yet.'
+ : 'Your organization hasn’t added any sources yet. Ask an organization admin to get started.'}
+
+ )}
+ {enrollment.error && (
+ {enrollment.error}
+ )}
+
+ {enrollment.setupConnector && (
+
+ enrollment.connectSource(scope, enrollment.setupConnector!.type, config)
+ }
+ />
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/page.tsx b/apps/sim/app/o/[organizationId]/integrations/page.tsx
new file mode 100644
index 00000000000..b3075409e06
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations'
+
+export const metadata: Metadata = {
+ title: 'Integrations',
+}
+
+export default async function OrganizationIntegrationsPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user)
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: organizationRoutes(organizationId).integrations,
+ isInviteFlow: false,
+ })
+ )
+ const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ if (!context?.searchAccess.memberScoped) notFound()
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx
new file mode 100644
index 00000000000..ebabd37f6fc
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx
@@ -0,0 +1,84 @@
+import { ChipLink } from '@sim/emcn'
+import { notFound, redirect } from 'next/navigation'
+import { readSearchDocumentResultSchema } from '@/lib/api/contracts/knowledge/documents'
+import { getSession } from '@/lib/auth'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { readSearchDocument } from '@/lib/knowledge/application/read-search-document'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
+
+interface OrganizationDocumentPageProps {
+ params: Promise<{ organizationId: string; knowledgeBaseId: string; documentId: string }>
+ searchParams: Promise<{ offset?: string }>
+}
+
+export default async function OrganizationDocumentPage({
+ params,
+ searchParams,
+}: OrganizationDocumentPageProps) {
+ const { organizationId, knowledgeBaseId, documentId } = await params
+ const { offset: rawOffset } = await searchParams
+ const offset = rawOffset === undefined ? 0 : Number(rawOffset)
+ if (!Number.isInteger(offset) || offset < 0 || offset > 5000) notFound()
+ const href = `/o/${encodeURIComponent(organizationId)}/knowledge/${encodeURIComponent(knowledgeBaseId)}/${encodeURIComponent(documentId)}`
+ const session = await getSession()
+ if (!session?.user) {
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: offset ? `${href}?offset=${offset}` : href,
+ isInviteFlow: false,
+ })
+ )
+ }
+ const registry = new ResolvedSecretTraceRegistry()
+ let result: Awaited>
+ try {
+ result = await readSearchDocument.execute({
+ principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
+ input: {
+ documentId,
+ assertedOrganizationId: organizationId,
+ offset,
+ limit: 20,
+ resultSecretRegistry: registry,
+ },
+ })
+ } catch (error) {
+ if (
+ error instanceof OrchestrationError &&
+ (error.code === 'not_found' || error.code === 'forbidden')
+ )
+ notFound()
+ throw error
+ }
+ if (result.knowledgeBaseId !== knowledgeBaseId) notFound()
+ const projected = projectResolvedSecretModelContent(result, registry, 1024 * 1024)
+ if (!projected.safe) return This document cannot be displayed safely.
+ const document = readSearchDocumentResultSchema.parse(projected.value)
+ return (
+
+
+
+ {document.documentName ?? 'Document'}
+
+ {document.chunks.map((chunk) => (
+
+ {chunk.content}
+
+ ))}
+
+ {offset > 0 && (
+ Previous
+ )}
+ {document.nextOffset !== null && (
+ Next
+ )}
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx
new file mode 100644
index 00000000000..f19d54169a7
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/layout.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * @vitest-environment node
+ */
+
+import type { ReactNode } from 'react'
+import { authMockFns } from '@sim/testing'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetOrganizationSurfaceContext, mockWorkspaceChrome, mockPrefetchUserProfile } =
+ vi.hoisted(() => ({
+ mockGetOrganizationSurfaceContext: vi.fn(),
+ mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children),
+ mockPrefetchUserProfile: vi.fn(async () => undefined),
+ }))
+
+vi.mock('@tanstack/react-query', () => ({
+ HydrationBoundary: ({ children }: { children: ReactNode }) => children,
+ dehydrate: () => ({}),
+}))
+
+vi.mock('@/app/_shell/providers/get-query-client', () => ({
+ getQueryClient: () => ({}),
+}))
+
+vi.mock('@/lib/users/prefetch-user-profile', () => ({
+ prefetchUserProfile: mockPrefetchUserProfile,
+}))
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(async () => ({ get: vi.fn(() => ({ value: '1' })) })),
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: vi.fn(),
+}))
+
+vi.mock('@/lib/organizations/surface', () => ({
+ getOrganizationSurfaceContext: mockGetOrganizationSurfaceContext,
+}))
+
+vi.mock('@/app/o/[organizationId]/components/organization-sidebar', () => ({
+ OrganizationSidebar: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({
+ WorkspaceChrome: mockWorkspaceChrome,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({
+ GlobalCommandsProvider: ({ children }: { children: ReactNode }) => children,
+}))
+
+import OrganizationLayout from '@/app/o/[organizationId]/layout'
+
+const mockGetSession = authMockFns.mockGetSession
+
+const SURFACE_CONTEXT = {
+ organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 },
+ viewer: { role: 'member', isAdmin: false },
+}
+
+describe('OrganizationLayout', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } })
+ })
+
+ it('renders the surface for a member and seeds the chrome from the collapse cookie', async () => {
+ mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT)
+
+ const element = await OrganizationLayout({
+ children: Organization child
,
+ params: Promise.resolve({ organizationId: 'org-1' }),
+ })
+ const html = renderToStaticMarkup(element)
+
+ expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1')
+ expect(mockPrefetchUserProfile).toHaveBeenCalledWith({}, 'viewer-1')
+ expect(html).toContain('Organization child')
+ expect(mockWorkspaceChrome).toHaveBeenCalledWith(
+ expect.objectContaining({ initialSidebarCollapsed: true }),
+ undefined
+ )
+ })
+
+ it('renders an explicit denial for a non-member without the surface', async () => {
+ mockGetOrganizationSurfaceContext.mockResolvedValue(null)
+
+ const element = await OrganizationLayout({
+ children: Secret organization child
,
+ params: Promise.resolve({ organizationId: 'org-denied' }),
+ })
+ const html = renderToStaticMarkup(element)
+
+ expect(html).toContain('Organization access denied')
+ expect(html).not.toContain('Secret organization child')
+ expect(mockWorkspaceChrome).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
new file mode 100644
index 00000000000..6b48f2feda3
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -0,0 +1,71 @@
+import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
+import { cookies } from 'next/headers'
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
+import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
+import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
+import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
+import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+
+/**
+ * The organization surface: the viewer's own view of one organization, outside
+ * any workspace. Membership in the routed organization is the whole gate — a
+ * non-member gets an explicit denial rather than a redirect, so a stale link
+ * never bounces someone into a different organization.
+ */
+export default async function OrganizationLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user) {
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: organizationRoutes(organizationId).home,
+ isInviteFlow: false,
+ })
+ )
+ }
+
+ const queryClient = getQueryClient()
+ const [context, cookieStore] = await Promise.all([
+ getOrganizationSurfaceContext(organizationId, session.user.id),
+ cookies(),
+ /* The rail's footer renders the viewer, so the profile is layout data: seeded
+ here it paints hydrated, and a page hydrating the same key beneath finds it
+ populated rather than an empty query it cannot fill during render. */
+ prefetchUserProfile(queryClient, session.user.id),
+ ])
+ if (!context) {
+ return
+ }
+
+ const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
+
+ return (
+
+
+
+
+ }
+ initialSidebarCollapsed={initialSidebarCollapsed}
+ >
+ {children}
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/not-found.tsx b/apps/sim/app/o/[organizationId]/not-found.tsx
new file mode 100644
index 00000000000..fc15b03c1cd
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/not-found.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import { Chip, ChipLink } from '@sim/emcn'
+import { ArrowLeft, Compass, Home } from '@sim/emcn/icons'
+import { useParams, useRouter } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { ErrorShell } from '@/app/workspace/[workspaceId]/components/error/error'
+
+export default function OrganizationNotFound() {
+ const router = useRouter()
+ const { organizationId } = useParams<{ organizationId?: string }>()
+ const homeHref = organizationId ? organizationRoutes(organizationId).home : '/o'
+
+ return (
+ }
+ >
+ router.back()}>
+ Go back
+
+
+ Return home
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx
new file mode 100644
index 00000000000..5ea08a88e4e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -0,0 +1,18 @@
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+
+export default async function OrganizationPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user?.id) notFound()
+ const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ if (!context) notFound()
+ const routes = organizationRoutes(organizationId)
+ redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH)
+}
diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
new file mode 100644
index 00000000000..849ee65ffba
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import { createContext, type ReactNode, useContext } from 'react'
+import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
+
+const OrganizationContextValue = createContext(null)
+
+interface OrganizationProviderProps {
+ children: ReactNode
+ context: OrganizationSurfaceContext
+}
+
+/**
+ * Provides the route-resolved organization and the viewer's standing in it to the
+ * organization surface. The layout resolves both on the server, so the first paint
+ * already knows the organization's name and logo.
+ */
+export function OrganizationProvider({ children, context }: OrganizationProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useOrganizationContext(): OrganizationSurfaceContext {
+ const context = useContext(OrganizationContextValue)
+ if (!context) {
+ throw new Error('useOrganizationContext must be used within OrganizationProvider')
+ }
+ return context
+}
+
+export function useOptionalOrganizationContext(): OrganizationSurfaceContext | null {
+ return useContext(OrganizationContextValue)
+}
diff --git a/apps/sim/app/o/[organizationId]/search/page.tsx b/apps/sim/app/o/[organizationId]/search/page.tsx
new file mode 100644
index 00000000000..74ae2f0234b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
+
+export const metadata: Metadata = { title: 'Search' }
+
+export default async function OrganizationSearchPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ const session = await getSession()
+ if (!session?.user?.id) notFound()
+ const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ if (!context) notFound()
+ if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/search/search-params.ts b/apps/sim/app/o/[organizationId]/search/search-params.ts
new file mode 100644
index 00000000000..57e73a7bc63
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/search-params.ts
@@ -0,0 +1,17 @@
+import { parseAsString } from 'nuqs/server'
+
+/**
+ * `q` is the Search page's query, so a search is a shareable, bookmarkable link.
+ * Written raw (consumers trim on read) and debounced on the way to the URL; the
+ * results' own source and recency filters live beside it, declared with the
+ * results component.
+ */
+export const organizationSearchParsers = {
+ q: parseAsString.withDefault(''),
+} as const
+
+/** A query is a filter-like view change, not navigation: replace, and clear when empty. */
+export const organizationSearchUrlKeys = {
+ history: 'replace',
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx
new file mode 100644
index 00000000000..ffe23679d40
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx
@@ -0,0 +1,171 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+
+const mocks = vi.hoisted(() => ({
+ search: vi.fn(),
+ urlUpdate: vi.fn(),
+ push: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }) }))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: () => ({
+ organization: { id: 'organization-a', name: 'Acme' },
+ searchAccess: { memberScoped: true },
+ }),
+}))
+vi.mock('@/hooks/queries/kb/knowledge', () => ({ useWorkspaceKnowledgeSearch: mocks.search }))
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ useSearchIndex: () => ({ data: { knowledgeBaseId: 'index-a' }, isPending: false }),
+ useSearchSources: () => ({ data: [] }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources', () => ({
+ isIndexing: () => false,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags',
+ () => ({
+ isHttpUrl: () => true,
+ })
+)
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
+ () => ({
+ SourceCard: ({ source }: { source: SourceTagData }) => (
+
+ {source.title}
+
+ ),
+ })
+)
+
+import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
+
+const scope: ResourceScope = { kind: 'organization', organizationId: 'organization-a' }
+let root: Root
+let container: HTMLDivElement
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.search.mockImplementation((_scope: ResourceScope, query: string) => {
+ const result: WorkspaceKnowledgeSearchResult = {
+ documentId: `document-${query}`,
+ knowledgeBaseId: 'index-a',
+ knowledgeBaseName: 'Organization Search',
+ documentName: `${query} launch plan`,
+ sourceUrl: `https://fixture.test/${encodeURIComponent(query)}`,
+ connectorType: null,
+ sourceModifiedAt: null,
+ author: null,
+ content: `${query} release milestones`,
+ chunkIndex: 0,
+ similarity: 1,
+ }
+ return { data: [result], isPending: false, isFetching: false, isError: false }
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+async function render(searchParams = '') {
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
+}
+
+function searchInput() {
+ const input = container.querySelector('input[aria-label="Search your sources"]')
+ if (!input) throw new Error('Missing Search input')
+ return input
+}
+
+async function editDraft(value: string) {
+ await act(async () => {
+ const input = searchInput()
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function expectVisibleQuery(query: string) {
+ expect(searchInput().value).toBe(query)
+ expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${query} launch plan`)
+ expect(mocks.search).toHaveBeenLastCalledWith(scope, query, {})
+ expect(document.activeElement).toBe(searchInput())
+}
+
+describe('organization Search query navigation', () => {
+ it('replaces the field draft and results when the committed URL query changes without remounting the page', async () => {
+ await render('?q=Orion')
+ expectVisibleQuery('Orion')
+ await editDraft('Unsubmitted draft')
+
+ await render('?q=Vega')
+ expectVisibleQuery('Vega')
+ expect(container.textContent).not.toContain('Orion launch plan')
+
+ await render('?q=Orion')
+ expectVisibleQuery('Orion')
+ expect(container.textContent).not.toContain('Vega launch plan')
+ expect(mocks.urlUpdate).not.toHaveBeenCalled()
+ })
+
+ it.each(['Enter', 'button'] as const)(
+ 'keeps the draft out of Search until %s commits it and restores input focus afterward',
+ async (submit) => {
+ await render('?q=Orion')
+ const callsBeforeEditing = mocks.search.mock.calls.length
+ await editDraft(' Vega ')
+ expect(searchInput().value).toBe(' Vega ')
+ expect(container.querySelector('a[data-source-link]')?.textContent).toBe('Orion launch plan')
+ expect(mocks.search).toHaveBeenCalledTimes(callsBeforeEditing)
+ expect(mocks.urlUpdate).not.toHaveBeenCalled()
+
+ await act(async () => {
+ if (submit === 'Enter') {
+ searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ } else {
+ const button = container.querySelector('button[aria-label="Search"]')!
+ button.focus()
+ button.click()
+ }
+ })
+ expectVisibleQuery('Vega')
+ await vi.waitFor(() =>
+ expect(mocks.urlUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ queryString: '?q=Vega' })
+ )
+ )
+ }
+ )
+
+ it('waits for the first submission before mounting results and keeps focus as the field docks', async () => {
+ await render()
+ expect(document.activeElement).toBe(searchInput())
+ await editDraft('Orion')
+ expect(mocks.search).not.toHaveBeenCalled()
+ expect(container.querySelector('a[data-source-link]')).toBeNull()
+ await act(async () =>
+ searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ )
+ expectVisibleQuery('Orion')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx
new file mode 100644
index 00000000000..581fcee0c2e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/search.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
+import { ArrowUp, Search } from '@sim/emcn/icons'
+import { useRouter } from 'next/navigation'
+import { useQueryStates } from 'nuqs'
+import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
+import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import {
+ organizationSearchParsers,
+ organizationSearchUrlKeys,
+} from '@/app/o/[organizationId]/search/search-params'
+import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+
+const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0 transition-colors'
+const SUBMIT_BUTTON_ACTIVE =
+ 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
+const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
+
+interface SearchFieldProps {
+ initialValue: string
+ onSubmit: (value: string) => void
+ /** Takes focus on mount so a query can be entered or refined immediately. */
+ focusOnMount?: boolean
+ /** Sitting at the page head over results, rather than floating in the hero. */
+ docked?: boolean
+}
+
+/**
+ * The query field: a single line in the pill the home composer's frame becomes,
+ * with the composer's send control at its end. A search runs on that control or
+ * on Enter, never as the viewer types. Like the composer, it carries the ambient
+ * shadow only while it floats in the hero; docked at the page head it sits flat.
+ */
+function SearchField({
+ initialValue,
+ onSubmit,
+ focusOnMount = false,
+ docked = false,
+}: SearchFieldProps) {
+ const inputRef = useRef(null)
+ const [value, setValue] = useState(initialValue)
+ const canSubmit = value.trim().length > 0
+
+ useEffect(() => {
+ if (focusOnMount) inputRef.current?.focus()
+ }, [focusOnMount])
+
+ return (
+
+
+
setValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
+ event.preventDefault()
+ onSubmit(value)
+ }
+ }}
+ placeholder='Search your sources'
+ aria-label='Search your sources'
+ autoComplete='off'
+ spellCheck={false}
+ className='h-full w-full bg-transparent font-body text-[14px] text-[var(--text-primary)] tracking-[-0.015em] outline-hidden placeholder:text-[var(--text-muted)] [&::-webkit-search-cancel-button]:hidden'
+ />
+
onSubmit(value)}
+ disabled={!canSubmit}
+ aria-label='Search'
+ className={cn(
+ SUBMIT_BUTTON_BASE,
+ canSubmit ? SUBMIT_BUTTON_ACTIVE : SUBMIT_BUTTON_DISABLED
+ )}
+ >
+
+
+
+ )
+}
+
+/**
+ * Sim Search over the organization's sources. Empty, it is the greeting over the
+ * query field, centered like Home; once a query is submitted the field docks at
+ * the top of the page — where every other organization page's title sits — and
+ * the results scroll beneath it under the sidebar's edge fade. The submitted
+ * query lives in the URL; the field holds the draft until the next submit.
+ * Summarizing a document hands the turn to the Assistant on Home.
+ */
+export function OrganizationSearch() {
+ const { searchAccess } = useOrganizationContext()
+ if (!searchAccess.memberScoped) return null
+ return
+}
+
+function OrganizationSearchContent() {
+ const { organization } = useOrganizationContext()
+ const router = useRouter()
+ const [{ q }, setParams] = useQueryStates(organizationSearchParsers, organizationSearchUrlKeys)
+ const query = q.trim()
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+ const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
+
+ const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => {
+ MothershipHandoffStorage.store(
+ { message, assistantSearch },
+ { organizationId: organization.id }
+ )
+ router.push(organizationRoutes(organization.id).home)
+ }
+
+ const submit = (draft: string) => {
+ const next = draft.trim()
+ if (!next) return
+ void setParams({ q: next })
+ }
+
+ const searching = query.length > 0
+
+ return (
+
+ {/* Reserved even while empty so the field docks where the page header sits. */}
+
+ {searching ? (
+ <>
+
+
+
+
+ {/* The rows carry their own `px-2`; this gutter brings each row's mark under the
+ field's own search glyph, so results read as a column hanging from the field. */}
+
+
+
+
+ >
+ ) : (
+
+ {/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */}
+
+
+ Search {organization.name}
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
new file mode 100644
index 00000000000..1874d5fc50f
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from 'react'
+import { notFound } from 'next/navigation'
+import {
+ getSettingsSectionMeta,
+ ORGANIZATION_SETTINGS_ITEMS,
+ toSettingsHeaderMeta,
+} from '@/components/settings/navigation'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
+
+interface OrganizationSettingsSectionLayoutProps {
+ children: ReactNode
+ params: Promise<{ section: string }>
+}
+
+export default async function OrganizationSettingsSectionLayout({
+ children,
+ params,
+}: OrganizationSettingsSectionLayoutProps) {
+ const { section } = await params
+ const resolved = resolveOrganizationSurfaceSection(section)
+ const meta =
+ resolved?.plane === 'organization'
+ ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)
+ : resolved
+ ? getSettingsSectionMeta('account', resolved.section)
+ : null
+ if (!meta) notFound()
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
new file mode 100644
index 00000000000..3ad60782e4f
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
@@ -0,0 +1,92 @@
+import { Suspense } from 'react'
+import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
+import {
+ getSettingsSectionMeta,
+ ORGANIZATION_SETTINGS_ITEMS,
+} from '@/components/settings/navigation'
+import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general'
+import { SettingsSectionProvider } from '@/components/settings/settings-panel'
+import { getSession } from '@/lib/auth'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings'
+import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
+
+interface OrganizationSettingsSectionPageProps {
+ params: Promise<{ organizationId: string; section: string }>
+}
+
+export async function generateMetadata({
+ params,
+}: OrganizationSettingsSectionPageProps): Promise {
+ const { section } = await params
+ const resolved = resolveOrganizationSurfaceSection(section)
+ const label =
+ resolved?.plane === 'organization'
+ ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)?.label
+ : resolved
+ ? getSettingsSectionMeta('account', resolved.section)?.label
+ : undefined
+ return { title: label ?? 'Settings' }
+}
+
+/**
+ * One settings surface for the organization view. An organization section runs
+ * through the organization gate; an account section needs only the sign-in the
+ * layout already established, and renders through the account plane's renderer.
+ */
+export default async function OrganizationSettingsSectionPage({
+ params,
+}: OrganizationSettingsSectionPageProps) {
+ const { organizationId, section } = await params
+ const routes = organizationRoutes(organizationId)
+ if (section === 'authorized-apps') {
+ redirect(`${routes.settingsSection('general')}?view=authorized-apps`)
+ }
+ const resolved = resolveOrganizationSurfaceSection(section)
+ if (!resolved) notFound()
+ const session = await getSession()
+ if (!session?.user) {
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: routes.settingsSection(resolved.section),
+ isInviteFlow: false,
+ })
+ )
+ }
+
+ if (resolved.plane === 'organization') {
+ if (
+ !(await authorizeOrganizationSettingsSection({
+ organizationId,
+ userId: session.user.id,
+ section: resolved.section,
+ }))
+ ) {
+ notFound()
+ }
+ return
+ }
+
+ /** Account sections read URL params via nuqs, so the renderer sits under a boundary; nothing stands in for it. */
+ const content = (
+
+
+
+
+
+ )
+
+ if (resolved.section === 'general') {
+ const queryClient = getQueryClient()
+ await prefetchStandaloneGeneral(queryClient)
+ return {content}
+ }
+
+ return content
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
new file mode 100644
index 00000000000..28c56368897
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import {
+ getOrganizationSettingsHref,
+ ORGANIZATION_SETTINGS_ITEMS,
+ type OrganizationSettingsSection,
+} from '@/components/settings/navigation'
+import { SettingsSectionProvider } from '@/components/settings/settings-panel'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
+import { OrganizationSearchMcp } from '@/app/o/[organizationId]/settings/components/organization-search-mcp'
+import { OrganizationConnectedAccounts } from '@/ee/credential-groups/components/organization-connected-accounts'
+
+const TeamManagement = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
+ (m) => m.TeamManagement
+ )
+)
+const Billing = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
+)
+const AccessControl = dynamic(() =>
+ import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
+)
+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 SessionPolicySettings = dynamic(() =>
+ import('@/ee/session-policy/components/session-policy-settings').then(
+ (m) => m.SessionPolicySettings
+ )
+)
+const DataRetentionSettings = dynamic(() =>
+ import('@/ee/data-retention/components/data-retention-settings').then(
+ (m) => m.DataRetentionSettings
+ )
+)
+const DataDrainsSettings = dynamic(() =>
+ import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
+)
+const UsageMonitoring = dynamic(() =>
+ import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
+)
+const WhitelabelingSettings = dynamic(() =>
+ import('@/ee/whitelabeling/components/whitelabeling-settings').then(
+ (m) => m.WhitelabelingSettings
+ )
+)
+
+interface OrganizationSettingsProps {
+ section: OrganizationSettingsSection
+}
+
+export function OrganizationSettings({ section }: OrganizationSettingsProps) {
+ const { organization, viewer } = useOrganizationContext()
+ const organizationId = organization.id
+ const meta = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === section)
+
+ return (
+
+ {section === 'integrations' && }
+ {section === 'connected-accounts' && (
+
+ )}
+ {section === 'search-mcp' && }
+ {section === 'members' && (
+
+ )}
+ {section === 'billing' && }
+ {section === 'access-control' && (
+
+ )}
+ {section === 'audit-logs' && }
+ {section === 'usage' && (
+
+ )}
+ {section === 'sso' && }
+ {section === 'sessions' && }
+ {section === 'data-retention' && }
+ {section === 'data-drains' && }
+ {section === 'whitelabeling' && }
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx
new file mode 100644
index 00000000000..6e08b448195
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx
@@ -0,0 +1,152 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { toast } from '@sim/emcn'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ context: vi.fn(),
+ accounts: vi.fn(),
+ people: vi.fn(),
+ invite: vi.fn(),
+ refetch: vi.fn(),
+}))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: mocks.context,
+}))
+vi.mock(
+ '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup',
+ () => ({ OrganizationIntegrationsSetup: () => Provider setup
})
+)
+vi.mock('@/hooks/queries/organization-accounts', () => ({
+ useOrganizationAccounts: mocks.accounts,
+ useOrganizationAccountPeople: mocks.people,
+ useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }),
+ useResendOrganizationAccountInvitation: () => ({}),
+ useRevokeOrganizationAccountEnrollment: () => ({}),
+}))
+
+import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
+
+describe('organization integration invitations', () => {
+ let root: Root
+ let container: HTMLDivElement
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(toast, 'success').mockReturnValue('toast-id')
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } })
+ mocks.accounts.mockReturnValue({
+ data: { credentialGroup: { id: 'group-a' } },
+ error: null,
+ refetch: mocks.refetch,
+ })
+ mocks.people.mockReturnValue({ data: { pages: [{ enrollments: [] }] } })
+ mocks.invite.mockResolvedValue({
+ sentCount: 2,
+ results: [
+ { email: 'one@example.com', success: true },
+ { email: 'two@example.com', success: true },
+ ],
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ async function render(searchParams = '') {
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
+ }
+
+ async function click(label: string) {
+ const button = Array.from(document.querySelectorAll('button')).find(
+ (element) => element.textContent === label
+ )
+ if (!button) throw new Error(`Missing ${label} button`)
+ await act(async () => button.click())
+ }
+
+ it('keeps provider setup as the default and sends manual invitations from People to this org', async () => {
+ await render()
+ expect(container.textContent).toContain('Provider setup')
+ expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
+ expect(mocks.people).not.toHaveBeenCalled()
+
+ await click('People')
+ expect(container.textContent).not.toContain('Provider setup')
+ expect(mocks.accounts).toHaveBeenLastCalledWith('org-a')
+ expect(mocks.people).toHaveBeenLastCalledWith('org-a')
+ expect(container.querySelector('[aria-label="Search people"]')).not.toBeNull()
+ expect(mocks.invite).not.toHaveBeenCalled()
+
+ await click('Request connections')
+ const input = document.querySelector('input[placeholder="Enter emails"]')
+ if (!input) throw new Error('Missing invitation email input')
+ const paste = new Event('paste', { bubbles: true, cancelable: true })
+ Object.defineProperty(paste, 'clipboardData', {
+ value: { getData: () => 'one@example.com two@example.com' },
+ })
+ await act(async () => input.dispatchEvent(paste))
+ await click('Send requests')
+ expect(mocks.invite).toHaveBeenCalledExactlyOnceWith({
+ organizationId: 'org-a',
+ emails: ['one@example.com', 'two@example.com'],
+ })
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ })
+
+ it('opens People directly from the saved URL', async () => {
+ await render('?tab=people')
+ expect(container.textContent).toContain('Request connections')
+ expect(container.textContent).not.toContain('Provider setup')
+ expect(mocks.people).toHaveBeenLastCalledWith('org-a')
+ })
+
+ it('sends an org without a credential group back to provider setup before invitations', async () => {
+ mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null })
+ await render('?tab=people')
+ expect(container.textContent).toContain('before inviting people')
+ expect(mocks.people).not.toHaveBeenCalled()
+ expect(container.textContent).not.toContain('Request connections')
+ await click('Set up providers')
+ expect(container.textContent).toContain('Provider setup')
+ expect(mocks.invite).not.toHaveBeenCalled()
+ })
+
+ it('surfaces account lookup errors instead of treating them as missing setup', async () => {
+ mocks.accounts.mockReturnValue({
+ error: new Error('Account access denied'),
+ refetch: mocks.refetch,
+ })
+ await render('?tab=people')
+ expect(container.textContent).toContain('Account access denied')
+ expect(container.textContent).not.toContain('Set up providers')
+ expect(mocks.people).not.toHaveBeenCalled()
+ await click('Try again')
+ expect(mocks.refetch).toHaveBeenCalledOnce()
+ })
+
+ it('does not load admin account data or expose invitations to an ordinary member', async () => {
+ mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: false } })
+ await render('?tab=people')
+ expect(container.textContent).toBe('')
+ expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
+ expect(mocks.people).not.toHaveBeenCalled()
+ expect(mocks.invite).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx
new file mode 100644
index 00000000000..ea16315bafe
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { Chip, ChipSwitch } from '@sim/emcn'
+import { useQueryState } from 'nuqs'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup'
+import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people'
+import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
+
+export function OrganizationIntegrationsSettings() {
+ const { organization, viewer } = useOrganizationContext()
+ const [tab, setTab] = useQueryState(
+ organizationIntegrationsTabParam.key,
+ organizationIntegrationsTabParam.parser
+ )
+ const accounts = useOrganizationAccounts(
+ viewer.isAdmin && tab === 'people' ? organization.id : undefined
+ )
+ if (!viewer.isAdmin) return null
+
+ return (
+
+
+ void setTab(value)}
+ options={[
+ { value: 'providers', label: 'Providers' },
+ { value: 'people', label: 'People' },
+ ]}
+ />
+
+ {tab === 'providers' &&
}
+ {tab === 'people' &&
+ (accounts.error ? (
+
void accounts.refetch()}
+ variant='inline'
+ />
+ ) : !accounts.data ? (
+ Loading connected accounts…
+ ) : !accounts.data.credentialGroup ? (
+
+
+ Set up a provider for personal account connections before inviting people.
+
+ void setTab('providers')}>Set up providers
+
+ ) : (
+
+ ))}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx
new file mode 100644
index 00000000000..18447f7bc37
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx
@@ -0,0 +1,252 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { Chip, ChipConfirmModal, ChipModalError, Switch } from '@sim/emcn'
+import { useQueryState } from 'nuqs'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
+import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
+import {
+ managedSourceParam,
+ searchSetupParam,
+} from '@/app/workspace/[workspaceId]/search/search-params'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import {
+ RESOURCE_LIST_STACK,
+ SettingsResourceRow,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { searchSourceKeys, useSearchSources } from '@/hooks/queries/kb/connectors'
+import {
+ useSearchIntegrations,
+ useUpdateSearchIntegration,
+} from '@/hooks/queries/search-integrations'
+import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+/** A change of approval the admin has asked for but not yet confirmed. */
+interface PendingApproval {
+ type: string
+ name: string
+ approve: boolean
+}
+
+/**
+ * Organization approval is independent of source setup. Each integration lists
+ * all of its configured sources using the same rows members see, with management
+ * actions for admins. Setup and OAuth returns stay within this settings section.
+ */
+export function OrganizationIntegrationsSetup() {
+ const { organization, viewer, searchAccess } = useOrganizationContext()
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+ const sources = useSearchSources(scope)
+ const integrations = useSearchIntegrations(organization.id)
+ const updateApproval = useUpdateSearchIntegration()
+ const {
+ integrationAvailability,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ isIntegrationAvailabilityFetching,
+ integrationAvailabilityError,
+ refetchIntegrationAvailability,
+ } = usePermissionConfig()
+ const [, setSelectedType] = useQueryState(
+ searchSetupParam.key,
+ searchSetupParam.parser.withOptions({ history: 'replace' })
+ )
+ const [, setManagedSource] = useQueryState(
+ managedSourceParam.key,
+ managedSourceParam.parser.withOptions({ history: 'replace' })
+ )
+ const membershipQueryKeys = useMemo(
+ () => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })],
+ [organization.id]
+ )
+ const connectedConnectorIds = useMemo(
+ () =>
+ new Set(
+ sources.data
+ ?.filter((source) => source.viewerMembership === 'connected')
+ .map((source) => source.connectorId)
+ ),
+ [sources.data]
+ )
+ const enrollment = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds })
+ const enabled = searchAccess.memberScoped || searchAccess.sourceMirrored
+ const [pendingApproval, setPendingApproval] = useState(null)
+ const approvals = new Map(
+ integrations.data?.map((integration) => [integration.connectorType, integration.approved])
+ )
+ const failedQuery = sources.isError ? sources : integrations.isError ? integrations : null
+
+ const confirmApproval = () => {
+ if (!pendingApproval) return
+ updateApproval.mutate(
+ {
+ organizationId: organization.id,
+ connectorType: pendingApproval.type,
+ approved: pendingApproval.approve,
+ },
+ {
+ onSuccess: () => setPendingApproval(null),
+ }
+ )
+ }
+
+ if (!enabled) {
+ return (
+
+ Search sources are not enabled for this organization.
+
+ )
+ }
+
+ return (
+ <>
+
+ {failedQuery ? (
+
void failedQuery.refetch()}
+ variant='inline'
+ />
+ ) : integrationAvailabilityError ? (
+ void refetchIntegrationAvailability()}
+ variant='inline'
+ />
+ ) : (
+ SEARCH_SOURCE_TYPES.map(([type, meta]) => {
+ const configured = sources.data?.filter((source) => source.connectorType === type) ?? []
+ const { admin: central, members } = getConnectorAccessAvailability(
+ meta,
+ integrationAvailability,
+ {
+ memberAccessAvailable: searchAccess.memberScoped,
+ mirroredAccessAvailable: searchAccess.sourceMirrored,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ }
+ )
+ const available = central || members
+ const approved = approvals.get(type) ?? false
+ const loading = sources.isPending || integrations.isPending
+ return (
+
+
}
+ title={meta.name}
+ description={
+ loading
+ ? 'Loading approval…'
+ : approved
+ ? available
+ ? 'Approved for Sim Search'
+ : 'Approved · Connection setup is unavailable'
+ : 'Not approved for Sim Search'
+ }
+ trailing={
+
+ {available && (
+ void setSelectedType(searchSetupParam.parser.parse(type))}
+ >
+ Set up
+
+ )}
+ {
+ updateApproval.reset()
+ setPendingApproval({ type, name: meta.name, approve })
+ }}
+ />
+
+ }
+ />
+ {configured.map((source) => (
+
enrollment.connect(source.knowledgeBaseId, source.connectorId)}
+ onManage={() => void setManagedSource(source.connectorId, { history: 'push' })}
+ />
+ ))}
+
+ )
+ })
+ )}
+ {enrollment.error && (
+ {enrollment.error}
+ )}
+
+
+
+ {
+ if (!open && !updateApproval.isPending) setPendingApproval(null)
+ }}
+ title={
+ pendingApproval?.approve
+ ? `Approve ${pendingApproval.name}?`
+ : `Deactivate ${pendingApproval?.name}?`
+ }
+ text={
+ pendingApproval?.approve
+ ? [
+ 'You are approving ',
+ { text: pendingApproval.name, bold: true },
+ ' for your organization in Sim Search. Members can connect their own accounts when supported. Sources that need a service account or custom app still require setup.',
+ ]
+ : [
+ 'Are you sure you want to deactivate ',
+ { text: pendingApproval?.name ?? '', bold: true },
+ ' for your organization? Its indexed content will be unavailable in Search, Assistant, and MCP until approved again. Source setup and connected accounts are preserved.',
+ ]
+ }
+ confirm={{
+ label: pendingApproval?.approve ? 'Approve' : 'Deactivate',
+ variant: pendingApproval?.approve ? 'primary' : 'destructive',
+ onClick: confirmApproval,
+ pending: updateApproval.isPending,
+ pendingLabel: 'Saving…',
+ }}
+ >
+ {updateApproval.error && {updateApproval.error.message} }
+
+ >
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts
new file mode 100644
index 00000000000..f0eda4192ce
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts
@@ -0,0 +1,11 @@
+import { parseAsStringLiteral } from 'nuqs/server'
+
+export const organizationIntegrationsTabParam = {
+ key: 'tab',
+ parser: parseAsStringLiteral(['providers', 'people']).withDefault('providers'),
+} as const
+
+export const connectedAccountsParam = {
+ key: 'connectedAccounts',
+ parser: parseAsStringLiteral(['slack']),
+} as const
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx
new file mode 100644
index 00000000000..1cc0c9b77b9
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx
@@ -0,0 +1,116 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ context: vi.fn(),
+ accounts: vi.fn(),
+ ensure: vi.fn(),
+ prepare: vi.fn(),
+ modal: vi.fn(),
+ setProvider: vi.fn(),
+ setReturnSource: vi.fn(),
+ setSelectedType: vi.fn(),
+}))
+vi.mock('nuqs', () => ({
+ useQueryState: (key: string) => {
+ if (key === 'connectedAccounts') return ['slack', mocks.setProvider]
+ if (key === 'search-setup') return ['slack', mocks.setReturnSource]
+ if (key === 'addConnector') return [null, mocks.setSelectedType]
+ throw new Error(`Unexpected query key: ${key}`)
+ },
+}))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: mocks.context,
+}))
+vi.mock('@/hooks/queries/organization-accounts', () => ({
+ useOrganizationAccounts: mocks.accounts,
+ useEnsureOrganizationAccounts: mocks.prepare,
+}))
+vi.mock('@/ee/credential-groups/components/slack-managed-users-modal', () => ({
+ SlackManagedUsersModal: mocks.modal,
+}))
+
+import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
+
+describe('organization Slack setup continuation', () => {
+ let root: Root
+ let container: HTMLDivElement
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } })
+ mocks.accounts.mockReturnValue({
+ isSuccess: true,
+ data: { credentialGroup: null },
+ error: null,
+ })
+ mocks.prepare.mockReturnValue({
+ mutate: mocks.ensure,
+ isIdle: true,
+ isPending: false,
+ error: null,
+ })
+ mocks.modal.mockReturnValue(null)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ })
+
+ async function render() {
+ await act(async () => root.render( ))
+ }
+
+ it('prepares a missing container without asking the admin to make an extra choice', async () => {
+ await render()
+ expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' })
+ expect(document.body.textContent).toContain('Loading Slack setup')
+ expect(document.body.textContent).not.toContain('Continue')
+ })
+
+ it('does not prepare accounts or open admin setup for an ordinary member', async () => {
+ mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: false } })
+ await render()
+ expect(mocks.ensure).not.toHaveBeenCalled()
+ expect(mocks.modal).not.toHaveBeenCalled()
+ expect(mocks.accounts).toHaveBeenCalledWith(undefined)
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ })
+
+ it('reuses the current container and resumes the original source after closing', async () => {
+ mocks.accounts.mockReturnValue({
+ isSuccess: true,
+ data: { credentialGroup: { id: 'group-a', options: [] } },
+ error: null,
+ })
+ await render()
+ expect(mocks.ensure).not.toHaveBeenCalled()
+ const props = mocks.modal.mock.calls[0][0]
+ expect(props).toMatchObject({ organizationId: 'org-a', credentialGroupId: 'group-a' })
+ expect(props).not.toHaveProperty('workspaceId')
+ expect(props.bots).toEqual([])
+ props.onOpenChange(false)
+ expect(mocks.setProvider).toHaveBeenCalledExactlyOnceWith(null)
+ expect(mocks.setReturnSource).toHaveBeenCalledExactlyOnceWith(null, { history: 'replace' })
+ expect(mocks.setSelectedType).toHaveBeenCalledExactlyOnceWith('slack', { history: 'replace' })
+ })
+
+ it('does not adopt a prepared container from another organization', async () => {
+ mocks.prepare.mockReturnValue({
+ mutate: mocks.ensure,
+ data: { credentialGroup: { id: 'foreign-group', organizationId: 'org-b', options: [] } },
+ isIdle: true,
+ })
+ await render()
+ expect(mocks.modal).not.toHaveBeenCalled()
+ expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' })
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx
new file mode 100644
index 00000000000..9184cb4b6ef
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { useEffect } from 'react'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+} from '@sim/emcn'
+import { useQueryState } from 'nuqs'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
+import {
+ searchSetupParam,
+ searchSetupReturnParam,
+} from '@/app/workspace/[workspaceId]/search/search-params'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
+import {
+ useEnsureOrganizationAccounts,
+ useOrganizationAccounts,
+} from '@/hooks/queries/organization-accounts'
+
+/** Slack's provider verification returns to the source form that started setup. */
+export function OrganizationSlackAccountSetup() {
+ const { organization, viewer } = useOrganizationContext()
+ const [provider, setProvider] = useQueryState(
+ connectedAccountsParam.key,
+ connectedAccountsParam.parser.withOptions({ history: 'replace' })
+ )
+ const [returnSource, setReturnSource] = useQueryState(
+ searchSetupReturnParam.key,
+ searchSetupReturnParam.parser
+ )
+ const [, setSelectedType] = useQueryState(searchSetupParam.key, searchSetupParam.parser)
+ const open = provider === 'slack' && viewer.isAdmin
+ const accounts = useOrganizationAccounts(open ? organization.id : undefined)
+ const {
+ mutate: ensureAccounts,
+ data: preparedAccounts,
+ error: setupError,
+ isIdle: setupIdle,
+ isPending: setupPending,
+ } = useEnsureOrganizationAccounts()
+ const prepared = preparedAccounts?.credentialGroup
+ const group =
+ accounts.data?.credentialGroup ??
+ (prepared?.organizationId === organization.id ? prepared : undefined)
+ const needsSetup = open && accounts.isSuccess && !group
+ useEffect(() => {
+ if (needsSetup && setupIdle) ensureAccounts({ organizationId: organization.id })
+ }, [ensureAccounts, needsSetup, setupIdle, organization.id])
+
+ const close = () => {
+ void setProvider(null)
+ void setReturnSource(null, { history: 'replace' })
+ if (returnSource)
+ void setSelectedType(returnSource === 'search' ? null : returnSource, { history: 'replace' })
+ }
+ if (!open) return null
+ if (group)
+ return (
+ option.provider === 'slack')?.slackBotCredentialId ??
+ undefined
+ }
+ initialRequiredScopes={
+ group.options.find((option) => option.provider === 'slack')?.requiredScopes
+ }
+ onOpenChange={(next) => {
+ if (!next) close()
+ }}
+ />
+ )
+ return (
+ {
+ if (!next) close()
+ }}
+ srTitle='Set up Slack'
+ >
+ Set up Slack
+
+
+ {accounts.error || setupError ? (
+
+ accounts.error
+ ? void accounts.refetch()
+ : ensureAccounts({ organizationId: organization.id })
+ }
+ variant='inline'
+ />
+ ) : (
+ Loading Slack setup…
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx
new file mode 100644
index 00000000000..66995c6f50d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { useState } from 'react'
+import { Chip, ChipCopyInput, Label } from '@sim/emcn'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
+
+/** A personal key keeps MCP queries subject to the same membership and document ACLs as Home. */
+export function OrganizationSearchMcp() {
+ const { organization, viewer } = useOrganizationContext()
+ const [createKeyOpen, setCreateKeyOpen] = useState(false)
+ const [apiKey, setApiKey] = useState(null)
+ const endpoint = `${getBaseUrl()}/api/mcp/search/organizations/${encodeURIComponent(organization.id)}`
+ return (
+
+
+
Server URL
+
+
Streamable HTTP
+
+
+
Authorization header
+
+
+ Your personal API key searches with your document access.
+
+
+ {!apiKey && (
+
+
setCreateKeyOpen(true)}
+ >
+ Generate API key
+
+ {!viewer.canUsePersonalApiKeys && (
+
+ Personal API keys are disabled by your organization.
+
+ )}
+
+ )}
+
setApiKey(key.key)}
+ />
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx
new file mode 100644
index 00000000000..52759b2ceb0
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
+
+interface OrganizationSettingsLayoutProps {
+ children: ReactNode
+}
+
+export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
+ useSettingsBeforeUnload()
+ return {children}
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
new file mode 100644
index 00000000000..1f347662b4a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
@@ -0,0 +1,150 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ ORGANIZATION_SETTINGS_GROUPS,
+ ORGANIZATION_SETTINGS_ITEMS,
+ type OrganizationSettingsFeatures,
+} from '@/components/settings/navigation'
+import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation'
+import {
+ organizationSettingsNavigation,
+ organizationSurfaceSettingsNavigation,
+ resolveOrganizationSettingsSection,
+ resolveOrganizationSurfaceSection,
+} from '@/app/o/[organizationId]/settings/navigation'
+
+const enterprise: OrganizationSettingsFeatures = {
+ billingEnabled: true,
+ hasEnterprisePlan: true,
+ hosted: true,
+ selfHosted: {},
+}
+
+const available = { connectedAccounts: true, search: true }
+
+describe('organization settings navigation', () => {
+ it('exposes MCP setup and the read-only roster to an ordinary organization member', () => {
+ expect(
+ organizationSettingsNavigation(false, enterprise, available).map(({ id }) => id)
+ ).toEqual(['members', 'search-mcp'])
+ })
+
+ it('uses Integrations instead of Connected accounts when Search is available', () => {
+ expect(organizationSettingsNavigation(true, enterprise, available)).toEqual(
+ ORGANIZATION_SETTINGS_ITEMS.filter(({ id }) => id !== 'connected-accounts')
+ )
+ })
+
+ it('keeps members and billing reachable without an enterprise plan', () => {
+ expect(
+ organizationSettingsNavigation(
+ true,
+ { ...enterprise, hasEnterprisePlan: false },
+ available
+ ).map(({ id }) => id)
+ ).toEqual(['billing', 'members', 'search-mcp'])
+ })
+
+ it('honors individual self-hosted feature flags and hides billing when disabled', () => {
+ expect(
+ organizationSettingsNavigation(
+ true,
+ {
+ ...enterprise,
+ hosted: false,
+ billingEnabled: false,
+ selfHosted: { sso: true },
+ },
+ available
+ ).map(({ id }) => id)
+ ).toEqual(['members', 'sso', 'integrations', 'search-mcp'])
+ })
+
+ it('normalizes old section names and does not expose unsupported routes', () => {
+ expect(resolveOrganizationSettingsSection('/o/one/settings/organization?query=person')).toBe(
+ 'members'
+ )
+ expect(resolveOrganizationSettingsSection('subscription')).toBe('billing')
+ expect(resolveOrganizationSettingsSection('domains')).toBe('sso')
+ expect(resolveOrganizationSettingsSection('skills')).toBeNull()
+ expect(buildOrganizationNavItems('org', true).map(({ id }) => id)).toEqual([
+ 'home',
+ 'search',
+ 'integrations',
+ ])
+ })
+
+ it('groups the sections as account, organization, governance, and Sim Search, in order', () => {
+ expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id, group }) => `${group}:${id}`)).toEqual([
+ 'account:billing',
+ 'organization:members',
+ 'organization:connected-accounts',
+ 'organization:usage',
+ 'organization:whitelabeling',
+ 'governance:audit-logs',
+ 'governance:access-control',
+ 'governance:sso',
+ 'governance:sessions',
+ 'governance:data-retention',
+ 'governance:data-drains',
+ 'sim-search:integrations',
+ 'sim-search:search-mcp',
+ ])
+ })
+
+ it('hosts the account General section ahead of the organization sections', () => {
+ expect(
+ organizationSurfaceSettingsNavigation(false, enterprise, available).map(({ id }) => id)
+ ).toEqual(['general', 'members', 'search-mcp'])
+ expect(ORGANIZATION_SETTINGS_GROUPS.map(({ key }) => key)).toEqual([
+ 'account',
+ 'organization',
+ 'governance',
+ 'sim-search',
+ ])
+ })
+
+ it('resolves a surface path to the plane that owns it, the organization winning billing', () => {
+ expect(resolveOrganizationSurfaceSection('/o/one/settings/general')).toEqual({
+ plane: 'account',
+ section: 'general',
+ })
+ expect(resolveOrganizationSurfaceSection('api-keys')).toBeNull()
+ expect(resolveOrganizationSurfaceSection('billing')).toEqual({
+ plane: 'organization',
+ section: 'billing',
+ })
+ expect(resolveOrganizationSurfaceSection('skills')).toBeNull()
+ })
+ it('hides gated sections while preserving ordinary organization navigation', () => {
+ const sections = organizationSurfaceSettingsNavigation(true, enterprise, {
+ connectedAccounts: false,
+ search: false,
+ }).map(({ id }) => id)
+ expect(sections).not.toContain('connected-accounts')
+ expect(sections).not.toContain('search-mcp')
+ expect(sections).not.toContain('integrations')
+ expect(sections).toContain('members')
+ expect(sections).toContain('general')
+ expect(buildOrganizationNavItems('org', false)).toEqual([])
+ })
+ it('exposes Connected accounts before Search is enabled', () => {
+ const sections = organizationSettingsNavigation(true, enterprise, {
+ connectedAccounts: true,
+ search: false,
+ }).map(({ id }) => id)
+ expect(sections).toContain('connected-accounts')
+ expect(sections).not.toContain('search-mcp')
+ expect(sections).not.toContain('integrations')
+ })
+ it('keeps both setup pages hidden from non-admins when Search is disabled', () => {
+ const sections = organizationSettingsNavigation(false, enterprise, {
+ connectedAccounts: true,
+ search: false,
+ }).map(({ id }) => id)
+ expect(sections).not.toContain('connected-accounts')
+ expect(sections).not.toContain('integrations')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts
new file mode 100644
index 00000000000..4b1e30b5ab0
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts
@@ -0,0 +1,95 @@
+import {
+ ACCOUNT_SETTINGS_ITEMS,
+ type AccountSettingsSection,
+ isOrganizationSettingsSectionAvailable,
+ ORGANIZATION_SETTINGS_GROUPS,
+ ORGANIZATION_SETTINGS_ITEMS,
+ type OrganizationSettingsFeatures,
+ type OrganizationSettingsSection,
+ parseSettingsPathSection,
+ resolveOrganizationSectionAccess,
+ type SettingsNavigationItem,
+} from '@/components/settings/navigation'
+
+/**
+ * A section on the organization surface's settings, tagged with the plane that
+ * renders it: the organization's own sections, or the viewer's account sections
+ * hosted beside them so one settings surface serves the whole organization view.
+ */
+export type OrganizationSurfaceSettingsSection =
+ | { plane: 'organization'; section: OrganizationSettingsSection }
+ | { plane: 'account'; section: AccountSettingsSection }
+
+/**
+ * The account sections the organization surface hosts, rendered by the account
+ * plane's own renderer. General is the one that belongs to the person alone: the
+ * personal Subscription gives way to the organization's, which sits beside
+ * General in the Account group, and the Desktop, Browser, and Terminal sections
+ * are bound to the workspace they are opened from.
+ */
+export const ORGANIZATION_SURFACE_ACCOUNT_ITEMS: SettingsNavigationItem[] =
+ ACCOUNT_SETTINGS_ITEMS.filter((item) => item.id === 'general')
+
+export function resolveOrganizationSettingsSection(
+ path: string
+): OrganizationSettingsSection | null {
+ return parseSettingsPathSection({
+ path,
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: { organization: 'members', team: 'members', subscription: 'billing', domains: 'sso' },
+ })
+}
+
+/**
+ * Resolves a settings path on the organization surface to the plane that owns
+ * it. Organization sections win, so `billing` is the organization's Subscription.
+ */
+export function resolveOrganizationSurfaceSection(
+ path: string
+): OrganizationSurfaceSettingsSection | null {
+ const organization = resolveOrganizationSettingsSection(path)
+ if (organization) return { plane: 'organization', section: organization }
+ const account = parseSettingsPathSection({
+ path,
+ items: ORGANIZATION_SURFACE_ACCOUNT_ITEMS,
+ defaultSection: null,
+ })
+ return account ? { plane: 'account', section: account } : null
+}
+
+export function organizationSettingsNavigation(
+ isAdmin: boolean,
+ features: OrganizationSettingsFeatures,
+ availability: { connectedAccounts: boolean; search: boolean }
+) {
+ return ORGANIZATION_SETTINGS_ITEMS.filter(
+ (item) =>
+ (item.id !== 'connected-accounts' ||
+ (availability.connectedAccounts && !availability.search)) &&
+ ((item.id !== 'search-mcp' && item.id !== 'integrations') || availability.search) &&
+ resolveOrganizationSectionAccess({
+ section: item.id,
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: isAdmin,
+ }) !== 'unavailable' &&
+ isOrganizationSettingsSectionAvailable(item.id, features)
+ )
+}
+
+/**
+ * Every section the organization surface's settings sidebar lists: the viewer's
+ * account sections, then the organization sections the viewer's role and the
+ * deployment allow. The sidebar groups them by {@link ORGANIZATION_SETTINGS_GROUPS},
+ * so General leads the Account group and the organization's Subscription follows it.
+ */
+export function organizationSurfaceSettingsNavigation(
+ isAdmin: boolean,
+ features: OrganizationSettingsFeatures,
+ availability: { connectedAccounts: boolean; search: boolean }
+): SettingsNavigationItem[] {
+ return [
+ ...ORGANIZATION_SURFACE_ACCOUNT_ITEMS,
+ ...organizationSettingsNavigation(isAdmin, features, availability),
+ ]
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
new file mode 100644
index 00000000000..f8efce6dccd
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+import {
+ getOrganizationSettingsFeatures,
+ ORGANIZATION_SETTINGS_GROUPS,
+} from '@/components/settings/navigation'
+import { SettingsSidebar } from '@/components/settings/settings-sidebar'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
+import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import {
+ organizationSurfaceSettingsNavigation,
+ resolveOrganizationSurfaceSection,
+} from '@/app/o/[organizationId]/settings/navigation'
+import { useOrganizationBilling } from '@/hooks/queries/organization'
+
+interface OrganizationSettingsSidebarProps {
+ isCollapsed: boolean
+ showCollapsedTooltips: boolean
+}
+
+export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarProps) {
+ const { organization, viewer, connectedAccountsAvailable, searchAccess } =
+ useOrganizationContext()
+ const pathname = usePathname()
+ const deployment = useDeploymentShape()
+ const { data: billing } = useOrganizationBilling(organization.id, {
+ enabled: viewer.isAdmin && deployment.hosted,
+ })
+ const features = getOrganizationSettingsFeatures(
+ isEnterprise(billing?.data?.subscriptionPlan),
+ deployment
+ )
+
+ const routes = organizationRoutes(organization.id)
+
+ return (
+ routes.settingsSection(section)}
+ backHref={searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH}
+ />
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/page.tsx b/apps/sim/app/o/[organizationId]/settings/page.tsx
new file mode 100644
index 00000000000..5000109f564
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+
+interface OrganizationSettingsPageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationSettingsPage({ params }: OrganizationSettingsPageProps) {
+ const { organizationId } = await params
+ redirect(organizationRoutes(organizationId).settingsSection('general'))
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx b/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx
new file mode 100644
index 00000000000..9458349e21f
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx
@@ -0,0 +1,44 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getOrganizationSettingsHref } from '@/components/settings/navigation'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { getSession } from '@/lib/auth'
+import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { UsageEventsView } from '@/ee/organization-usage/components/usage-events-view'
+
+export const metadata: Metadata = { title: 'Usage events' }
+
+interface OrganizationUsageEventsPageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationUsageEventsPage({
+ params,
+}: OrganizationUsageEventsPageProps) {
+ const { organizationId } = await params
+ const backHref = getOrganizationSettingsHref(organizationId, 'usage')
+ const session = await getSession()
+ if (!session?.user) {
+ redirect(
+ buildAuthCrossLink('/login', { callbackUrl: `${backHref}/events`, isInviteFlow: false })
+ )
+ }
+ if (
+ !(await authorizeOrganizationSettingsSection({
+ organizationId,
+ userId: session.user.id,
+ section: 'usage',
+ }))
+ ) {
+ notFound()
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/skills/page.tsx b/apps/sim/app/o/[organizationId]/skills/page.tsx
new file mode 100644
index 00000000000..7cbc12a5a32
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/skills/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from 'next'
+import { redirect } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+
+export const metadata: Metadata = {
+ title: 'Skills',
+}
+
+interface OrganizationSkillsPageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationSkillsPage({ params }: OrganizationSkillsPageProps) {
+ const { organizationId } = await params
+ redirect(organizationRoutes(organizationId).home)
+}
diff --git a/apps/sim/app/o/page.tsx b/apps/sim/app/o/page.tsx
new file mode 100644
index 00000000000..b0f5c8f2751
--- /dev/null
+++ b/apps/sim/app/o/page.tsx
@@ -0,0 +1,16 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
+
+/**
+ * Bare `/o` has no organization to show, so it resolves exactly like the app entry:
+ * the viewer's organization home, or their workspaces when they belong to none.
+ */
+export default async function OrganizationIndexPage() {
+ const session = await getSession()
+ if (!session?.user) {
+ redirect('/login')
+ }
+
+ redirect(await resolveAppEntryPath(session))
+}
diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
index 8b28bf1f473..bd72e37127b 100644
--- a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
@@ -128,7 +128,7 @@ describe('ChatCompleteHandoff', () => {
vi.advanceTimersByTime(400)
})
- expect(calls).toEqual(['/workspace'])
+ expect(calls).toEqual(['/home'])
act(() => root.unmount())
})
})
diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
index 258bf17d1e8..7965bf186d4 100644
--- a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
@@ -6,6 +6,7 @@ import {
OAUTH_CHAT_RETURN_TO_PARAM,
setOAuthChatAttemptStatus,
} from '@/lib/credentials/oauth-chat-attempt'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const CLOSE_FALLBACK_DELAY_MS = 400
@@ -57,7 +58,7 @@ export function ChatCompleteHandoff() {
window.close()
const timer = window.setTimeout(() => {
- window.location.replace(returnTo ?? '/workspace')
+ window.location.replace(returnTo ?? APP_ENTRY_PATH)
}, CLOSE_FALLBACK_DELAY_MS)
return () => window.clearTimeout(timer)
}, [])
diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx
index 72606f82511..289d49be15f 100644
--- a/apps/sim/app/oauth/credential-connected/page.tsx
+++ b/apps/sim/app/oauth/credential-connected/page.tsx
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { LogoShell } from '@/app/(landing)/components'
export const metadata: Metadata = {
@@ -30,7 +31,7 @@ export default async function CredentialConnectedPage({
? 'The credential is ready to use. You can close this tab and return to the app that started the connection.'
: 'The credential could not be connected. Return to the app that started the connection and try again.'}
-
+
Open Sim
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
index aab03e439b2..a85f0021b9a 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
@@ -69,17 +69,26 @@ vi.mock('@sim/emcn', () => ({
),
ChipModalFooter: ({
primaryAction,
+ secondaryActions,
}: {
primaryAction: { label: string; onClick: () => void; disabled: boolean }
+ secondaryActions?: { label: string; onClick: () => void }[]
}) => (
-
- {primaryAction.label}
-
+ <>
+ {secondaryActions?.map((action) => (
+
+ {action.label}
+
+ ))}
+
+ {primaryAction.label}
+
+ >
),
ChipModalHeader: ({ children }: { children?: ReactNode }) => ,
InfoCard: ({ children }: { children?: ReactNode }) => {children}
,
@@ -127,7 +136,10 @@ vi.mock('@/hooks/queries/credentials', () => ({
mutateAsync: mocks.createDraft,
isPending: false,
}),
- useWorkspaceCredentials: mocks.workspaceCredentials,
+}))
+
+vi.mock('@/hooks/queries/scoped-credentials', () => ({
+ useScopedCredentials: mocks.workspaceCredentials,
}))
vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
@@ -216,6 +228,54 @@ describe('ConnectOAuthModal reauthorization', () => {
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.restoreAllMocks()
+ })
+
+ it('opens an optional setup guide without submitting or losing the connection name', async () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ const onOpenChange = vi.fn()
+ act(() => {
+ root.render(
+
+ )
+ })
+ const name = container.querySelector('input[aria-label="Display name"]')!
+ expect(name).not.toBeNull()
+ act(() => setFormControlValue(name, 'Team account'))
+ const guide = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Setup guide'
+ )!
+
+ act(() => guide.click())
+
+ expect(open).toHaveBeenCalledWith(
+ 'https://docs.sim.ai/search/slack',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ expect(name.value).toBe('Team account')
+ expect(mocks.createDraft).not.toHaveBeenCalled()
+ expect(mocks.connectOAuthService).not.toHaveBeenCalled()
+ expect(onOpenChange).not.toHaveBeenCalled()
+ await clickConnect()
+ expect(mocks.createDraft).toHaveBeenCalledWith(
+ expect.objectContaining({ displayName: 'Team account', providerId: 'slack' })
+ )
+ })
+
+ it('does not add a setup action without a contextual guide', () => {
+ renderReauthorizeModal()
+ expect(container.textContent).not.toContain('Setup guide')
})
it('binds the selected credential draft to the OAuth launch', async () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index b844023b37a..b2be2c61f0c 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -16,6 +16,7 @@ import {
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
+import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import {
ADD_CONNECTOR_SEARCH_PARAM,
@@ -35,12 +36,13 @@ import {
useMicrosoftDataverseEnvironmentForm,
} from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment'
import { withBrandIcon } from '@/blocks/brand-icon'
-import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
+import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
import {
assertMicrosoftDataverseWebOAuthAvailable,
useConnectMicrosoftDataverseOAuthService,
} from '@/hooks/queries/oauth/microsoft-dataverse-connections'
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
+import { useScopedCredentials } from '@/hooks/queries/scoped-credentials'
const logger = createLogger('ConnectOAuthModal')
@@ -105,6 +107,7 @@ interface ConnectOAuthModalBaseProps {
*/
serviceName?: string
serviceIcon?: ServiceIcon
+ docsUrl?: string
/** Used to resolve display metadata and the provider id when not supplied directly. */
provider?: OAuthProvider
serviceId?: string
@@ -121,10 +124,11 @@ interface ConnectOAuthModalBaseProps {
*/
type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & {
mode: 'connect'
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
requiredScopes: readonly string[]
} & (
- | { origin: 'workflow'; workflowId: string }
+ | { origin: 'workflow'; workflowId: string; workspaceId: string; organizationId?: never }
| {
origin: 'kb-connectors'
knowledgeBaseId: string
@@ -144,7 +148,8 @@ interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
requiredScopes?: readonly string[]
newScopes?: readonly string[]
reconnectTarget?: {
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
credentialId: string
displayName: string
}
@@ -163,7 +168,7 @@ export type ConnectOAuthModalProps =
* context written here.
*/
export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
- const { open, onOpenChange, mode } = props
+ const { open, onOpenChange, mode, docsUrl } = props
const isConnect = mode === 'connect'
const declaredProviderId = useMemo(
@@ -216,15 +221,17 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
return resolveService(provider, props.serviceId ?? providerId)
}, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
- const workspaceId = isConnect ? props.workspaceId : (props.reconnectTarget?.workspaceId ?? '')
+ const workspaceId = isConnect ? props.workspaceId : props.reconnectTarget?.workspaceId
+ const organizationId = isConnect ? props.organizationId : props.reconnectTarget?.organizationId
const clientConfiguration = getServiceConfigByProviderId(providerId)?.clientConfiguration
const oauthClientRedirectUri =
clientConfiguration?.redirectPath && typeof window !== 'undefined'
? new URL(clientConfiguration.redirectPath, window.location.origin).toString()
: null
- const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
+ const { data: credentials = [], isPending: credentialsLoading } = useScopedCredentials({
workspaceId,
- enabled: Boolean(workspaceId) && open,
+ organizationId,
+ enabled: Boolean(workspaceId || organizationId) && open,
})
const createDraft = useCreateCredentialDraft()
const connectOAuthService = useConnectOAuthService()
@@ -346,7 +353,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
}
const draft = await createDraft.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
providerId,
displayName: trimmed,
description: description.trim() || undefined,
@@ -371,7 +378,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
accountId: credential.accountId,
updatedAt: credential.updatedAt,
})),
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
requestedAt: Date.now(),
}
@@ -388,6 +395,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
returnContext = {
...baseContext,
origin: 'workflow',
+ workspaceId: props.workspaceId,
+ organizationId: undefined,
workflowId: props.workflowId,
}
} else {
@@ -403,7 +412,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
} else {
if (props.reconnectTarget) {
const draft = await createDraft.mutateAsync({
- workspaceId: props.reconnectTarget.workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner(props.reconnectTarget)),
providerId,
credentialId: props.reconnectTarget.credentialId,
displayName: props.reconnectTarget.displayName,
@@ -424,7 +433,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
accountId: credential.accountId,
updatedAt: credential.updatedAt,
})),
- workspaceId: props.reconnectTarget.workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner(props.reconnectTarget)),
reconnect: true,
requestedAt: Date.now(),
})
@@ -623,6 +632,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
window.open(docsUrl, '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : undefined
+ }
primaryAction={{
label: isPending ? 'Connecting...' : 'Connect',
onClick: handleConnect,
diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx
index 35f72a05818..d74c543210e 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx
@@ -10,6 +10,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
+ Tooltip,
} from '@sim/emcn'
import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options'
@@ -30,6 +31,8 @@ interface FolderContextMenuProps {
pinned: boolean
moveOptions?: MoveOptionNode[]
canEdit: boolean
+ canDelete?: boolean
+ deleteDisabledReason?: string
selectedCount: number
}
@@ -58,12 +61,14 @@ export const FolderContextMenu = memo(function FolderContextMenu({
pinned,
moveOptions,
canEdit,
+ canDelete = canEdit,
+ deleteDisabledReason,
selectedCount,
}: FolderContextMenuProps) {
const isMultiSelect = selectedCount > 1
const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0)
const hasActionsAboveDestructive = !isMultiSelect || hasMove
- const hasAvailableActions = !isMultiSelect || canEdit
+ const hasAvailableActions = !isMultiSelect || (canEdit && (hasMove || canDelete))
return (
!open && onClose()} modal={false}>
@@ -122,11 +127,29 @@ export const FolderContextMenu = memo(function FolderContextMenu({
)}
- {hasActionsAboveDestructive && }
-
-
- {selectionActionLabel('Delete', selectedCount)}
-
+ {canDelete && (
+ <>
+ {hasActionsAboveDestructive && }
+ {deleteDisabledReason ? (
+
+
+
+
+
+ {selectionActionLabel('Delete', selectedCount)}
+
+
+
+ {deleteDisabledReason}
+
+ ) : (
+
+
+ {selectionActionLabel('Delete', selectedCount)}
+
+ )}
+ >
+ )}
>
)}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
index f88977be585..24a22ed7d60 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Banner } from '@sim/emcn'
import { useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
import { clearUserData } from '@/stores'
@@ -39,7 +40,7 @@ export function ImpersonationBanner() {
onSuccess: async () => {
setIsRedirecting(true)
await clearUserData({ preserveRecentImpersonations: true })
- window.location.assign('/workspace')
+ window.location.assign(APP_ENTRY_PATH)
},
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx
index 5bf9becae94..f27e865a6a1 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx
@@ -12,7 +12,7 @@ const { hostContext, mockUseOrganizationBilling, mockUseAdminWorkspaces, mockMut
current: {
hostOrganizationId: 'org-host',
viewer: { isHostOrganizationAdmin: false },
- },
+ } as { hostOrganizationId: string; viewer: { isHostOrganizationAdmin: boolean } } | null,
},
mockUseOrganizationBilling: vi.fn(),
mockUseAdminWorkspaces: vi.fn(),
@@ -25,8 +25,38 @@ vi.mock('@sim/emcn', () => ({
ChipModal: ({ children }: { children: ReactNode }) => {children}
,
ChipModalBody: ({ children }: { children: ReactNode }) => {children}
,
ChipModalError: ({ children }: { children: ReactNode }) => {children}
,
- ChipModalField: () =>
,
- ChipModalFooter: () =>
,
+ ChipModalField: ({
+ title,
+ type,
+ onChange,
+ options,
+ }: {
+ title: string
+ type: string
+ onChange?: (value: string[]) => void
+ options?: readonly { value: string; label: string }[]
+ }) => (
+
+ {title}
+ {type === 'emails' && (
+ onChange?.(['person@example.com'])}>
+ Add test recipient
+
+ )}
+ {options?.map((option) => (
+ {option.label}
+ ))}
+
+ ),
+ ChipModalFooter: ({
+ primaryAction,
+ }: {
+ primaryAction: { label: string; onClick: () => void; disabled: boolean }
+ }) => (
+
+ {primaryAction.label}
+
+ ),
ChipModalHeader: ({ children }: { children: ReactNode }) => {children}
,
toast: { success: vi.fn() },
}))
@@ -36,7 +66,7 @@ vi.mock('@/lib/auth/auth-client', () => ({
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
- useWorkspaceHostContext: () => hostContext.current,
+ useOptionalWorkspaceHostContext: () => hostContext.current,
}))
vi.mock('@/hooks/queries/invitations', () => ({
@@ -104,6 +134,61 @@ describe('InviteModal organization billing isolation', () => {
expect(mockUseOrganizationBilling).toHaveBeenCalledWith('org-host', { enabled: false })
})
+ it('invites an organization member without a workspace provider or workspace selection', async () => {
+ hostContext.current = null
+ await act(async () => {
+ root.render(
+
+ )
+ })
+ expect(container.querySelector('[data-field="Workspaces"]')).toBeNull()
+ expect(container.querySelector('[data-field="Workspace access"]')).toBeNull()
+ expect(container.querySelector('[data-field="Role"]')?.textContent).toBe('RoleMemberAdmin')
+ expect(mockUseAdminWorkspaces).toHaveBeenCalledWith('user-1', 'org-target', { enabled: false })
+ const recipientButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Add test recipient'
+ )
+ await act(async () => recipientButton?.click())
+ const sendButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Send invites'
+ )
+ expect(sendButton?.disabled).toBe(false)
+ await act(async () => sendButton?.click())
+ expect(mockMutate).toHaveBeenCalledWith(
+ {
+ workspaceIds: [],
+ organizationId: 'org-target',
+ emails: ['person@example.com'],
+ permission: 'write',
+ membership: 'member',
+ },
+ expect.anything()
+ )
+ })
+
+ it('keeps org-only invites disabled for a member without target-org admin authority', async () => {
+ hostContext.current = null
+ await act(async () =>
+ root.render(
+
+ )
+ )
+ const recipientButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Add test recipient'
+ )
+ await act(async () => recipientButton?.click())
+ const sendButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Send invites'
+ )
+ expect(sendButton?.disabled).toBe(true)
+ expect(container.querySelector('[data-field="Role"]')?.textContent).toBe('RoleMember')
+ })
+
it('fetches seat data for an administrator of the routed host organization', async () => {
hostContext.current = {
hostOrganizationId: 'org-host',
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
index 38e7ef2038a..090a9096847 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
@@ -19,7 +19,7 @@ import { isEnterprise } from '@/lib/billing/plan-helpers'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
-import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
+import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useSendWorkspaceInvitations } from '@/hooks/queries/invitations'
import { useOrganizationBilling } from '@/hooks/queries/organization'
import { useAdminWorkspaces } from '@/hooks/queries/workspace'
@@ -99,6 +99,8 @@ interface InviteModalProps {
inviteDisabledReason?: string | null
/** False when the viewer lacks permission to invite. */
canInvite?: boolean
+ /** Target-organization authority when this modal is rendered outside a workspace. */
+ isOrganizationAdmin?: boolean
}
/**
@@ -114,6 +116,7 @@ export function InviteModal({
organizationId = null,
inviteDisabledReason = null,
canInvite = true,
+ isOrganizationAdmin,
}: InviteModalProps) {
const [emails, setEmails] = useState([])
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState(
@@ -143,6 +146,7 @@ export function InviteModal({
const { data: session } = useSession()
const { billingEnabled } = useDeploymentShape()
const isOrganizationInvite = Boolean(organizationId)
+ const organizationOnly = isOrganizationInvite && !workspaceId
const sendInvitations = useSendWorkspaceInvitations()
const isSubmitting = sendInvitations.isPending
@@ -154,7 +158,7 @@ export function InviteModal({
const { data: adminWorkspaces } = useAdminWorkspaces(
session?.user?.id,
organizationId ?? undefined,
- { enabled: open && isOrganizationInvite }
+ { enabled: open && isOrganizationInvite && !organizationOnly }
)
const workspaceOptions = useMemo(() => {
@@ -171,7 +175,7 @@ export function InviteModal({
* it is fetched solely when the viewer administers the organization the page
* is actually hosted by.
*/
- const hostContext = useWorkspaceHostContext()
+ const hostContext = useOptionalWorkspaceHostContext()
/**
* Organization Admin is an organization-level grant — it carries admin on every
* workspace the org owns plus member and billing management — so it is only
@@ -180,15 +184,15 @@ export function InviteModal({
*/
const canGrantOrganizationAdmin =
isOrganizationInvite &&
- hostContext.hostOrganizationId === organizationId &&
- hostContext.viewer.isHostOrganizationAdmin
- const membershipOptions = canGrantOrganizationAdmin
- ? MEMBERSHIP_OPTIONS
- : MEMBERSHIP_OPTIONS.filter((option) => option.value !== 'admin')
- const canViewOrganizationBilling =
- isOrganizationInvite &&
- hostContext.hostOrganizationId === organizationId &&
- hostContext.viewer.isHostOrganizationAdmin
+ (isOrganizationAdmin ??
+ (hostContext?.hostOrganizationId === organizationId &&
+ hostContext.viewer.isHostOrganizationAdmin))
+ const membershipOptions = MEMBERSHIP_OPTIONS.filter(
+ (option) =>
+ (option.value !== 'admin' || canGrantOrganizationAdmin) &&
+ (option.value !== 'external' || !organizationOnly)
+ )
+ const canViewOrganizationBilling = canGrantOrganizationAdmin
const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
enabled: open && billingEnabled && canViewOrganizationBilling,
@@ -235,9 +239,9 @@ export function InviteModal({
setErrorMessage(null)
}, [])
- const handleSend = useCallback(() => {
+ const handleSend = () => {
setErrorMessage(null)
- if (emails.length === 0 || selectedWorkspaceIds.length === 0) return
+ if (emails.length === 0 || (!organizationOnly && selectedWorkspaceIds.length === 0)) return
sendInvitations.mutate(
{
@@ -278,28 +282,21 @@ export function InviteModal({
},
}
)
- }, [
- emails,
- selectedWorkspaceIds,
- access,
- membership,
- isOrganizationInvite,
- organizationId,
- onOpenChange,
- ])
+ }
const isSendDisabled =
!canInvite ||
Boolean(inviteDisabledReason) ||
isSubmitting ||
emails.length === 0 ||
- selectedWorkspaceIds.length === 0
+ (!organizationOnly && selectedWorkspaceIds.length === 0) ||
+ (organizationOnly && !canGrantOrganizationAdmin)
return (
onOpenChange(false)}>Invite teammates
@@ -317,34 +314,38 @@ export function InviteModal({
}
disabled={isSubmitting || !canInvite}
/>
-
-
-
- setAccess(next as PermissionType)}
- />
+ {!organizationOnly && (
+ <>
+
+
+
+ setAccess(next as PermissionType)}
+ />
+ >
+ )}
{isOrganizationInvite && (
({
+ params: {} as { organizationId?: string; workspaceId?: string },
+ push: vi.fn(),
+ fork: vi.fn(),
+ useFork: vi.fn(),
+ clearChatSelection: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => mocks.params,
+ useRouter: () => ({ push: mocks.push }),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ Check: () => null,
+ Duplicate: () => null,
+ Split: () => null,
+ ThumbsDown: () => null,
+ ThumbsUp: () => null,
+ ChipModal: () => null,
+ ChipModalBody: () => null,
+ ChipModalField: () => null,
+ ChipModalFooter: () => null,
+ ChipModalHeader: () => null,
+ Tooltip: {
+ Root: ({ children }: { children: ReactNode }) => <>{children}>,
+ Trigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ Content: () => null,
+ },
+ cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
+ toast: { warning: vi.fn(), error: vi.fn() },
+ useCopyToClipboard: () => ({ copied: false, copy: vi.fn() }),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({
+ useChatSurface: () => ({ chatId: 'parent-chat' }),
+}))
+
+vi.mock('@/hooks/queries/copilot-feedback', () => ({
+ useSubmitCopilotFeedback: () => ({ mutate: vi.fn() }),
+}))
+
+vi.mock('@/hooks/queries/mothership-chats', () => ({
+ useForkMothershipChat: mocks.useFork,
+}))
+
+vi.mock('@/stores/folders/store', () => ({
+ useFolderStore: { getState: () => ({ clearChatSelection: mocks.clearChatSelection }) },
+}))
+
+import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions/message-actions'
+
+describe('MessageActions fork navigation', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.params = {}
+ mocks.fork.mockResolvedValue({ id: 'forked-chat' })
+ mocks.useFork.mockReturnValue({ mutateAsync: mocks.fork, isPending: false })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ async function renderActions() {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+ }
+
+ async function forkChat() {
+ const button = container.querySelector('[aria-label="Fork in new chat"]')
+ expect(button).not.toBeNull()
+ await act(async () => button?.click())
+ }
+
+ it('keeps an organization fork in its organization and leaves workspace selection untouched', async () => {
+ mocks.params = { organizationId: 'organization-1' }
+ await renderActions()
+ await forkChat()
+
+ expect(mocks.useFork).toHaveBeenCalledWith({ organizationId: 'organization-1' })
+ expect(mocks.fork).toHaveBeenCalledWith({
+ chatId: 'parent-chat',
+ upToMessageId: 'persisted-message',
+ })
+ expect(mocks.push).toHaveBeenCalledWith('/o/organization-1/chat/forked-chat')
+ expect(mocks.clearChatSelection).not.toHaveBeenCalled()
+ })
+
+ it('preserves workspace fork navigation and clears the workspace chat selection', async () => {
+ mocks.params = { workspaceId: 'workspace-1' }
+ await renderActions()
+ await forkChat()
+
+ expect(mocks.useFork).toHaveBeenCalledWith('workspace-1')
+ expect(mocks.push).toHaveBeenCalledWith('/workspace/workspace-1/chat/forked-chat')
+ expect(mocks.clearChatSelection).toHaveBeenCalledOnce()
+ })
+
+ it('does not offer a fork when the route has no owner scope', async () => {
+ await renderActions()
+
+ expect(container.querySelector('[aria-label="Fork in new chat"]')).toBeNull()
+ expect(mocks.fork).not.toHaveBeenCalled()
+ expect(mocks.push).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index f1911cd1144..6207a1e247a 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -20,6 +20,7 @@ import {
} from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
+import { organizationRoutes } from '@/lib/navigation/paths'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
@@ -49,7 +50,10 @@ export const MessageActions = memo(function MessageActions({
messageId,
}: MessageActionsProps) {
const router = useRouter()
- const params = useParams<{ workspaceId: string }>()
+ const params = useParams<{ workspaceId?: string; organizationId?: string }>()
+ const owner = params.organizationId
+ ? { organizationId: params.organizationId }
+ : params.workspaceId
const { chatId } = useChatSurface()
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
const [copiedRequestId, setCopiedRequestId] = useState(false)
@@ -57,7 +61,7 @@ export const MessageActions = memo(function MessageActions({
const [feedbackText, setFeedbackText] = useState('')
const requestIdTimeoutRef = useRef(null)
const submitFeedback = useSubmitCopilotFeedback()
- const forkChat = useForkMothershipChat(params.workspaceId)
+ const forkChat = useForkMothershipChat(owner)
useEffect(() => {
return () => {
@@ -125,7 +129,7 @@ export const MessageActions = memo(function MessageActions({
}
const handleFork = async () => {
- if (!chatId || !messageId || forkChat.isPending) return
+ if (!owner || !chatId || !messageId || forkChat.isPending) return
try {
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
if (result.failedFileCopies) {
@@ -133,8 +137,12 @@ export const MessageActions = memo(function MessageActions({
`${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork`
)
}
- useFolderStore.getState().clearChatSelection()
- router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
+ if (params.organizationId) {
+ router.push(organizationRoutes(params.organizationId).chat(result.id))
+ } else {
+ useFolderStore.getState().clearChatSelection()
+ router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
+ }
} catch {
toast.error('Failed to fork chat')
}
@@ -145,7 +153,7 @@ export const MessageActions = memo(function MessageActions({
// A live (just-streamed) assistant message carries a synthetic id that the
// persisted transcript doesn't know — forking it would 400. The button
// appears once the transcript refetch swaps in the persisted message id.
- const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
+ const canFork = Boolean(owner && chatId && messageId && !isLiveAssistantMessageId(messageId))
if (!canCopyContent && !canSubmitFeedback && !canFork) return null
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx
index 5a792131fd5..499b185fc72 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx
@@ -6,13 +6,20 @@ vi.mock('@sim/emcn', () => ({
DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <>{children}> : null,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}>,
- DropdownMenuItem: ({ children }: { children: ReactNode }) => {children} ,
+ DropdownMenuItem: ({ children, disabled }: { children: ReactNode; disabled?: boolean }) => (
+ {children}
+ ),
DropdownMenuSeparator: () => ,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children} ,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
Upload: () => null,
+ Tooltip: {
+ Root: ({ children }: { children: ReactNode }) => <>{children}>,
+ Trigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ Content: ({ children }: { children: ReactNode }) => <>{children}>,
+ },
}))
vi.mock('@sim/emcn/icons', () => ({
@@ -47,6 +54,48 @@ const POSITION = { x: 0, y: 0 }
const MOVE_OPTIONS = [{ value: '__root__', label: 'Root', children: [] }]
describe('selection-aware resource context menus', () => {
+ it('hides a protected mixed-folder delete while retaining movement', () => {
+ const menu = renderToStaticMarkup(
+ {}}
+ onOpen={() => {}}
+ onRename={() => {}}
+ onDelete={() => {}}
+ onMove={() => {}}
+ onTogglePin={() => {}}
+ pinned={false}
+ canEdit
+ canDelete={false}
+ moveOptions={MOVE_OPTIONS}
+ selectedCount={2}
+ />
+ )
+ expect(menu).toContain('Move 2 items')
+ expect(menu).not.toContain('Delete')
+ })
+
+ it('explains a blocked folder cascade while retaining ordinary folder actions', () => {
+ const menu = renderToStaticMarkup(
+ {}}
+ onOpen={() => {}}
+ onRename={() => {}}
+ onDelete={() => {}}
+ onTogglePin={() => {}}
+ pinned={false}
+ canEdit
+ deleteDisabledReason='Delete the search knowledge base first'
+ selectedCount={1}
+ />
+ )
+ expect(menu).toContain('Rename')
+ expect(menu).toContain('Delete the search knowledge base first')
+ expect(menu).toContain('aria-disabled="true"')
+ })
it('limits a multi-table menu to actions that can target the selection', () => {
const menu = renderToStaticMarkup(
-
+
View your workspaces
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
index f568d2dec2d..fdd2f9bf069 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
@@ -1 +1,3 @@
+export type { SidebarChromeState } from './sidebar-chrome-context'
+export { SidebarChromeProvider, useSidebarChrome } from './sidebar-chrome-context'
export { WorkspaceChrome } from './workspace-chrome'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx
new file mode 100644
index 00000000000..cdb39e7de5d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { createContext, type ReactNode, useContext, useMemo } from 'react'
+
+export interface SidebarChromeState {
+ /**
+ * Authoritative collapse state, derived once in `WorkspaceChrome` from the
+ * `sidebar_collapsed` cookie (server prop → store after hydration) so the rail's
+ * structure, labels, and width all read a single source.
+ */
+ isCollapsed: boolean
+ /**
+ * True while the sidebar is rendered as the desktop hover-peek card. The card shows
+ * the expanded layout even though the rail is collapsed, so a sidebar treats this
+ * as overriding {@link SidebarChromeState.isCollapsed} — and separately suppresses
+ * the chrome the card already provides (the title-bar lane, drag-resize).
+ */
+ isPeeking: boolean
+}
+
+const SidebarChromeContext = createContext(null)
+
+interface SidebarChromeProviderProps extends SidebarChromeState {
+ children: ReactNode
+}
+
+/**
+ * Hands the chrome's collapse and peek state to whichever sidebar it hosts. The
+ * chrome owns that state; the sidebar is passed in as an element, so it cannot take
+ * the values as props from a server layout — it reads them here instead.
+ */
+export function SidebarChromeProvider({
+ isCollapsed,
+ isPeeking,
+ children,
+}: SidebarChromeProviderProps) {
+ const value = useMemo(() => ({ isCollapsed, isPeeking }), [isCollapsed, isPeeking])
+ return {children}
+}
+
+export function useSidebarChrome(): SidebarChromeState {
+ const context = useContext(SidebarChromeContext)
+ if (!context) {
+ throw new Error('useSidebarChrome must be used within WorkspaceChrome')
+ }
+ return context
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
index f782b93d2f5..fd0e5aac7f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
@@ -1,23 +1,20 @@
'use client'
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { type ReactNode, useEffect, useLayoutEffect, useState } from 'react'
import { cn } from '@sim/emcn'
import { ArrowLeft, ArrowRight, PanelLeft } from '@sim/emcn/icons'
import { usePathname } from 'next/navigation'
import { getDesktopBridge } from '@/lib/desktop'
import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar'
+import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context'
import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek'
-import { Sidebar, SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
-/** Slide timing for the fullscreen sidebar collapse and content shift. */
-const SLIDE_TRANSITION =
- '[transition-duration:175ms] [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none'
-
/**
* The peek card's floating chrome.
*
@@ -63,25 +60,28 @@ const PEEK_CARD_EXIT = cn(
'pointer-events-none animate-out fade-out-0 zoom-out-95 fill-mode-forwards duration-150 ease-out motion-reduce:animate-none'
)
-/** The docked rail: in flow, width-animated by the collapse toggle. */
-const SIDEBAR_SHELL_IN_FLOW = cn('transition-[width]', SLIDE_TRANSITION)
-
/**
- * The content pane's own chrome, dropped when the pane sits flush to the window.
- *
- * Collapsing the sidebar in the desktop shell takes the surrounding padding to `0`,
- * which puts the pane hard against the window edge — and its border and radius then
- * draw a hairline outline with rounded corners inset from the square window frame.
+ * The divider between the rail and the content pane, dropped when there is no rail
+ * beside it: collapsed to nothing in the desktop shell, where the pane sits hard
+ * against the window edge. A fullscreen route drops it through React state instead,
+ * since that is a navigation rather than a pre-paint attribute.
*
* Keyed off the ancestor attributes rather than React state on purpose: the title-bar
- * attribute is written pre-paint, so a state-driven rule would flash the border on
+ * attribute is written pre-paint, so a state-driven rule would flash the line on
* first paint before hydration settles.
*/
-const CONTENT_PANE_FLUSH =
- '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:rounded-none [[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:border-0'
+const CONTENT_PANE_DIVIDER =
+ 'border-l border-[var(--border)] [[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:border-l-0'
interface WorkspaceChromeProps {
- children: React.ReactNode
+ children: ReactNode
+ /**
+ * The rail this chrome hosts. Rendered once inside the shell and never re-mounted
+ * across collapse, peek, or fullscreen; it reads collapse and peek state through
+ * {@link useSidebarChrome}. The workspace passes its own `Sidebar`; the organization
+ * surface passes `OrganizationSidebar`.
+ */
+ sidebar: ReactNode
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
initialSidebarCollapsed?: boolean
}
@@ -154,15 +154,15 @@ function isFullscreenPath(pathname: string | null): boolean {
}
/**
- * Renders the workspace chrome as a single persistent tree. The sidebar is
+ * Renders the app chrome as a single persistent tree — the workspace layout and the
+ * organization layout both mount it, each with its own sidebar. The sidebar is
* always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to
- * zero width while the inner shell slides off the left edge, revealing the route
- * content. Because this component lives in the workspace layout it persists
- * across navigations, so the pathname-driven class toggle animates smoothly.
+ * zero width, revealing the route content. Because this component lives in the
+ * layout it persists across navigations, so the rail never re-mounts.
*
- * Leaving a fullscreen route is instant: App Router swaps `children` to the
- * origin page and the fullscreen page is simply unmounted, while the sidebar
- * slides back in. There is no exit fade — the new page just loads in place.
+ * Nothing here animates: collapse, expand, and the fullscreen swap all apply in
+ * one frame. The rail and the pane meet on a single hairline divider with no
+ * gutter, radius, or shift between states.
*
* Because the chrome observes every pathname transition, it records the page a
* fullscreen route was launched from into {@link useFullscreenOriginStore}. The
@@ -170,9 +170,6 @@ function isFullscreenPath(pathname: string | null): boolean {
* trigger that merely pushes a fullscreen route gets correct return-to-origin
* without per-call-site wiring.
*
- * On a direct load of a fullscreen route the wrapper mounts already collapsed,
- * so no slide plays (CSS transitions don't run on mount).
- *
* On the macOS desktop shell, where collapsing hides the rail entirely, the same
* wrapper doubles as the hover-peek card: hovering the title-bar sidebar toggle
* takes it out of flow, floats it over the content inset from the window edge, and
@@ -181,10 +178,9 @@ function isFullscreenPath(pathname: string | null): boolean {
*/
export function WorkspaceChrome({
children,
+ sidebar,
initialSidebarCollapsed = false,
}: WorkspaceChromeProps) {
- const rafRef = useRef(0)
-
const pathname = usePathname()
const isFullscreen = isFullscreenPath(pathname)
@@ -228,29 +224,6 @@ export function WorkspaceChrome({
const { isPeekActive, isPeekOpen, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } =
useSidebarPeek(peekEnabled, isSearchModalOpen)
- /**
- * Suppresses sidebar transitions across the initial hydration window. The
- * pre-paint script already set the correct `--sidebar-width`, but the store
- * rehydration below re-applies it a tick later; without this guard that
- * re-apply animates the rail, reading as a collapse -> expand flash on a
- * fresh load. Applied before the rehydrate effect so the class is in place
- * ahead of the width mutation, then lifted after the first paint so
- * user-driven collapse toggles and the fullscreen slide still animate.
- */
- useLayoutEffect(() => {
- const root = document.documentElement
- root.classList.add('sidebar-booting')
- const raf1 = requestAnimationFrame(() => {
- const raf2 = requestAnimationFrame(() => root.classList.remove('sidebar-booting'))
- rafRef.current = raf2
- })
- rafRef.current = raf1
- return () => {
- cancelAnimationFrame(rafRef.current)
- root.classList.remove('sidebar-booting')
- }
- }, [])
-
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
useLayoutEffect(() => {
void useSidebarStore.persist.rehydrate()
@@ -362,7 +335,9 @@ export function WorkspaceChrome({
? isPeekOpen
? PEEK_CARD_ENTER
: PEEK_CARD_EXIT
- : cn(isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]', SIDEBAR_SHELL_IN_FLOW)
+ : isFullscreen
+ ? 'w-0'
+ : 'w-[var(--sidebar-width)]'
)}
data-collapsed={isCollapsed || undefined}
data-peek={isPeekActive || undefined}
@@ -370,23 +345,14 @@ export function WorkspaceChrome({
aria-hidden={isFullscreen || (isPeekActive && !isPeekOpen) || undefined}
suppressHydrationWarning
>
-
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index 6185773176e..f72a679abe5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -1,20 +1,17 @@
'use client'
import { useMemo } from 'react'
-import { Button, Chip, OverflowText } from '@sim/emcn'
-import { FileText } from '@sim/emcn/icons'
-import { formatDate } from '@sim/utils/formatting'
+import { Chip, ChipLink } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
-import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
+import type {
+ WorkspaceKnowledgeSearchResult,
+ WorkspaceSearchFilters,
+} from '@/lib/api/contracts/knowledge'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { matchSnippet } from '@/lib/knowledge/search/snippet'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
-import { searchedKnowledgeBases } from '@/lib/sim-search/knowledge-bases'
-import {
- highlightTerms,
- SOURCE_ROW_CLASSES,
- SOURCE_ROW_MARK_CLASSES,
- SourceCard,
-} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
+import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
import {
isHttpUrl,
type SourceTagData,
@@ -26,13 +23,11 @@ import {
UPDATED_WINDOWS,
} from '@/app/workspace/[workspaceId]/home/search-params'
import {
- useWorkspaceMemberConnectors,
+ useSearchIndex,
+ useSearchSources,
type WorkspaceMemberConnector,
} from '@/hooks/queries/kb/connectors'
-import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
-import { useMemberAccessAvailable } from '@/hooks/use-member-access'
-
-const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
+import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
/** Filters appear only once a list is long and mixed enough for them to help. */
const FILTERS_MIN_RESULTS = 10
@@ -78,14 +73,18 @@ export function indexingSourceNames(
/**
* A result as the source card renders it: the row's second line names the
- * source app, or the knowledge base for an upload. A document without an
- * http(s) source URL cannot be opened, and a connector-supplied value of any
- * other scheme is never handed to the browser as a link.
+ * source app, or the knowledge base for an upload. Without an HTTP(S) source
+ * URL, the link opens the canonical document in Sim.
*/
-function toSource(result: WorkspaceKnowledgeSearchResult, query: string): SourceTagData | null {
- if (!isHttpUrl(result.sourceUrl)) return null
+function toSource(
+ result: WorkspaceKnowledgeSearchResult,
+ query: string,
+ scope: ResourceScope
+): SourceTagData {
return {
- url: result.sourceUrl,
+ url: isHttpUrl(result.sourceUrl)
+ ? result.sourceUrl
+ : `${getBaseUrl()}${scope.kind === 'organization' ? `/o/${encodeURIComponent(scope.organizationId)}` : `/workspace/${encodeURIComponent(scope.workspaceId)}`}/knowledge/${encodeURIComponent(result.knowledgeBaseId)}/${encodeURIComponent(result.documentId)}`,
title: result.documentName ?? undefined,
siteName: result.connectorType
? connectorDisplayName(result.connectorType)
@@ -113,53 +112,18 @@ function handleResultsKeyDown(event: React.KeyboardEvent
) {
links[next].focus()
}
-interface UnlinkedResultRowProps {
- result: WorkspaceKnowledgeSearchResult
+type KnowledgeSearchResultsProps = (
+ | { workspaceId: string; scope?: never }
+ | { scope: ResourceScope; workspaceId?: never }
+) & {
query: string
-}
-
-/**
- * A document with nowhere to open, such as an upload: the same row as a
- * linked result, with the file mark in place of a brand mark, so the list's
- * columns and the matched passage stay aligned whatever the document is.
- */
-function UnlinkedResultRow({ result, query }: UnlinkedResultRowProps) {
- const meta = [
- result.knowledgeBaseName,
- result.author,
- result.sourceModifiedAt ? formatDate(new Date(result.sourceModifiedAt)) : null,
- ].filter((part): part is string => Boolean(part))
- return (
-
-
-
-
-
-
-
-
- {highlightTerms(matchSnippet(result.content, query), query)}
-
-
-
- )
-}
-
-interface KnowledgeSearchResultsProps {
- workspaceId: string
- query: string
- /** Asks the agent about one document; the prompt names it and links to it. */
- onSummarize: (prompt: string) => void
- /** Asks the agent the query itself, for a prose answer with citations. */
- onAnswer: (query: string) => void
+ /** Binds the Assistant turn to the selected canonical document. */
+ onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
}
/**
* The composer's Search mode: the documents the signed-in person may read that
- * match their query, across every knowledge base in the workspace, as rows
+ * match their query in the canonical Enterprise Search index, as rows
* that open the source. A header says how many and that the search ran as
* them; while a connected source is still indexing it says so, and the list
* grows as documents land. Filters by source and recency appear only once the
@@ -168,73 +132,90 @@ interface KnowledgeSearchResultsProps {
*/
export function KnowledgeSearchResults({
workspaceId,
+ scope: suppliedScope,
query,
onSummarize,
- onAnswer,
}: KnowledgeSearchResultsProps) {
+ const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const {
- data: knowledgeBases = [],
+ data: index,
isPending: basesPending,
- error: basesError,
- } = useKnowledgeBasesQuery(workspaceId)
- const knowledgeBaseIds = searchedKnowledgeBases(knowledgeBases, workspaceId).map((kb) => kb.id)
+ isError: basesFailed,
+ isFetching: basesFetching,
+ refetch: refetchIndex,
+ } = useSearchIndex(scope)
+ const knowledgeBaseIds = index?.knowledgeBaseId ? [index.knowledgeBaseId] : []
+ const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
+ const searchFilters = useMemo(() => {
+ const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
+ return {
+ ...(filters.source ? { source: filters.source } : {}),
+ ...(window?.days
+ ? { modifiedAfter: new Date(Date.now() - window.days * DAY_MS).toISOString() }
+ : {}),
+ }
+ }, [filters.source, filters.updated])
const {
data: results,
isPending,
isFetching,
- isPlaceholderData,
- error,
- } = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query)
- /**
- * With per-member access off, member-scoped documents are hidden, so the
- * indexing list is not worth asking for.
- */
- const memberAccessAvailable = useMemberAccessAvailable()
- const { data: memberConnectorRows } = useWorkspaceMemberConnectors(workspaceId, {
- enabled: memberAccessAvailable,
- })
- /** Rows cached before the feature went off are not this surface's to show. */
- const memberConnectors = memberAccessAvailable
- ? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS)
- : EMPTY_MEMBER_CONNECTORS
- const indexing = indexingSourceNames(memberConnectors, knowledgeBaseIds)
+ isError: searchFailed,
+ refetch: refetchSearch,
+ } = useWorkspaceKnowledgeSearch(scope, query, searchFilters)
+ const { data: sources = [] } = useSearchSources(scope)
+ const indexing = [
+ ...new Set(
+ sources
+ .filter((source) => source.isSyncing)
+ .map((source) => connectorDisplayName(source.connectorType))
+ ),
+ ]
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
- const sourceTypes = useMemo(
- () => [...new Set(documents.map((result) => result.connectorType ?? UPLOAD_SOURCE))],
- [documents]
- )
- const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
+ const sourceTypes = [
+ ...new Set([
+ ...(filters.source ? [filters.source] : []),
+ ...documents.map((result) => result.connectorType ?? UPLOAD_SOURCE),
+ ]),
+ ]
const filtersActive = filters.source !== null || filters.updated !== 'any'
/** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */
const showFilters =
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
- const visible = useMemo(() => {
- if (!filtersActive) return documents
- const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
- const cutoff = window?.days ? Date.now() - window.days * DAY_MS : null
- return documents.filter((result) => {
- if (filters.source && (result.connectorType ?? UPLOAD_SOURCE) !== filters.source) return false
- if (cutoff !== null) {
- const modified = result.sourceModifiedAt ? Date.parse(result.sourceModifiedAt) : Number.NaN
- if (Number.isNaN(modified) || modified < cutoff) return false
- }
- return true
- })
- }, [documents, filtersActive, filters.source, filters.updated])
- const failure = basesError ?? error
- if (failure) {
- return {failure.message}
+ /* A failed search says so in one quiet line and offers to run again; the cause is
+ the server's to log, never the reader's to parse. */
+ if (basesFailed || searchFailed) {
+ const retrying = basesFetching || isFetching
+ return (
+
+
Search couldn’t run.
+
void (basesFailed ? refetchIndex() : refetchSearch())}
+ >
+ {retrying ? 'Retrying…' : 'Try again'}
+
+
+ )
}
if (!basesPending && knowledgeBaseIds.length === 0) {
return (
-
- Nothing to search yet. Clear the query and connect a source to index what you can open.
-
+
+
No sources are set up yet.
+
+ View sources
+
+
)
}
- /** Kept results belong to the previous query; a new query shows its own state. */
- if (isPending || isPlaceholderData || (isFetching && !results)) {
+ if (isPending || (isFetching && !results)) {
return Searching…
}
@@ -253,9 +234,6 @@ export function KnowledgeSearchResults({
{' · searched as you'}
{indexingNote && {indexingNote} }
- onAnswer(query)}>
- Answer with Sim
-
{showFilters && (
@@ -289,27 +267,28 @@ export function KnowledgeSearchResults({
))}
)}
- {visible.length === 0 ? (
+ {documents.length === 0 ? (
- {documents.length === 0
- ? `No documents you can read match “${query}”.`
- : 'No documents match these filters.'}
+ {filtersActive
+ ? 'No documents match these filters.'
+ : `No documents you can read match “${query}”.`}
) : (
- {visible.map((result) => {
- const source = toSource(result, query)
- return source ? (
+ {documents.map((result) => {
+ const source = toSource(result, query, scope)
+ return (
- onSummarize(`Summarize "${cited.title ?? cited.url}" (${cited.url})`)
+ onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
+ ...searchFilters,
+ documentIds: [result.documentId],
+ })
}
/>
- ) : (
-
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 1a8ea3dfd5b..ef9edea0bc2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -455,6 +455,7 @@ const MARKDOWN_COMPONENTS = {
interface ChatContentProps {
content: string
messageId?: string
+ requestMode?: 'agent' | 'assistant'
isStreaming?: boolean
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
@@ -478,6 +479,7 @@ interface ChatContentProps {
function ChatContentInner({
content,
messageId,
+ requestMode,
isStreaming = false,
questionAnswers,
credentialSubmission,
@@ -725,6 +727,7 @@ function ChatContentInner({
questionAnswers={questionAnswers}
credentialSubmission={credentialSubmission}
credentialAbandoned={credentialAbandoned}
+ requestMode={requestMode}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/personal-credential-card.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/personal-credential-card.test.tsx
new file mode 100644
index 00000000000..67ff47b73c1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/personal-credential-card.test.tsx
@@ -0,0 +1,425 @@
+/**
+ * @vitest-environment jsdom
+ * @vitest-environment-options { "url": "https://sim.test/workspace/workspace-1/chat/chat-1" }
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PersonalCredential } from '@/lib/api/contracts/credentials'
+import type { CredentialItemData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
+
+const mocks = vi.hoisted(() => ({
+ rows: [] as PersonalCredential[],
+ fetched: true,
+ metadataError: null as Error | null,
+ startPending: false,
+ canEdit: false,
+ list: vi.fn(),
+ start: vi.fn(),
+ refetch: vi.fn(),
+ workspaceCredentials: vi.fn(),
+ personalEnvironment: vi.fn(),
+ continue: vi.fn(),
+ openExternal: vi.fn(),
+ desktop: false,
+ error: null as Error | null,
+}))
+
+vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('@/lib/desktop', () => ({
+ getDesktopBridge: () => (mocks.desktop ? { openExternal: mocks.openExternal } : null),
+}))
+vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null }) }))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useUserPermissionsContext: () => ({ canEdit: mocks.canEdit }),
+}))
+vi.mock('@/hooks/queries/personal-credentials', () => ({
+ usePersonalCredentials: (workspaceId: string, options: unknown) => {
+ mocks.list(workspaceId, options)
+ return {
+ data: mocks.rows,
+ isFetched: mocks.fetched,
+ isSuccess: mocks.fetched && !mocks.metadataError,
+ isError: Boolean(mocks.metadataError),
+ refetch: mocks.refetch,
+ error: mocks.metadataError,
+ }
+ },
+ useStartPersonalCredentialConnection: () => ({
+ mutate: mocks.start,
+ isPending: mocks.startPending,
+ error: mocks.error,
+ }),
+}))
+vi.mock('@/hooks/queries/credentials', () => ({
+ useWorkspaceCredentials: (options: unknown) => {
+ mocks.workspaceCredentials(options)
+ return { data: [], refetch: vi.fn() }
+ },
+ useUpdateWorkspaceCredential: () => ({ mutateAsync: vi.fn() }),
+ useWorkspaceCredential: () => ({ data: null }),
+}))
+vi.mock('@/hooks/queries/environment', () => ({
+ usePersonalEnvironment: (options: unknown) => {
+ mocks.personalEnvironment(options)
+ return { data: {}, refetch: vi.fn() }
+ },
+ useSavePersonalEnvironment: () => ({ mutateAsync: vi.fn() }),
+ useUpsertWorkspaceEnvironment: () => ({ mutateAsync: vi.fn() }),
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal',
+ () => ({
+ ConnectPersonalTokenModal: ({
+ onConnected,
+ onOpenChange,
+ }: {
+ onConnected: () => void
+ onOpenChange: (open: boolean) => void
+ }) => (
+
+ {
+ onConnected()
+ onOpenChange(false)
+ }}
+ >
+ Finish personal token
+
+
+ ),
+ })
+)
+
+import { OAUTH_CHAT_ATTEMPT_MAX_AGE_MS } from '@/lib/credentials/oauth-chat-attempt'
+import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
+
+let root: Root
+let container: HTMLDivElement
+let popup: {
+ closed: boolean
+ close: ReturnType
+ focus: ReturnType
+ opener: unknown
+ location: { href: string }
+}
+const slack: CredentialItemData = {
+ type: 'link',
+ provider: 'slack',
+ value: 'https://untrusted.example/authorize?credentialId=someone-else',
+}
+
+async function render(data: CredentialItemData[] = [slack]) {
+ await act(async () =>
+ root.render(
+
+ )
+ )
+}
+
+async function click(label: string) {
+ const button = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent === label
+ )
+ expect(button, label).toBeDefined()
+ await act(async () => button?.click())
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ window.localStorage.clear()
+ mocks.rows = []
+ mocks.fetched = true
+ mocks.metadataError = null
+ mocks.startPending = false
+ mocks.canEdit = false
+ mocks.error = null
+ mocks.desktop = false
+ mocks.refetch.mockImplementation(async () => ({ isSuccess: true, data: mocks.rows }))
+ mocks.openExternal.mockResolvedValue(true)
+ mocks.start.mockImplementation((_body, callbacks) =>
+ callbacks.onSuccess({
+ providerId: 'slack',
+ url: 'https://slack.com/oauth/v2/authorize?state=trusted-state',
+ })
+ )
+ popup = {
+ closed: false,
+ close: vi.fn(),
+ focus: vi.fn(),
+ opener: {},
+ location: { href: 'about:blank' },
+ }
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+})
+
+describe('Assistant credential card', () => {
+ it('lets readers connect through the canonical endpoint without following the model URL', async () => {
+ await render()
+ await click('Connect Slack')
+ expect(mocks.start).toHaveBeenCalledWith(
+ { workspaceId: 'workspace-1', providerId: 'slack' },
+ expect.any(Object)
+ )
+ expect(popup.location.href).toBe('https://slack.com/oauth/v2/authorize?state=trusted-state')
+ expect(popup.opener).toBeNull()
+ expect(container.querySelector('a')).toBeNull()
+ expect(mocks.workspaceCredentials).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'oauth' })
+ )
+ })
+
+ it('marks a connection complete from the personal list, closes its popup, and resumes through Submit', async () => {
+ await render()
+ await click('Connect Slack')
+ mocks.rows = [
+ {
+ id: 'owned',
+ providerId: 'slack',
+ type: 'managed_oauth',
+ displayName: 'My Slack',
+ updatedAt: new Date().toISOString(),
+ connectedAt: new Date().toISOString(),
+ },
+ ]
+ await render()
+ expect(container.textContent).toContain('Connected Slack')
+ expect(popup.close).toHaveBeenCalled()
+ await click('Submit')
+ expect(mocks.continue).toHaveBeenCalledOnce()
+ expect(mocks.continue.mock.calls[0][0]).toContain('connected')
+ })
+
+ it('does not claim an existing personal credential as this attempt completing', async () => {
+ mocks.rows = [
+ {
+ id: 'owned',
+ providerId: 'slack',
+ type: 'managed_oauth',
+ displayName: 'My Slack',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ },
+ ]
+ await render()
+ await click('Connect Slack')
+ await render()
+ expect(container.textContent).toContain('Waiting for Slack connection')
+ expect(popup.close).not.toHaveBeenCalled()
+ })
+
+ it('refreshes the baseline so a previously connected account missing from cache cannot complete the attempt', async () => {
+ const existing: PersonalCredential = {
+ id: 'owned',
+ providerId: 'slack',
+ type: 'managed_oauth',
+ displayName: 'My Slack',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ }
+ mocks.refetch.mockResolvedValue({ isSuccess: true, data: [existing] })
+ await render()
+ await click('Connect Slack')
+ mocks.rows = [existing]
+ await render()
+ expect(mocks.refetch).toHaveBeenCalledOnce()
+ expect(container.textContent).toContain('Waiting for Slack connection')
+ expect(popup.close).not.toHaveBeenCalled()
+ })
+
+ it('does not start OAuth when the fresh baseline fails and offers metadata retry', async () => {
+ mocks.refetch.mockImplementation(async () => {
+ mocks.metadataError = new Error('Could not refresh your connections')
+ return { isSuccess: false }
+ })
+ await render()
+ await click('Connect Slack')
+ await render()
+ expect(mocks.start).not.toHaveBeenCalled()
+ expect(popup.close).toHaveBeenCalledOnce()
+ expect(container.textContent).toContain('Retry checking Slack connections')
+ await click('Retry checking Slack connections')
+ expect(mocks.refetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('starts only once while the fresh metadata read is in flight', async () => {
+ let resolveFresh!: (result: { isSuccess: boolean; data: PersonalCredential[] }) => void
+ mocks.refetch.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveFresh = resolve
+ })
+ )
+ await render()
+ await click('Connect Slack')
+ await click('Connect Slack')
+ expect(mocks.refetch).toHaveBeenCalledOnce()
+ expect(window.open).toHaveBeenCalledOnce()
+ expect(mocks.start).not.toHaveBeenCalled()
+ await act(async () => resolveFresh({ isSuccess: true, data: [] }))
+ expect(mocks.start).toHaveBeenCalledOnce()
+ })
+
+ it('does not complete on background refresh, but does complete on a new verified grant', async () => {
+ const original = {
+ id: 'owned',
+ providerId: 'slack',
+ type: 'managed_oauth' as const,
+ displayName: 'My Slack',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ }
+ mocks.rows = [original]
+ await render()
+ await click('Connect Slack')
+ mocks.rows = [{ ...original, updatedAt: new Date().toISOString() }]
+ await render()
+ expect(container.textContent).toContain('Waiting for Slack connection')
+ mocks.rows = [
+ { ...original, updatedAt: new Date().toISOString(), connectedAt: new Date().toISOString() },
+ ]
+ await render()
+ expect(container.textContent).toContain('Connected Slack')
+ })
+
+ it('requires successful metadata before starting and offers retry when the read fails', async () => {
+ mocks.metadataError = new Error('Could not load your connections')
+ await render()
+ await click('Retry checking Slack connections')
+ expect(mocks.refetch).toHaveBeenCalledOnce()
+ expect(mocks.start).not.toHaveBeenCalled()
+ expect(window.open).not.toHaveBeenCalled()
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Could not load your connections'
+ )
+ })
+
+ it('rejects an insecure external OAuth URL and leaves the popup closed', async () => {
+ mocks.start.mockImplementation((_body, callbacks) =>
+ callbacks.onSuccess({ providerId: 'slack', url: 'http://untrusted.example/authorize' })
+ )
+ await render()
+ await click('Connect Slack')
+ expect(popup.location.href).toBe('about:blank')
+ expect(popup.close).toHaveBeenCalledOnce()
+ expect(container.textContent).toContain('Not connected — connect Slack')
+ })
+
+ it('keeps a failed start retryable and surfaces the server setup message', async () => {
+ mocks.start.mockImplementation((_body, callbacks) => {
+ mocks.error = new Error('Ask an admin to enable Slack')
+ callbacks.onError(mocks.error)
+ })
+ await render()
+ await click('Connect Slack')
+ expect(popup.close).toHaveBeenCalledOnce()
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Ask an admin to enable Slack'
+ )
+ expect(container.textContent).toContain('Not connected — connect Slack')
+ })
+
+ it('ends polling when a connection never completes', async () => {
+ await render()
+ await click('Connect Slack')
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(OAUTH_CHAT_ATTEMPT_MAX_AGE_MS)
+ })
+ expect(container.textContent).toContain('Not connected — connect Slack')
+ expect(mocks.list).toHaveBeenLastCalledWith('workspace-1', {
+ enabled: true,
+ refetchInterval: false,
+ })
+ expect(popup.close).toHaveBeenCalled()
+ })
+
+ it('opens OAuth in the system browser on desktop and allows a deliberate retry', async () => {
+ mocks.desktop = true
+ await render()
+ await click('Connect Slack')
+ expect(window.open).not.toHaveBeenCalled()
+ expect(mocks.openExternal).toHaveBeenCalledWith(
+ 'https://slack.com/oauth/v2/authorize?state=trusted-state'
+ )
+ await click('Waiting for Slack connection…')
+ expect(mocks.start).toHaveBeenCalledTimes(2)
+ expect(mocks.openExternal).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not allow a desktop retry while the start request is still pending', async () => {
+ mocks.desktop = true
+ mocks.start.mockImplementation(() => {
+ mocks.startPending = true
+ })
+ await render()
+ await click('Connect Slack')
+ await render()
+ await click('Waiting for Slack connection…')
+ expect(mocks.start).toHaveBeenCalledOnce()
+ expect(mocks.openExternal).not.toHaveBeenCalled()
+ })
+
+ it('focuses the live web popup and starts a fresh attempt once its handle is closed', async () => {
+ await render()
+ await click('Connect Slack')
+ await click('Waiting for Slack connection…')
+ expect(popup.focus).toHaveBeenCalledOnce()
+ expect(mocks.start).toHaveBeenCalledOnce()
+ popup.closed = true
+ await click('Waiting for Slack connection…')
+ expect(mocks.start).toHaveBeenCalledTimes(2)
+ expect(window.open).toHaveBeenCalledTimes(2)
+ })
+
+ it('uses the existing GitLab personal token modal without posting a token to the chat', async () => {
+ mocks.canEdit = true
+ await render([{ type: 'link', provider: 'gitlab' }])
+ await click('Connect GitLab')
+ await click('Finish personal token')
+ expect(mocks.start).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('Connected GitLab')
+ await click('Submit')
+ expect(mocks.continue.mock.calls[0][0]).toContain('connected')
+ })
+
+ it('does not offer GitLab token creation to a reader', async () => {
+ await render([{ type: 'link', provider: 'gitlab' }])
+ expect(container.querySelector('button')).toBeNull()
+ })
+
+ it('hides workspace secrets, service accounts and API key reveals even for editors', async () => {
+ mocks.canEdit = true
+ await render([
+ slack,
+ { type: 'secret_input', name: 'HIDDEN_SECRET' },
+ { type: 'service_account', provider: 'google-drive' },
+ { type: 'sim_key', value: 'must-never-render' },
+ ])
+ expect(container.textContent).not.toContain('HIDDEN_SECRET')
+ expect(container.textContent).not.toContain('service account')
+ expect(container.textContent).not.toContain('must-never-render')
+ expect(container.querySelector('input')).toBeNull()
+ expect(mocks.personalEnvironment).toHaveBeenCalledWith({ enabled: false })
+ expect(mocks.workspaceCredentials).toHaveBeenCalledWith(
+ expect.objectContaining({ enabled: false })
+ )
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
index 3dab938af28..a5ea29ee9d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
@@ -54,6 +54,17 @@ describe('parseCredentialTagBody', () => {
expect(parseCredentialTagBody(JSON.stringify(secret))).toEqual([secret])
})
+ it('parses provider-only Assistant connection tags while Build still requires a URL', () => {
+ const data: CredentialItemData[] = [{ type: 'link', provider: 'slack' }]
+ expect(
+ parseLastCredentialTag('{"type":"link","provider":"slack"} ')
+ ).toEqual(data)
+ expect(credentialTagHasVisibleCard(data, true, 'assistant')).toBe(true)
+ expect(credentialTagHasVisibleCard(data, true, 'agent')).toBe(false)
+ expect(parseCredentialTagBody('{"type":"link","provider":" "}')).toBeNull()
+ expect(parseCredentialTagBody('{"type":"link","provider":"slack","value":123}')).toBeNull()
+ })
+
it('preserves a mixed credential-input batch in one tag', () => {
expect(parseCredentialTagBody(JSON.stringify([secret, oauth]))).toEqual([secret, oauth])
})
@@ -122,6 +133,28 @@ describe('parseCredentialTagBody', () => {
expect(credentialTagHasVisibleCard([oauth], false)).toBe(false)
expect(credentialTagHasVisibleCard([oauth], true)).toBe(true)
})
+
+ it('offers personal integration connections to Assistant readers without trusting a model URL', () => {
+ expect(
+ credentialTagHasVisibleCard([{ type: 'link', provider: 'slack' }], false, 'assistant')
+ ).toBe(true)
+ expect(
+ credentialTagHasVisibleCard([{ type: 'link', provider: 'gitlab' }], false, 'assistant')
+ ).toBe(false)
+ })
+
+ it.each(['secret_input', 'service_account', 'sim_key'] as const)(
+ 'hides %s setup in Assistant even for a workspace editor',
+ (type) => {
+ expect(
+ credentialTagHasVisibleCard(
+ [{ type, name: 'Secret', provider: 'slack' }],
+ true,
+ 'assistant'
+ )
+ ).toBe(false)
+ }
+ )
})
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index 6fd2a7ea41a..5a3a4848347 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -1,19 +1,8 @@
'use client'
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
-import {
- ArrowRight,
- Check,
- ChevronDown,
- cn,
- Expandable,
- ExpandableContent,
- SecretReveal,
- SquareArrowUpRight,
- Tooltip,
- toast,
-} from '@sim/emcn'
-import { TerminalWindow } from '@sim/emcn/icons'
+import { cn, Expandable, ExpandableContent, SecretReveal, Tooltip, toast } from '@sim/emcn'
+import { ArrowRight, Check, ChevronDown, SquareArrowUpRight, TerminalWindow } from '@sim/emcn/icons'
import { isRecordLike } from '@sim/utils/object'
import { useParams } from 'next/navigation'
import { ThinkingLoader } from '@/components/ui'
@@ -26,6 +15,7 @@ import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt'
import { getDesktopBridge } from '@/lib/desktop'
import { desktopChatScopeId } from '@/lib/desktop/chat-scope'
+import { resolveCredentialDisplay } from '@/lib/integrations/credential-display'
import {
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
@@ -50,6 +40,7 @@ import {
resolveOAuthChipTarget,
useOAuthChipConnection,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection'
+import { usePersonalCredentialConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection'
import type {
ChatMessageContext,
MothershipResource,
@@ -111,6 +102,12 @@ const ConnectServiceAccountModal = lazy(() =>
).then((m) => ({ default: m.ConnectServiceAccountModal }))
)
+const ConnectPersonalTokenModal = lazy(() =>
+ import('@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal').then(
+ (module) => ({ default: module.ConnectPersonalTokenModal })
+ )
+)
+
export const CREDENTIAL_TAG_TYPES = [
'env_key',
'oauth_key',
@@ -523,10 +520,12 @@ function isCredentialItemData(value: unknown): value is CredentialItemData {
}
return typeof value.provider === 'string' && value.provider.trim().length > 0
}
+ if (value.type === 'link' && value.value === undefined) {
+ return typeof value.provider === 'string' && value.provider.trim().length > 0
+ }
// A sim_key chip is platform-filled: the model only marks where the workspace
// API key belongs (it never holds the value) and Sim injects it from the tool
- // result, so the tag is valid with or without a `value`. Every other rendered
- // type (e.g. link) needs a string value to render.
+ // result, so the tag is valid with or without a `value`.
if (value.type === 'sim_key') return true
return typeof value.value === 'string'
}
@@ -1716,6 +1715,7 @@ function recoverTrailingBareOptions(segments: ContentSegment[]): void {
interface SpecialTagsProps {
segment: Exclude
+ requestMode?: 'agent' | 'assistant'
/** Stable identity for interaction state owned by this message/tag. */
interactionId?: string
/** Transcript-derived answers for this message's question card (renders the recap). */
@@ -1736,6 +1736,7 @@ interface SpecialTagsProps {
*/
export function SpecialTags({
segment,
+ requestMode,
interactionId,
questionAnswers,
credentialSubmission,
@@ -1755,6 +1756,7 @@ export function SpecialTags({
return (
| null {
const lower = provider.toLowerCase()
+ if (lower === 'gitlab')
+ return resolveCredentialDisplay({
+ type: 'personal_token',
+ providerId: lower,
+ displayName: provider,
+ }).icon
const directMatch = OAUTH_PROVIDERS[lower]
if (directMatch) return directMatch.icon
@@ -1978,6 +1986,12 @@ function getCredentialIcon(provider: string): React.ComponentType<{ className?:
}
function getCredentialProviderDisplayName(provider: string): string {
+ if (provider.toLowerCase() === 'gitlab')
+ return resolveCredentialDisplay({
+ type: 'personal_token',
+ providerId: 'gitlab',
+ displayName: provider,
+ }).detailTitle
return (
getServiceConfigByProviderId(provider)?.name ??
OAUTH_PROVIDERS[provider.toLowerCase()]?.name ??
@@ -2012,6 +2026,7 @@ const LockIcon = (props: { className?: string }) => (
*/
interface CredentialControlProps {
data: CredentialItemData
+ requestMode?: 'agent' | 'assistant'
controlId?: string
embedded?: boolean
divided?: boolean
@@ -2435,6 +2450,7 @@ function ServiceAccountConnectDisplay({
onOpenChange={setOpen}
workspaceId={workspaceId}
serviceAccountProviderId={target.serviceAccountProviderId}
+ atlassianProduct={match?.providerId === 'confluence' ? 'confluence' : 'jira'}
serviceName={target.serviceName}
serviceIcon={target.serviceIcon}
credentialId={reconnectCredentialId}
@@ -2528,6 +2544,95 @@ function CredentialLinkDisplay({
)
}
+function PersonalCredentialLinkDisplay({
+ data,
+ controlId = 'credential-link',
+ embedded = false,
+ divided = false,
+ onConnected,
+}: CredentialControlProps) {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const { canEdit } = useUserPermissionsContext()
+ const [tokenModalOpen, setTokenModalOpen] = useState(false)
+ const provider = data.provider?.trim() ?? ''
+ const name = getCredentialProviderDisplayName(provider)
+ const connection = usePersonalCredentialConnection({
+ provider,
+ displayName: name,
+ controlId,
+ onConnected,
+ })
+ if (!provider || (provider.toLowerCase() === 'gitlab' && !canEdit)) return null
+ const Icon = getCredentialIcon(provider) ?? LockIcon
+ const connected = connection.status === 'connected'
+ const label = connected
+ ? `Connected ${name}`
+ : connection.hasMetadataError
+ ? `Retry checking ${name} connections`
+ : !connection.isReady
+ ? `Checking ${name} connections…`
+ : connection.status === 'pending'
+ ? `Waiting for ${name} connection…`
+ : connection.status === 'failed'
+ ? `Not connected — connect ${name}`
+ : `Connect ${name}`
+ return (
+ <>
+ {
+ if (connection.hasMetadataError) {
+ void connection.retryMetadata()
+ return
+ }
+ if (provider.toLowerCase() === 'gitlab') {
+ connection.beginPersonalToken()
+ setTokenModalOpen(true)
+ } else connection.connectOAuth()
+ }}
+ className={cn(
+ embedded
+ ? INTERACTION_CARD_ROW_CLASSES
+ : 'flex w-full items-center gap-2 rounded-2xl border border-[var(--border)] px-3 py-2.5 text-left transition-colors',
+ embedded && divided && 'border-t',
+ 'hover-hover:bg-[var(--surface-5)]'
+ )}
+ >
+
+ {label}
+ {connected ? (
+
+ ) : (
+
+ )}
+
+ {connection.error && (
+
+ {connection.error}
+
+ )}
+ {tokenModalOpen && (
+
+ {
+ setTokenModalOpen(open)
+ if (!open) connection.cancelPersonalToken()
+ }}
+ workspaceId={workspaceId}
+ onConnected={connection.connectedPersonalToken}
+ />
+
+ )}
+ >
+ )
+}
+
/**
* Inline hand-back chip rendered while a terminal handoff waits on the user —
* a command sitting on a prompt only they can answer. Without it the tool row
@@ -2581,7 +2686,17 @@ const CREDENTIAL_CARD_TYPES: ReadonlySet = new Set([
'sim_key',
])
-function isCredentialCardItemVisible(item: CredentialItemData, canEdit: boolean): boolean {
+function isCredentialCardItemVisible(
+ item: CredentialItemData,
+ canEdit: boolean,
+ requestMode?: 'agent' | 'assistant'
+): boolean {
+ if (requestMode === 'assistant')
+ return (
+ item.type === 'link' &&
+ Boolean(item.provider?.trim()) &&
+ (item.provider?.trim().toLowerCase() !== 'gitlab' || canEdit)
+ )
if (item.type === 'sim_key') return false
if (item.type === 'secret_input') return item.scope === 'personal' || canEdit
if (item.type === 'link') {
@@ -2591,16 +2706,21 @@ function isCredentialCardItemVisible(item: CredentialItemData, canEdit: boolean)
}
/** Whether a terminal credential tag produces the shared question-style card. */
-export function credentialTagHasVisibleCard(data: CredentialTagData, canEdit: boolean): boolean {
+export function credentialTagHasVisibleCard(
+ data: CredentialTagData,
+ canEdit: boolean,
+ requestMode?: 'agent' | 'assistant'
+): boolean {
return (
data.length > 0 &&
data.every((item) => CREDENTIAL_CARD_TYPES.has(item.type)) &&
- data.some((item) => isCredentialCardItemVisible(item, canEdit))
+ data.some((item) => isCredentialCardItemVisible(item, canEdit, requestMode))
)
}
function CredentialItemDisplay({
data,
+ requestMode,
controlId,
embedded = false,
divided = false,
@@ -2609,6 +2729,13 @@ function CredentialItemDisplay({
onSaved,
onConnected,
}: CredentialControlProps) {
+ if (
+ requestMode === 'assistant' &&
+ data.type !== 'link' &&
+ data.type !== 'browser_takeover' &&
+ data.type !== 'terminal_handoff'
+ )
+ return null
if (data.type === 'secret_input') {
const secretName = data.name?.trim()
if (embedded) {
@@ -2640,6 +2767,17 @@ function CredentialItemDisplay({
}
if (data.type === 'link') {
+ if (requestMode === 'assistant') {
+ return (
+
+ )
+ }
return (
>({})
const [savedSecretRows, setSavedSecretRows] = useState>(() => new Set())
const [connectedIntegrationRows, setConnectedIntegrationRows] = useState>(
@@ -2719,15 +2859,19 @@ function CredentialInputCard({
if (item.type !== 'link' && item.type !== 'service_account') continue
const index = restoreIndex++
if (item.type !== 'link') continue
- const { providerId, reconnectCredentialId } = resolveOAuthChipTarget(
- item.value,
- item.provider
- )
+ const { providerId, reconnectCredentialId } =
+ requestMode === 'assistant'
+ ? {
+ providerId:
+ resolveOAuthServiceForSlug(item.provider ?? '')?.providerId ?? item.provider ?? '',
+ reconnectCredentialId: undefined,
+ }
+ : resolveOAuthChipTarget(item.value, item.provider)
if (!providerId) continue
const attempt = readLatestOAuthChatAttempt({
workspaceId,
providerId,
- controlId: `${controlIdPrefix}:${dataIndex}`,
+ controlId: `${requestMode === 'assistant' ? 'personal:' : ''}${controlIdPrefix}:${dataIndex}`,
credentialId: reconnectCredentialId,
})
if (attempt?.status === 'connected') restored.add(index)
@@ -2737,7 +2881,7 @@ function CredentialInputCard({
if (Array.from(restored).every((index) => current.has(index))) return current
return new Set([...current, ...restored])
})
- }, [abandoned, controlIdPrefix, data, workspaceId])
+ }, [abandoned, controlIdPrefix, data, workspaceId, requestMode])
let integrationIndex = 0
let secretIndex = 0
@@ -2748,7 +2892,9 @@ function CredentialInputCard({
item.type === 'link' || item.type === 'service_account' ? integrationIndex++ : undefined,
secretIndex: item.type === 'secret_input' ? secretIndex++ : undefined,
}))
- const visibleRows = indexedRows.filter(({ item }) => isCredentialCardItemVisible(item, canEdit))
+ const visibleRows = indexedRows.filter(({ item }) =>
+ isCredentialCardItemVisible(item, canEdit, requestMode)
+ )
if (visibleRows.length === 0) return null
const integrationRows = visibleRows.filter(
@@ -2766,6 +2912,7 @@ function CredentialInputCard({
0}
@@ -2920,12 +3067,14 @@ function CredentialInputCard({
export function CredentialDisplay({
data,
+ requestMode,
interactionId,
submitted,
abandoned,
onContinue,
}: {
data: CredentialTagData
+ requestMode?: 'agent' | 'assistant'
interactionId?: string
submitted?: CredentialSubmissionPayload
abandoned?: boolean
@@ -2940,7 +3089,9 @@ export function CredentialDisplay({
// pairing) stay stable — the card simply renders no sim_key rows.
const simKeyReveals = data
.map((item, index) =>
- item.type === 'sim_key' ? : null
+ item.type === 'sim_key' && requestMode !== 'assistant' ? (
+
+ ) : null
)
.filter(Boolean)
const inputItems = data.filter((item) => item.type !== 'sim_key')
@@ -2949,6 +3100,7 @@ export function CredentialDisplay({
const inputControls = usesCredentialCard ? (
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
index 9829532420e..5ab1b986952 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
@@ -21,6 +21,7 @@ import {
setOAuthChatAttemptStatus,
} from '@/lib/credentials/oauth-chat-attempt'
import { getDesktopBridge } from '@/lib/desktop'
+import { isAppSurfacePath } from '@/lib/navigation/paths'
import type { OAuthProvider } from '@/lib/oauth/types'
import { parseProvider, providerIdsForService } from '@/lib/oauth/utils'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
@@ -38,12 +39,21 @@ const OAUTH_POPUP_POLL_INTERVAL_MS = 400
const OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS = 10 * 60 * 1000
/**
- * Same-origin pages an OAuth flow can die on without reaching the return leg —
* Better Auth sends pre-state failures (usually a denied consent) to its global
- * error page, and the custom-provider callbacks exit to the workspace root.
- * Neither publishes a verdict, so a popup sitting on one is finished.
+ * error page, which publishes no verdict.
+ */
+const OAUTH_ERROR_PATH = '/oauth-error'
+
+/**
+ * Same-origin pages an OAuth flow can die on without reaching the return leg —
+ * the Better Auth error page, or anywhere in the signed-in app, which is where the
+ * custom-provider callbacks exit to. The app entry forwards on the server to the
+ * organization or a workspace, so any app surface counts, not just the entry
+ * itself. None of them publishes a verdict, so a popup sitting on one is finished.
*/
-const OAUTH_POPUP_TERMINAL_PATHS = new Set(['/oauth-error', '/workspace'])
+function isOAuthPopupTerminalPath(pathname: string): boolean {
+ return pathname === OAUTH_ERROR_PATH || isAppSurfacePath(pathname)
+}
/**
* What the opener can actually prove about a popup it launched. `ended` needs
@@ -64,7 +74,7 @@ function observePopup(popup: { window: Window } | null): PopupObservation {
if (closed) return 'unobservable'
try {
const { origin, pathname } = popup.window.location
- if (origin === window.location.origin && OAUTH_POPUP_TERMINAL_PATHS.has(pathname)) {
+ if (origin === window.location.origin && isOAuthPopupTerminalPath(pathname)) {
return 'ended'
}
} catch {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection.ts
new file mode 100644
index 00000000000..8f6552b07ae
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection.ts
@@ -0,0 +1,241 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
+import { useParams } from 'next/navigation'
+import type { PersonalCredential } from '@/lib/api/contracts/credentials'
+import {
+ createOAuthChatAttempt,
+ getOAuthCredentialBaseline,
+ hasOAuthCredentialChanged,
+ OAUTH_CHAT_ATTEMPT_EVENT,
+ OAUTH_CHAT_ATTEMPT_MAX_AGE_MS,
+ type OAuthChatAttempt,
+ readLatestOAuthChatAttempt,
+ setOAuthChatAttemptStatus,
+} from '@/lib/credentials/oauth-chat-attempt'
+import { getDesktopBridge } from '@/lib/desktop'
+import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service'
+import {
+ usePersonalCredentials,
+ useStartPersonalCredentialConnection,
+} from '@/hooks/queries/personal-credentials'
+
+interface PersonalCredentialConnectionProps {
+ provider: string
+ controlId: string
+ displayName: string
+ onConnected?: () => void
+}
+
+function grantedCredentials(credentials: readonly PersonalCredential[]) {
+ return credentials.map(({ id, providerId, connectedAt }) => ({
+ id,
+ providerId,
+ updatedAt: connectedAt,
+ }))
+}
+
+/** Personal card attempts observe only the caller's credentials, including enrollment OAuth. */
+export function usePersonalCredentialConnection({
+ provider,
+ controlId,
+ displayName,
+ onConnected,
+}: PersonalCredentialConnectionProps) {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const providerId = resolveOAuthServiceForSlug(provider)?.providerId ?? provider.toLowerCase()
+ const personalControlId = `personal:${controlId}`
+ const [attempt, setAttempt] = useState(() =>
+ readLatestOAuthChatAttempt({ workspaceId, providerId, controlId: personalControlId })
+ )
+ const pending = attempt?.status === 'pending'
+ const credentials = usePersonalCredentials(workspaceId, {
+ enabled: Boolean(providerId),
+ refetchInterval: pending && providerId !== 'gitlab' ? 1_500 : false,
+ })
+ const start = useStartPersonalCredentialConnection()
+ const popup = useRef(null)
+ const starting = useRef(false)
+ const onConnectedRef = useRef(onConnected)
+ onConnectedRef.current = onConnected
+
+ useEffect(() => {
+ const refresh = () =>
+ setAttempt(
+ readLatestOAuthChatAttempt({
+ workspaceId,
+ providerId,
+ controlId: personalControlId,
+ })
+ )
+ window.addEventListener(OAUTH_CHAT_ATTEMPT_EVENT, refresh)
+ window.addEventListener('storage', refresh)
+ refresh()
+ return () => {
+ window.removeEventListener(OAUTH_CHAT_ATTEMPT_EVENT, refresh)
+ window.removeEventListener('storage', refresh)
+ }
+ }, [workspaceId, providerId, personalControlId])
+
+ useEffect(() => {
+ if (
+ !attempt ||
+ attempt.status !== 'pending' ||
+ providerId === 'gitlab' ||
+ !credentials.isSuccess ||
+ start.isPending
+ )
+ return
+ const records = grantedCredentials(credentials.data)
+ const changed = hasOAuthCredentialChanged(attempt, records)
+ const baselineGrantedAt = Date.parse(attempt.baselineCredentialUpdatedAt ?? '')
+ const refreshed =
+ Number.isFinite(baselineGrantedAt) &&
+ records.some(
+ (credential) =>
+ credential.providerId === providerId &&
+ Date.parse(credential.updatedAt) > baselineGrantedAt
+ )
+ if (changed || refreshed) setOAuthChatAttemptStatus(attempt.id, 'connected')
+ }, [attempt, credentials.data, credentials.isSuccess, providerId, start.isPending])
+
+ useEffect(() => {
+ if (!attempt || attempt.status !== 'pending') return
+ const timeout = window.setTimeout(
+ () => {
+ popup.current?.close()
+ popup.current = null
+ setOAuthChatAttemptStatus(attempt.id, 'failed')
+ },
+ Math.max(0, attempt.requestedAt + OAUTH_CHAT_ATTEMPT_MAX_AGE_MS - Date.now())
+ )
+ return () => window.clearTimeout(timeout)
+ }, [attempt])
+
+ useEffect(() => {
+ if (attempt?.status !== 'connected') return
+ popup.current?.close()
+ popup.current = null
+ onConnectedRef.current?.()
+ }, [attempt?.status])
+
+ const beginAttempt = useCallback(
+ (records: readonly PersonalCredential[] = credentials.data ?? []) => {
+ const rows = grantedCredentials(records)
+ const target = { providerId, baseProviderId: providerId }
+ const latestUpdate = rows.reduce(
+ (latest, row) =>
+ row.providerId === providerId ? Math.max(latest, Date.parse(row.updatedAt) || 0) : latest,
+ 0
+ )
+ const next = createOAuthChatAttempt({
+ workspaceId,
+ providerId,
+ baseProviderId: providerId,
+ displayName,
+ controlId: personalControlId,
+ ...getOAuthCredentialBaseline(target, rows),
+ baselineCredentialUpdatedAt: new Date(latestUpdate).toISOString(),
+ })
+ setAttempt(next)
+ return next
+ },
+ [credentials.data, workspaceId, providerId, displayName, personalControlId]
+ )
+
+ const connectOAuth = useCallback(async () => {
+ if (starting.current || !credentials.isSuccess || start.isPending) return
+ const desktop = getDesktopBridge()
+ if (pending && popup.current && !popup.current.closed) {
+ popup.current.focus()
+ return
+ }
+ const tab = desktop?.openExternal
+ ? null
+ : window.open('about:blank', '_blank', 'width=600,height=700')
+ if (!tab && !desktop?.openExternal) {
+ toast.error('Allow pop-ups to connect your account.')
+ return
+ }
+ if (tab) tab.opener = null
+ popup.current = tab
+ starting.current = true
+ const fresh = await credentials.refetch({ cancelRefetch: false })
+ if (!fresh.isSuccess || !fresh.data) {
+ tab?.close()
+ popup.current = null
+ starting.current = false
+ return
+ }
+ const next = beginAttempt(fresh.data)
+ start.mutate(
+ { workspaceId, providerId },
+ {
+ onSuccess: ({ url }) => {
+ starting.current = false
+ const target = new URL(url, window.location.origin)
+ if (
+ target.protocol !== 'https:' &&
+ !(target.protocol === 'http:' && target.origin === window.location.origin)
+ ) {
+ tab?.close()
+ popup.current = null
+ setOAuthChatAttemptStatus(next.id, 'failed')
+ return
+ }
+ if (desktop?.openExternal) {
+ void desktop
+ .openExternal(target.href)
+ .then((opened) => {
+ if (!opened) setOAuthChatAttemptStatus(next.id, 'failed')
+ })
+ .catch(() => setOAuthChatAttemptStatus(next.id, 'failed'))
+ } else if (tab && !tab.closed) tab.location.href = target.href
+ },
+ onError: () => {
+ starting.current = false
+ tab?.close()
+ popup.current = null
+ setOAuthChatAttemptStatus(next.id, 'failed')
+ },
+ }
+ )
+ }, [
+ credentials.isSuccess,
+ credentials.refetch,
+ start.isPending,
+ start.mutate,
+ pending,
+ beginAttempt,
+ workspaceId,
+ providerId,
+ ])
+
+ const connectedPersonalToken = useCallback(() => {
+ if (attempt) setOAuthChatAttemptStatus(attempt.id, 'connected')
+ void credentials.refetch()
+ }, [attempt, credentials.refetch])
+
+ const cancelPersonalToken = useCallback(() => {
+ const current = readLatestOAuthChatAttempt({
+ workspaceId,
+ providerId,
+ controlId: personalControlId,
+ })
+ if (current?.status === 'pending') setOAuthChatAttemptStatus(current.id, 'failed')
+ }, [workspaceId, providerId, personalControlId])
+
+ return {
+ isReady: credentials.isSuccess,
+ hasMetadataError: credentials.isError,
+ retryMetadata: credentials.refetch,
+ isStarting: start.isPending || (starting.current && credentials.isFetching),
+ status: attempt?.status ?? null,
+ error: start.error?.message ?? credentials.error?.message,
+ connectOAuth,
+ beginPersonalToken: beginAttempt,
+ connectedPersonalToken,
+ cancelPersonalToken,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index 8f2df7c9f49..99683295ec0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -22,6 +22,7 @@ import {
} from '@/lib/copilot/tools/tool-display'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
@@ -818,6 +819,7 @@ interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
messageId?: string
+ requestMode?: 'agent' | 'assistant'
isStreaming: boolean
/**
* True for the last message in the transcript. The last turn keeps a
@@ -848,6 +850,7 @@ function MessageContentInner({
blocks,
fallbackContent,
messageId,
+ requestMode,
isStreaming = false,
isLast = false,
questionAnswers,
@@ -860,9 +863,13 @@ function MessageContentInner({
}: MessageContentProps) {
const { onWorkspaceResourceSelect } = useChatSurface()
const blockOverlayVersion = useCustomBlockOverlayVersion()
+ const cited = useMemo(
+ () => resolveMessageCitations(blocks, fallbackContent, requestMode === 'assistant'),
+ [blocks, fallbackContent, requestMode]
+ )
const parsed = useMemo(
- () => (blocks.length > 0 ? parseBlocks(blocks) : []),
- [blocks, blockOverlayVersion]
+ () => (cited.blocks.length > 0 ? parseBlocks(cited.blocks) : []),
+ [cited.blocks, blockOverlayVersion]
)
const [trailingRevealing, setTrailingRevealing] = useState(false)
@@ -883,10 +890,10 @@ function MessageContentInner({
() =>
parsed.length > 0
? parsed
- : fallbackContent?.trim()
- ? [{ type: 'text', id: 'text-fallback', content: fallbackContent }]
+ : cited.fallbackContent?.trim()
+ ? [{ type: 'text', id: 'text-fallback', content: cited.fallbackContent }]
: [],
- [parsed, fallbackContent]
+ [parsed, cited.fallbackContent]
)
/**
* Collected from the segments that render, not the raw blocks: that is the
@@ -976,6 +983,7 @@ function MessageContentInner({
key={segment.id}
content={segment.content}
messageId={messageId}
+ requestMode={requestMode}
isStreaming={shouldSmoothTextSegment({
isStreaming,
segmentIndex: i,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts
new file mode 100644
index 00000000000..f9cb6f66bbe
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts
@@ -0,0 +1,87 @@
+/** @vitest-environment node */
+import { describe, expect, it } from 'vitest'
+import { compactRetrievalCitations } from '@/lib/copilot/chat/retrieval-citations'
+import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
+import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
+
+const output = {
+ success: true,
+ data: {
+ results: [
+ {
+ citationId: 'document:a',
+ citationUrl: 'https://docs.example.test/a',
+ documentName: 'Actual title',
+ content: 'Retrieved passage',
+ },
+ ],
+ },
+}
+function blocks(result: unknown = output): ContentBlock[] {
+ return [
+ {
+ type: 'tool_call',
+ toolCall: {
+ id: 'call',
+ name: 'search_workspace',
+ status: 'success',
+ result: { success: true, output: result },
+ },
+ },
+ {
+ type: 'text',
+ content: 'Answer {"id":"document:a","url":"https://forged.test"} ',
+ },
+ ]
+}
+describe('evidence-linked citations', () => {
+ it('uses returned metadata and escapes source-tag terminators', () => {
+ const result = resolveMessageCitations(blocks(), '', true)
+ expect(result.blocks[1].content).toContain('Actual title')
+ expect(result.blocks[1].content).toContain('https://docs.example.test/a')
+ expect(result.blocks[1].content).not.toContain('forged')
+ const hostile = structuredClone(output)
+ hostile.data.results[0].documentName = '{"url":"https://forged.test"} '
+ expect(
+ resolveMessageCitations(blocks(hostile), '', true).blocks[1].content?.match(//g)
+ ).toHaveLength(1)
+ })
+ it('rejects invented IDs, model URLs, and failed retrievals in Assistant', () => {
+ expect(
+ resolveMessageCitations(
+ [],
+ '{"id":"missing"} {"url":"https://forged.test"} ',
+ true
+ ).fallbackContent
+ ).toBe('')
+ const failed = blocks()
+ failed[0].toolCall!.status = 'error'
+ expect(resolveMessageCitations(failed, '', true).blocks[1].content).toBe('Answer ')
+ })
+ it('resolves evidence after large tool outputs are compacted', () => {
+ expect(
+ resolveMessageCitations(
+ blocks(compactRetrievalCitations('search_workspace', output)),
+ '',
+ true
+ ).blocks[1].content
+ ).toEqual(resolveMessageCitations(blocks(), '', true).blocks[1].content)
+ })
+ it('resolves source tags split across streamed text chunks before rendering', () => {
+ const split = blocks().slice(0, 1)
+ split.push(
+ { type: 'text', content: 'Answer {"id":"document:a"}' },
+ { type: 'text', content: ' ' }
+ )
+ const result = resolveMessageCitations(split, '', true)
+ expect(result.blocks).toHaveLength(2)
+ expect(result.blocks[1].content).toContain('Actual title')
+ expect(result.blocks[1].content).not.toContain('"id"')
+ })
+
+ it('keeps Build web citations', () => {
+ const text = '{"url":"https://web.test"} '
+ expect(resolveMessageCitations([], text).fallbackContent).toBe(text)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts
new file mode 100644
index 00000000000..34687044324
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.ts
@@ -0,0 +1,100 @@
+import { isRecordLike } from '@sim/utils/object'
+import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
+
+function parseRecord(value: unknown): Record | null {
+ if (typeof value === 'string') {
+ try {
+ return parseRecord(JSON.parse(value))
+ } catch {
+ return null
+ }
+ }
+ return isRecordLike(value) ? value : null
+}
+
+/** Source cards use metadata from successful retrieval, never model-authored IDs or URLs. */
+export function resolveMessageCitations(
+ blocks: readonly ContentBlock[],
+ fallbackContent: string,
+ requireEvidence = false
+) {
+ const evidence = new Map>()
+ for (const block of blocks) {
+ const call = block.toolCall
+ if (
+ !call ||
+ !['search_workspace', 'read_document'].includes(call.name) ||
+ call.status !== 'success' ||
+ !call.result?.success
+ )
+ continue
+ const output = parseRecord(call.result.output)
+ if (!output || output.success === false) continue
+ const data = parseRecord(output.data) ?? output
+ const results = Array.isArray(data.results) ? data.results : [data]
+ for (const raw of results) {
+ const result = parseRecord(raw)
+ if (
+ !result ||
+ typeof result.citationId !== 'string' ||
+ typeof result.citationUrl !== 'string'
+ )
+ continue
+ try {
+ const url = new URL(result.citationUrl)
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') continue
+ } catch {
+ continue
+ }
+ if (evidence.has(result.citationId)) continue
+ evidence.set(result.citationId, {
+ url: result.citationUrl,
+ ...(typeof result.documentName === 'string' ? { title: result.documentName } : {}),
+ ...(typeof result.knowledgeBaseName === 'string'
+ ? { siteName: result.knowledgeBaseName }
+ : {}),
+ ...(typeof result.connectorType === 'string'
+ ? { connectorType: result.connectorType }
+ : {}),
+ ...(typeof result.author === 'string' ? { author: result.author } : {}),
+ ...(typeof result.sourceModifiedAt === 'string'
+ ? { updatedAt: result.sourceModifiedAt }
+ : {}),
+ ...(typeof result.content === 'string' ? { snippet: result.content.slice(0, 500) } : {}),
+ })
+ }
+ }
+ function resolve(text: string) {
+ return text.replace(/\s*([\s\S]*?)\s*<\/source>/g, (tag, json: string) => {
+ const source = parseRecord(json)
+ if (!source || !Object.hasOwn(source, 'id')) return requireEvidence ? '' : tag
+ const resolved = typeof source.id === 'string' ? evidence.get(source.id) : undefined
+ return resolved
+ ? `${JSON.stringify(resolved).replaceAll('<', '\\u003c')} `
+ : ''
+ })
+ }
+ const textRuns: ContentBlock[] = []
+ for (const block of blocks) {
+ const previous = textRuns.at(-1)
+ if (
+ previous &&
+ block.content &&
+ previous.content &&
+ (block.type === 'text' || block.type === 'subagent_text') &&
+ previous.type === block.type &&
+ previous.spanId === block.spanId &&
+ previous.parentSpanId === block.parentSpanId &&
+ previous.parentToolCallId === block.parentToolCallId &&
+ previous.subagent === block.subagent
+ ) {
+ textRuns[textRuns.length - 1] = { ...previous, content: previous.content + block.content }
+ } else textRuns.push(block)
+ }
+ return {
+ blocks: textRuns.map((block) =>
+ block.content ? { ...block, content: resolve(block.content) } : block
+ ),
+ fallbackContent: resolve(fallbackContent),
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 1e0f8656a43..d84c76b2685 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -34,7 +34,10 @@ import {
parseLastCredentialTag,
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
-import { prepareCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
+import {
+ prepareCopyableMarkdown,
+ toCopyableMarkdown,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
@@ -51,7 +54,7 @@ import type {
QueuedMessage,
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { useOptionalWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getWorkspaceFilesQueryOptions, workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
@@ -59,7 +62,8 @@ import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
interface MothershipChatProps {
- workspaceId: string
+ workspaceId?: string
+ composer?: ReactNode
messages: ChatMessage[]
isSending: boolean
/** The composer's Search-mode results, shown above the input. */
@@ -212,6 +216,7 @@ interface AssistantMessageRowProps {
isStreaming: boolean
isLast: boolean
precedingUserContent: string | undefined
+ requestMode?: ChatMessage['requestMode']
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
/** Transcript-derived status payload for this message's credential card. */
@@ -229,6 +234,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
isStreaming,
isLast,
precedingUserContent,
+ requestMode,
questionAnswers,
credentialSubmission,
credentialAbandoned,
@@ -236,7 +242,8 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
onOptionSelect,
onAnimatingChange,
}: AssistantMessageRowProps) {
- const { canEdit } = useUserPermissionsContext()
+ const permissions = useOptionalWorkspacePermissionsContext()
+ const canEdit = permissions?.userPermissions.canEdit ?? false
const blocks = message.contentBlocks ?? EMPTY_BLOCKS
const hasAnyBlocks = blocks.length > 0
const trimmedContent = message.content?.trim() ?? ''
@@ -266,7 +273,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
const endsWithCredential = trimmedContent.endsWith('')
const trailingCredentials = endsWithCredential ? parseLastCredentialTag(trimmedContent) : null
const showsCredentialCard = trailingCredentials
- ? credentialTagHasVisibleCard(trailingCredentials, canEdit)
+ ? credentialTagHasVisibleCard(trailingCredentials, canEdit, message.requestMode ?? requestMode)
: false
const questionTag = endsWithQuestion
? trimmedContent.slice(trimmedContent.lastIndexOf(''))
@@ -296,6 +303,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
- prepareCopyableMarkdown(
- content,
- queryClient.getQueryData(
- workspaceFilesKeys.list(workspaceId)
- ) ?? EMPTY_WORKSPACE_FILES,
- () =>
- queryClient.fetchQuery({
- ...getWorkspaceFilesQueryOptions(workspaceId),
- staleTime: 0,
- })
- ),
+ workspaceId
+ ? prepareCopyableMarkdown(
+ content,
+ queryClient.getQueryData(
+ workspaceFilesKeys.list(workspaceId)
+ ) ?? EMPTY_WORKSPACE_FILES,
+ () =>
+ queryClient.fetchQuery({
+ ...getWorkspaceFilesQueryOptions(workspaceId),
+ staleTime: 0,
+ })
+ )
+ : toCopyableMarkdown(content),
[queryClient, workspaceId]
)
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
@@ -565,12 +576,12 @@ export function MothershipChat({
return out
}, [messages])
- const precedingUserContentByIndex = useMemo(() => {
- const out: Array = []
- let lastUserContent: string | undefined
+ const precedingUserByIndex = useMemo(() => {
+ const out: Array = []
+ let lastUser: ChatMessage | undefined
for (const [index, message] of messages.entries()) {
- out[index] = lastUserContent
- if (message.role === 'user') lastUserContent = message.content
+ out[index] = lastUser
+ if (message.role === 'user') lastUser = message
}
return out
}, [messages])
@@ -822,7 +833,8 @@ export function MothershipChat({
prepareContentForCopy={prepareContentForCopy}
isStreaming={isStreamActive && isLast}
isLast={isLast}
- precedingUserContent={precedingUserContentByIndex[index]}
+ precedingUserContent={precedingUserByIndex[index]?.content}
+ requestMode={precedingUserByIndex[index]?.requestMode}
questionAnswers={interactionPairing.answersByIndex[index]}
credentialSubmission={interactionPairing.credentialSubmissionByIndex[index]}
credentialAbandoned={interactionPairing.credentialAbandonedByIndex[index]}
@@ -855,21 +867,24 @@ export function MothershipChat({
onEdit={handleEditQueued}
onCancelEdit={onCancelQueueEdit}
/>
-
+ {!isLoading &&
+ (composer ?? (
+
+ ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx
new file mode 100644
index 00000000000..7ae8ef92de4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.test.tsx
@@ -0,0 +1,177 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WorkspaceMemberConnector } from '@/lib/api/contracts/knowledge/connectors'
+
+const mocks = vi.hoisted(() => ({
+ rows: vi.fn(),
+ admin: vi.fn(),
+ enabled: vi.fn(),
+ connect: vi.fn(),
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useOptionalWorkspaceHostContext: () => ({ features: { knowledgeMemberAccess: mocks.enabled() } }),
+}))
+vi.mock('@/hooks/queries/workspace', () => ({
+ useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.admin() } } }),
+}))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ integrationAvailability: new Map([
+ ['slack', { oauthAvailable: true, state: 'ready' }],
+ ['slack_v2', { oauthAvailable: true, state: 'ready' }],
+ ]),
+ oauthServiceAvailability: new Map(
+ [
+ 'confluence',
+ 'google-drive',
+ 'google_drive',
+ 'google-email',
+ 'google-calendar',
+ 'jira',
+ 'github-repositories',
+ ].map((providerId) => [providerId, true])
+ ),
+ isIntegrationAvailabilityReady: true,
+ isIntegrationAvailabilityLoading: false,
+ integrationAvailabilityError: null,
+ refetchIntegrationAvailability: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ useWorkspaceMemberConnectors: () => ({ data: mocks.rows() }),
+ memberConnectorKeys: { list: (id: string) => ['member-connectors', id] },
+}))
+vi.mock('@/hooks/use-member-enrollment', () => ({
+ CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']),
+ useMemberEnrollment: () => ({
+ connectSearchSource: mocks.connect,
+ isAwaiting: () => false,
+ isAwaitingSource: () => false,
+ isPending: false,
+ setupConnector: null,
+ }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal', () => ({
+ SourceSetupModal: () => null,
+}))
+vi.mock('@/lib/integrations/credential-display', () => ({
+ getIntegrationsForCredentialProvider: () => [],
+}))
+vi.mock('@/lib/oauth', () => ({
+ getCanonicalScopesForProvider: () => [],
+ getServiceConfigByProviderId: () => undefined,
+ getServiceConfigByServiceId: (id: string) => ({ providerId: id, name: id, icon: () => null }),
+}))
+vi.mock('@/connectors/registry', () => ({
+ CONNECTOR_META_REGISTRY: Object.fromEntries(
+ ['confluence', 'google_drive', 'slack'].map((id) => [
+ id,
+ {
+ id,
+ name: id,
+ search: true,
+ icon: () => null,
+ auth: { mode: 'oauth', provider: id },
+ permissionScopedListing: { capFieldIds: [] },
+ configFields: [],
+ },
+ ])
+ ),
+}))
+
+import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources/search-sources'
+
+let container: HTMLDivElement
+let root: Root
+const source = (overrides: Partial = {}): WorkspaceMemberConnector => ({
+ knowledgeBaseId: 'canonical-index',
+ knowledgeBaseName: 'Renamed company index',
+ knowledgeBaseIsSearchIndex: true,
+ connectorId: 'source-one',
+ connectorType: 'confluence',
+ sourceDescription: 'company.atlassian.net · ENG',
+ memberSyncStatus: 'idle',
+ viewerMembership: 'not_enrolled',
+ viewerDocumentCount: 0,
+ ...overrides,
+})
+function mount(rows: WorkspaceMemberConnector[]) {
+ mocks.rows.mockReturnValue(rows)
+ act(() => root.render( ))
+}
+function chips() {
+ return [...container.querySelectorAll('button')]
+}
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.admin.mockReturnValue(false)
+ mocks.enabled.mockReturnValue(true)
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('home Search source connections', () => {
+ it('lets a reader connect a configured source after the canonical index is renamed', () => {
+ const connection = source()
+ mount([connection])
+ const chip = chips().find((button) => button.textContent === 'confluence')!
+ expect(chip.disabled).toBe(false)
+ act(() => chip.click())
+ expect(mocks.connect).toHaveBeenCalledWith(
+ 'workspace',
+ expect.objectContaining({ type: 'confluence' }),
+ connection
+ )
+ expect(chips().find((button) => button.textContent === 'google_drive')?.disabled).toBe(true)
+ })
+
+ it('keeps distinct configured sites visible and connects only the selected source', () => {
+ const first = source({ viewerMembership: 'connected', viewerDocumentCount: 2 })
+ const second = source({
+ connectorId: 'source-two',
+ sourceDescription: 'other.atlassian.net · OPS',
+ })
+ mount([first, second])
+ expect(container.textContent).toContain('company.atlassian.net · ENG')
+ expect(container.textContent).toContain('other.atlassian.net · OPS')
+ const chip = chips().find((button) => button.textContent?.includes('other.atlassian.net'))!
+ act(() => chip.click())
+ expect(mocks.connect).toHaveBeenCalledExactlyOnceWith(
+ 'workspace',
+ expect.objectContaining({ type: 'confluence' }),
+ second
+ )
+ })
+
+ it('does not use a same-named ordinary knowledge base as the canonical index', () => {
+ mount([source({ knowledgeBaseIsSearchIndex: false, knowledgeBaseName: 'Sim Search' })])
+ expect(chips().every((button) => button.disabled)).toBe(true)
+ expect(mocks.connect).not.toHaveBeenCalled()
+ })
+
+ it('does not offer stale cached connections after member access is disabled', () => {
+ mocks.enabled.mockReturnValue(false)
+ mount([source({ viewerMembership: 'connected', viewerDocumentCount: 99 })])
+ expect(container.textContent).not.toContain('99 documents')
+ expect(chips().every((button) => button.disabled)).toBe(true)
+ })
+
+ it.each(['revoked', 'unverified_email'] as const)(
+ 'does not re-enroll an account with %s access',
+ (viewerMembership) => {
+ mount([source({ viewerMembership })])
+ const chip = chips().find((button) => button.textContent?.startsWith('confluence'))!
+ expect(chip.getAttribute('aria-disabled')).toBe('true')
+ act(() => chip.click())
+ expect(mocks.connect).not.toHaveBeenCalled()
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
index 0a7a80d06a7..de1e9194d8c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx
@@ -1,13 +1,13 @@
'use client'
import { useMemo } from 'react'
-import { Chip, chipContentGap, cn } from '@sim/emcn'
+import { Chip, chipContentGap, cn, OverflowText } from '@sim/emcn'
import { Loader, Plus } from '@sim/emcn/icons'
+import { groupSearchConnections } from '@/lib/sim-search/connections'
import {
canConnectPersonally,
SEARCH_CONNECTORS,
type SearchConnector,
- SIM_SEARCH_KNOWLEDGE_BASE_NAME,
searchConnectorUnavailableReason,
} from '@/lib/sim-search/connectors'
import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
@@ -29,18 +29,6 @@ const PERSONAL_SEARCH_CONNECTORS = SEARCH_CONNECTORS.filter((connector) =>
canConnectPersonally(connector.meta)
)
-/** The Sim Search connection per source, keyed by connector type. */
-function simSearchConnectionsByType(
- connectors: readonly WorkspaceMemberConnector[]
-): Map {
- const byType = new Map()
- for (const connector of connectors) {
- if (connector.knowledgeBaseName !== SIM_SEARCH_KNOWLEDGE_BASE_NAME) continue
- if (!byType.has(connector.connectorType)) byType.set(connector.connectorType, connector)
- }
- return byType
-}
-
/** Whether a connected source is still indexing for the viewer. */
export function isIndexing(connection: WorkspaceMemberConnector | undefined): boolean {
return (
@@ -77,6 +65,7 @@ function sourceState(
interface SourceChipProps {
connector: SearchConnector
connection: WorkspaceMemberConnector | undefined
+ showSource: boolean
/** Why the source cannot be connected here, shown as the chip's title; null when it can. */
unavailableReason: string | null
waiting: boolean
@@ -87,6 +76,7 @@ interface SourceChipProps {
function SourceChip({
connector,
connection,
+ showSource,
unavailableReason,
waiting,
disabled,
@@ -99,9 +89,11 @@ function SourceChip({
!unavailable &&
!waiting &&
(!connection || CONNECTABLE_MEMBERSHIPS.has(connection.viewerMembership))
- const title =
- unavailableReason ??
- (connected ? `${connector.meta.name}: ${state}` : `Connect ${connector.meta.name}`)
+ const name =
+ showSource && connection?.sourceDescription
+ ? `${connector.meta.name} · ${connection.sourceDescription}`
+ : connector.meta.name
+ const title = unavailableReason ?? (connected ? `${name}: ${state}` : `Connect ${name}`)
const busy = waiting || isIndexing(connection)
return (
- {connector.meta.name}
+
{state && {state} }
@@ -139,7 +131,8 @@ interface SearchSourcesProps {
* as workspace connectors do not appear here.
*/
export function SearchSources({ workspaceId }: SearchSourcesProps) {
- const { integrationAvailability } = usePermissionConfig()
+ const { integrationAvailability, oauthServiceAvailability, isIntegrationAvailabilityReady } =
+ usePermissionConfig()
/** With per-member access off, a connect is refused, so the chips say so instead. */
const memberAccessAvailable = useMemberAccessAvailable()
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId)
@@ -152,8 +145,8 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
const memberConnectors = memberAccessAvailable
? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS)
: EMPTY_MEMBER_CONNECTORS
- const connectionByType = useMemo(
- () => simSearchConnectionsByType(memberConnectors),
+ const { connectionByType } = useMemo(
+ () => groupSearchConnections(memberConnectors),
[memberConnectors]
)
const connectedConnectorIds = useMemo(
@@ -179,7 +172,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
/** Connected sources first; the catalog is already alphabetical, so the partition keeps the order. */
const isConnected = (connector: SearchConnector) =>
- connectionByType.get(connector.type)?.viewerMembership === 'connected'
+ connectionByType.get(connector.type)?.some((source) => source.viewerMembership === 'connected')
const ordered = [
...PERSONAL_SEARCH_CONNECTORS.filter(isConnected),
...PERSONAL_SEARCH_CONNECTORS.filter((connector) => !isConnected(connector)),
@@ -188,17 +181,24 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
return (
- {ordered.map((connector) => {
- const connection = connectionByType.get(connector.type)
- return (
+ {ordered.flatMap((connector) => {
+ const connections = connectionByType.get(connector.type) ?? []
+ return (connections.length ? connections : [undefined]).map((connection) => (
1}
unavailableReason={searchConnectorUnavailableReason(
connector,
integrationAvailability,
- { memberAccessAvailable, hasConnection: connection !== undefined, canCreate }
+ {
+ memberAccessAvailable,
+ hasConnection: connection !== undefined,
+ canCreate,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ }
)}
waiting={
connection ? isAwaiting(connection.connectorId) : isAwaitingSource(connector.type)
@@ -206,13 +206,15 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
disabled={isPending}
onConnect={() => connectSearchSource(workspaceId, connector, connection)}
/>
- )
+ ))
})}
{error &&
{error}
}
{setupConnector && (
connectSource(workspaceId, setupConnector.type, sourceConfig)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx
index 3537ebec9ed..4b73d5b989c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx
@@ -13,6 +13,8 @@ import type { SearchConnector } from '@/lib/sim-search/connectors'
interface SourceSetupModalProps {
connector: SearchConnector
onClose: () => void
+ isPending?: boolean
+ error?: string | null
/** Connects the source with the filled-in fields; the caller opens the OAuth tab in this click. */
onConnect: (sourceConfig: Record) => void
}
@@ -21,15 +23,21 @@ interface SourceSetupModalProps {
* The few fields a source needs before its first connect, such as a site and
* a space. Everyone after the first person clicks straight through.
*/
-export function SourceSetupModal({ connector, onClose, onConnect }: SourceSetupModalProps) {
+export function SourceSetupModal({
+ connector,
+ onClose,
+ onConnect,
+ isPending = false,
+ error,
+}: SourceSetupModalProps) {
+ const docsUrl = connector.meta.searchDocsUrl
const fields = connector.setupFields
const [values, setValues] = useState>({})
const complete = fields.every((field) => values[field.id]?.trim())
const submit = () => {
- if (!complete) return
+ if (!complete || isPending) return
onConnect(Object.fromEntries(fields.map((field) => [field.id, values[field.id]?.trim() ?? ''])))
- onClose()
}
return (
@@ -72,10 +80,29 @@ export function SourceSetupModal({ connector, onClose, onConnect }: SourceSetupM
/>
)
)}
+ {error && (
+
+ {error}
+
+ )}
window.open(docsUrl, '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : undefined
+ }
+ primaryAction={{
+ label: isPending ? 'Connecting…' : 'Connect',
+ onClick: submit,
+ disabled: !complete || isPending,
+ }}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx
index db326e1a8d3..f10c0594548 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx
@@ -6,19 +6,34 @@ import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockCaptureEvent, mockLeaveSearch } = vi.hoisted(() => ({
+const { mockCaptureEvent, mockModeChange, mockPush, navigation } = vi.hoisted(() => ({
mockCaptureEvent: vi.fn(),
- mockLeaveSearch: vi.fn(),
+ mockModeChange: vi.fn(),
+ mockPush: vi.fn(),
+ navigation: {
+ pathname: '/workspace/workspace-1/home',
+ chatId: undefined as string | undefined,
+ requestMode: undefined as 'agent' | 'assistant' | undefined,
+ },
}))
const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
vi.mock('next/navigation', () => ({
- useParams: () => ({ workspaceId: 'workspace-1' }),
+ useParams: () => ({ workspaceId: 'workspace-1', chatId: navigation.chatId }),
+ usePathname: () => navigation.pathname,
+ useRouter: () => ({ push: mockPush }),
}))
/** The switcher renders only where Search mode exists, so these tests are that workspace. */
vi.mock('@/hooks/use-member-access', () => ({ useMemberAccessAvailable: () => true }))
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
+vi.mock('@/hooks/queries/mothership-chats', () => ({
+ useMothershipChatHistory: () => ({
+ data: navigation.chatId
+ ? { messages: [{ role: 'user', requestMode: navigation.requestMode }] }
+ : undefined,
+ }),
+}))
import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher'
@@ -33,7 +48,7 @@ function mount(searchParams = '') {
act(() =>
root?.render(
-
+
)
)
@@ -65,8 +80,12 @@ async function select(index: number) {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ navigation.pathname = '/workspace/workspace-1/home'
+ navigation.chatId = undefined
+ navigation.requestMode = undefined
+ mockPush.mockClear()
+ mockModeChange.mockClear()
mockCaptureEvent.mockClear()
- mockLeaveSearch.mockClear()
mockUrlUpdate.mockClear()
})
@@ -114,7 +133,6 @@ describe('ModeSwitcher', () => {
mode: 'search',
})
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search')
- expect(mockLeaveSearch).not.toHaveBeenCalled()
})
it('reads the mode from the URL on mount', () => {
@@ -124,24 +142,75 @@ describe('ModeSwitcher', () => {
expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant')
})
+ it('restores Assistant from a conversation without an explicit URL mode', () => {
+ navigation.pathname = '/workspace/workspace-1/chat/existing-chat'
+ navigation.chatId = 'existing-chat'
+ navigation.requestMode = 'assistant'
+ mount()
+
+ expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant')
+ expect(mockUrlUpdate).not.toHaveBeenCalled()
+ })
+
+ it('uses the explicit Assistant selection for the next turn in a Build conversation', () => {
+ navigation.pathname = '/workspace/workspace-1/chat/existing-chat'
+ navigation.chatId = 'existing-chat'
+ navigation.requestMode = 'agent'
+ mount('?mode=assistant')
+
+ expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant')
+ })
+
+ it('changes to Build within the restored Assistant conversation', async () => {
+ navigation.pathname = '/workspace/workspace-1/chat/existing-chat'
+ navigation.chatId = 'existing-chat'
+ navigation.requestMode = 'assistant'
+ mount()
+ openMenu()
+ await select(0)
+
+ expect(trigger().getAttribute('aria-label')).toBe('Mode: Build')
+ expect(mockPush).not.toHaveBeenCalled()
+ expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('build')
+ })
+
it('clears the composer and search parameters together when leaving Search', async () => {
mount('?mode=search&q=budget&source=upload&updated=7d&resource=report')
openMenu()
await select(0)
expect(trigger().textContent).toBe('Build')
- expect(mockLeaveSearch).toHaveBeenCalledOnce()
+ expect(mockModeChange).toHaveBeenCalledOnce()
expect(mockUrlUpdate).toHaveBeenCalledOnce()
- expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('resource=report')
+ expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe(
+ 'mode=build&resource=report'
+ )
expect(mockUrlUpdate.mock.lastCall?.[0].options).toMatchObject({
history: 'replace',
scroll: false,
})
- expect(mockLeaveSearch.mock.invocationCallOrder[0]).toBeLessThan(
+ expect(mockModeChange.mock.invocationCallOrder[0]).toBeLessThan(
mockUrlUpdate.mock.invocationCallOrder[0]
)
})
+ it.each([
+ ['', 2, 'assistant'],
+ ['?mode=assistant', 0, 'build'],
+ ['?mode=assistant', 1, 'search'],
+ ] as const)(
+ 'keeps the current chat when selecting a different mode',
+ async (params, index, target) => {
+ navigation.pathname = '/workspace/workspace-1/chat/existing-chat'
+ mount(params)
+ openMenu()
+ await select(index)
+ expect(mockPush).not.toHaveBeenCalled()
+ expect(mockModeChange).toHaveBeenCalledOnce()
+ expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe(target)
+ }
+ )
+
it('does not report re-selecting the active mode', async () => {
mount()
openMenu()
@@ -149,6 +218,5 @@ describe('ModeSwitcher', () => {
expect(trigger().textContent).toBe('Build')
expect(mockCaptureEvent).not.toHaveBeenCalled()
- expect(mockLeaveSearch).not.toHaveBeenCalled()
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx
index 110248bb2da..efc97850b1f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx
@@ -26,7 +26,7 @@ const MODE_LABELS: Record = {
}
interface ModeSwitcherProps {
- onLeaveSearch?: () => void
+ onModeChange?: () => void
}
/**
@@ -36,14 +36,14 @@ interface ModeSwitcherProps {
* round controls — opening a menu that checks the active mode, as
* `ChipDropdown` does.
*/
-export const ModeSwitcher = memo(function ModeSwitcher({ onLeaveSearch }: ModeSwitcherProps) {
+export const ModeSwitcher = memo(function ModeSwitcher({ onModeChange }: ModeSwitcherProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const posthog = usePostHog()
const [mode, setMode] = useMothershipMode()
const handleSelect = (next: MothershipMode) => {
if (next === mode) return
- if (mode === 'search' && next !== 'search') onLeaveSearch?.()
+ onModeChange?.()
void setMode(next)
captureEvent(posthog, 'chat_mode_changed', { workspace_id: workspaceId, mode: next })
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
index 7b8ca833ca2..b7954578748 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts
@@ -110,6 +110,8 @@ export interface PromptEditorKeyPolicy {
}
export interface UsePromptEditorProps {
+ /** Whether this surface accepts workspace context, skills and attachments. */
+ contextsEnabled?: boolean
/** Workspace whose resources, integrations, and skills the editor mentions. */
workspaceId: string
/** Initial text. Chipified (`@`-mentions / `/`-skills converted) on mount. */
@@ -158,12 +160,15 @@ export type PromptEditorInstance = ReturnType
* ```
*/
export function usePromptEditor({
+ contextsEnabled = true,
workspaceId,
initialValue = '',
initialContexts,
onContextAdd,
onPasteFiles,
}: UsePromptEditorProps) {
+ const contextsEnabledRef = useRef(contextsEnabled)
+ contextsEnabledRef.current = contextsEnabled
const { data: skills = [] } = useSkills(workspaceId)
const { data: allMcpServers = [] } = useMcpToolServers(workspaceId)
const mcpServers = useMemo(
@@ -205,7 +210,10 @@ export function usePromptEditor({
const dismissedMentionStartRef = useRef(null)
const dismissedSlashStartRef = useRef(null)
- const contextManagement = useContextManagement({ message: value, initialContexts })
+ const contextManagement = useContextManagement({
+ message: value,
+ initialContexts: contextsEnabled ? initialContexts : undefined,
+ })
const contextManagementRef = useRef(contextManagement)
contextManagementRef.current = contextManagement
@@ -215,6 +223,7 @@ export function usePromptEditor({
onPasteFilesRef.current = onPasteFiles
const addContextNotified = useCallback((context: ChatContext) => {
+ if (!contextsEnabledRef.current) return
contextManagementRef.current.addContext(context)
onContextAddRef.current?.(context)
}, [])
@@ -252,7 +261,10 @@ export function usePromptEditor({
* fully converted text and registers both context kinds.
*/
const applyAutoMentions = useCallback(
- (text: string) => skillAutoMention.applyToText(integrationAutoMention.applyToText(text)),
+ (text: string) =>
+ contextsEnabledRef.current
+ ? skillAutoMention.applyToText(integrationAutoMention.applyToText(text))
+ : text,
[skillAutoMention.applyToText, integrationAutoMention.applyToText]
)
const applyAutoMentionsRef = useRef(applyAutoMentions)
@@ -317,10 +329,12 @@ export function usePromptEditor({
/** Contexts whose tokens still exist in the latest synchronous editor value. */
const getActiveContexts = useCallback(
() =>
- filterContextsPresentInMessage(
- contextManagementRef.current.selectedContexts,
- valueRef.current
- ),
+ contextsEnabledRef.current
+ ? filterContextsPresentInMessage(
+ contextManagementRef.current.selectedContexts,
+ valueRef.current
+ )
+ : [],
[]
)
@@ -611,6 +625,7 @@ export function usePromptEditor({
const syncMentionState = useCallback(
(textarea: HTMLTextAreaElement, text: string, caret: number) => {
+ if (!contextsEnabledRef.current) return
const active = getActiveMentionAtRef.current(caret, text)
// Any word-boundary character inside the query — whitespace, sentence
// punctuation, or brackets — dismisses the menu. The mention token
@@ -650,6 +665,7 @@ export function usePromptEditor({
const syncSlashState = useCallback(
(textarea: HTMLTextAreaElement, text: string, caret: number) => {
+ if (!contextsEnabledRef.current) return
const active = getActiveSlashAtRef.current(caret, text)
// Any word-boundary character inside the query dismisses the menu. The
// boundary set intentionally excludes `/` so the slash itself doesn't
@@ -718,7 +734,7 @@ export function usePromptEditor({
* viewport position — the toolbar `+` button flow.
*/
const openResourceMenu = useCallback((anchor: { left: number; top: number }) => {
- plusMenuRef.current?.open(anchor)
+ if (contextsEnabledRef.current) plusMenuRef.current?.open(anchor)
}, [])
const handleInputChange = useCallback(
@@ -727,7 +743,7 @@ export function usePromptEditor({
const nextValue = e.target.value
let finalValue = nextValue
- if (nextValue.length === previousValue.length + 1) {
+ if (contextsEnabledRef.current && nextValue.length === previousValue.length + 1) {
// Single-char keystroke — synchronous, boundary-triggered.
finalValue = integrationAutoMention.processChange({
textarea: e.target,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx
index c11218a3b60..49262bb05fd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx
@@ -16,7 +16,11 @@ const { mockSubmit, mockResetTranscript, mockMemberAccessAvailable } = vi.hoiste
mockMemberAccessAvailable: vi.fn(() => true),
}))
-vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+ usePathname: () => '/workspace/workspace-1/home',
+ useRouter: () => ({ push: vi.fn() }),
+}))
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() }))
vi.mock('@/hooks/use-member-access', () => ({
@@ -30,6 +34,9 @@ vi.mock('@/hooks/use-speech-to-text', () => ({
}))
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
+vi.mock('@/hooks/queries/mothership-chats', () => ({
+ useMothershipChatHistory: () => ({ data: undefined }),
+}))
vi.mock('@/blocks/integration-matcher', () => ({
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
mentionifyIntegrations: (text: string) => text,
@@ -67,8 +74,19 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a
return {
usePromptEditor,
ModeSwitcher,
- PromptEditor: ({ editor }: { editor: PromptEditorInstance }) => (
-
+ PromptEditor: ({
+ editor,
+ placeholder,
+ }: {
+ editor: PromptEditorInstance
+ placeholder: string
+ }) => (
+
),
SendButton: ({ onSubmit }: { onSubmit: () => void }) => (
@@ -116,7 +134,7 @@ function mount(requestMode?: QueuedMessage['requestMode']) {
{
- void setMode(requestMode === 'ask' ? 'assistant' : 'build')
+ void setMode(requestMode === 'assistant' ? 'assistant' : 'build')
inputRef.current?.loadQueuedMessage({ ...QUEUED_MESSAGE, requestMode })
}}
>
@@ -205,15 +223,19 @@ describe('search composer transitions', () => {
it.each(['Build', 'Assistant'])('clears the query when the menu selects %s', async (mode) => {
mount()
expect(textarea().value).toBe('budget')
+ expect(textarea().placeholder).toBe('Search your documents…')
await selectMode(mode)
expect(textarea().value).toBe('')
+ expect(textarea().placeholder).toBe(
+ mode === 'Assistant' ? 'Ask about your documents or take action…' : 'Ask Sim to '
+ )
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.has('q')).toBe(false)
expect(mockSubmit).not.toHaveBeenCalled()
})
- it.each([undefined, 'ask'] as const)(
+ it.each([undefined, 'assistant'] as const)(
'retains queued content and files after restoring request mode %s',
async (requestMode) => {
mount(requestMode)
@@ -223,24 +245,26 @@ describe('search composer transitions', () => {
expect(textarea().value).toBe(QUEUED_MESSAGE.content)
expect(container?.querySelector('output')?.textContent).not.toContain('build:budget')
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe(
- requestMode === 'ask' ? 'mode=assistant&resource=report' : 'resource=report'
+ requestMode === 'assistant'
+ ? 'mode=assistant&resource=report'
+ : 'mode=build&resource=report'
)
await clickButton('Send')
expect(mockSubmit).toHaveBeenCalledWith(
QUEUED_MESSAGE.content,
- QUEUED_MESSAGE.fileAttachments,
+ requestMode === 'assistant' ? undefined : QUEUED_MESSAGE.fileAttachments,
undefined
)
}
)
- it('keeps files available when leaving Search', async () => {
+ it('starts a clean composer when changing modes', async () => {
const inputRef = mount()
act(() => inputRef.current?.loadQueuedMessage({ ...QUEUED_MESSAGE, content: 'budget' }))
await selectMode('Build')
await clickButton('Send')
- expect(mockSubmit).toHaveBeenCalledWith('', QUEUED_MESSAGE.fileAttachments, undefined)
+ expect(mockSubmit).toHaveBeenCalledWith('', undefined, undefined)
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index ff08b09bcd0..12cf0cb09ad 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -10,7 +10,8 @@ import {
useRef,
useState,
} from 'react'
-import { Button, cn, Paperclip, Plus, Slash, Tooltip, toast } from '@sim/emcn'
+import { Chip, cn, Tooltip, toast } from '@sim/emcn'
+import { Paperclip, Plus, Slash } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
@@ -31,6 +32,7 @@ import {
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event'
+import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode'
import type {
FileAttachmentForApi,
MothershipResource,
@@ -124,6 +126,11 @@ const UserInputImpl = forwardRef(function UserI
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
const { userId, onContextAdd, onContextRemove } = useChatSurface()
+ const [mode] = useMothershipMode()
+ const isSearch = canSearch && mode === 'search'
+ const contextsEnabled = !canSearch || mode === 'build'
+ const contextsEnabledRef = useRef(contextsEnabled)
+ contextsEnabledRef.current = contextsEnabled
const [microphonePermissionHelpOpen, setMicrophonePermissionHelpOpen] = useState(false)
const [initialValue] = useState(() => {
@@ -138,17 +145,17 @@ const UserInputImpl = forwardRef(function UserI
const files = useFileAttachments({
userId,
workspaceId,
- disabled: false,
+ disabled: !contextsEnabled,
isLoading: isSending,
})
- const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
- const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading)
+ const hasFiles = contextsEnabled && files.attachedFiles.some((f) => !f.uploading && f.key)
+ const hasUploadingFiles = contextsEnabled && files.attachedFiles.some((f) => f.uploading)
const filesRef = useRef(files)
filesRef.current = files
const handlePasteFiles = useCallback((pasted: FileList) => {
- filesRef.current.processFiles(pasted)
+ if (contextsEnabledRef.current) filesRef.current.processFiles(pasted)
}, [])
const editor = usePromptEditor({
@@ -156,6 +163,7 @@ const UserInputImpl = forwardRef(function UserI
initialValue,
onContextAdd,
onPasteFiles: handlePasteFiles,
+ contextsEnabled,
})
const editorRef = useRef(editor)
editorRef.current = editor
@@ -169,7 +177,7 @@ const UserInputImpl = forwardRef(function UserI
*/
useEffect(() => {
const handleAddContext = (event: Event) => {
- handleMothershipAddContextEvent(event, editorRef.current)
+ if (contextsEnabledRef.current) handleMothershipAddContextEvent(event, editorRef.current)
}
window.addEventListener(MOTHERSHIP_ADD_CONTEXT_EVENT, handleAddContext)
@@ -214,8 +222,8 @@ const UserInputImpl = forwardRef(function UserI
useMothershipDraftsStore.getState().clearDraft(draftScopeKey)
return
}
- if (restoredContexts) editor.setContexts(restoredContexts)
- if (restoredFiles) files.restoreAttachedFiles(restoredFiles)
+ if (contextsEnabled && restoredContexts) editor.setContexts(restoredContexts)
+ if (contextsEnabled && restoredFiles) files.restoreAttachedFiles(restoredFiles)
if (caretText !== null) {
const textarea = textareaRef.current
if (textarea) {
@@ -453,7 +461,7 @@ const UserInputImpl = forwardRef(function UserI
)
const handleFileSelectStable = useCallback(() => {
- filesRef.current.handleFileSelect()
+ if (contextsEnabledRef.current) filesRef.current.handleFileSelect()
}, [])
const handleFileClick = useCallback((file: AttachedFile) => {
@@ -479,6 +487,10 @@ const UserInputImpl = forwardRef(function UserI
const handleContainerDrop = useCallback(
(e: React.DragEvent) => {
+ if (!contextsEnabledRef.current) {
+ e.preventDefault()
+ return
+ }
const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE)
if (resourcesJson) {
e.preventDefault()
@@ -574,18 +586,13 @@ const UserInputImpl = forwardRef(function UserI
filesRef.current.clearAttachedFiles()
}, [resetTranscript])
- /** Discards the search query while keeping files available for the next agent turn. */
- const handleLeaveSearch = useCallback(() => {
- editorRef.current.setValue('')
- sttPrefixRef.current = ''
- resetTranscript()
- }, [resetTranscript])
-
const handleSubmit = useCallback(() => {
const currentFiles = filesRef.current
const currentEditor = editorRef.current
- const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles
+ const fileAttachmentsForApi: FileAttachmentForApi[] = (
+ contextsEnabledRef.current ? currentFiles.attachedFiles : []
+ )
.filter((f) => !f.uploading && f.key)
.map((f) => ({
id: f.id,
@@ -673,17 +680,27 @@ const UserInputImpl = forwardRef(function UserI
onDragOver={handleContainerDragOver}
onDrop={handleContainerDrop}
>
-
+ {!isSearch && mode !== 'assistant' && (
+
+ )}
-
+ {contextsEnabled && (
+
+ )}
(function UserI
-
-
-
-
-
-
- Add resources
-
-
-
-
-
-
-
- Attach file
-
-
-
-
-
-
-
- Skills
-
+ {contextsEnabled && (
+ <>
+
+
+
+
+ Add resources
+
+
+
+
+
+ Attach file
+
+
+
+
+
+ Skills
+
+ >
+ )}
- {canSearch &&
}
+ {canSearch &&
}
{isSttSupported && (
(function UserI
className='hidden'
accept={MOTHERSHIP_ACCEPT_ATTRIBUTE}
multiple
+ disabled={!contextsEnabled}
/>
{files.isDragging && }
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index da77bd436a0..68b925f26c0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -21,6 +21,7 @@ import { useQueryState, useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import { requestJson } from '@/lib/api/client/request'
import { createWorkflowContract } from '@/lib/api/contracts'
+import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search'
import {
LandingPromptStorage,
type LandingWorkflowSeed,
@@ -33,10 +34,6 @@ import {
type MothershipSendMessageDetail,
} from '@/lib/mothership/events'
import { captureEvent } from '@/lib/posthog/client'
-import {
- searchedKnowledgeBases,
- withSearchedKnowledgeContexts,
-} from '@/lib/sim-search/knowledge-bases'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
/**
* Imported from its own folder, not the components barrel: the workflow copilot
@@ -63,9 +60,7 @@ import {
searchQueryParam,
} from '@/app/workspace/[workspaceId]/home/search-params'
import { useFolders } from '@/hooks/queries/folders'
-import { fetchKnowledgeBases } from '@/hooks/queries/kb/knowledge'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
-import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
import { useWorkflows } from '@/hooks/queries/workflows'
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useMemberAccessAvailable } from '@/hooks/use-member-access'
@@ -187,17 +182,6 @@ export function Home({ chatId, userName, userId }: HomeProps) {
)
const memberAccessAvailable = useMemberAccessAvailable()
const [composerMode, setComposerMode] = useMothershipMode()
- /**
- * A link that carries a query but no mode opens in Search with the query in
- * the box; the composer follows the live query the same way (below), so the
- * box and the results never show two different queries. Where per-member
- * access is off there is no Search to open into, so the query stays a plain
- * Build draft rather than a mode write `useMothershipMode` would drop.
- */
- useEffect(() => {
- if (!memberAccessAvailable) return
- if (searchQuery.trim() && composerMode === 'build') void setComposerMode('search')
- }, [memberAccessAvailable, searchQuery, composerMode, setComposerMode])
const hasCheckedLandingStorageRef = useRef(false)
const initialViewInputRef = useRef(null)
const initialViewUserInputRef = useRef(null)
@@ -479,18 +463,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[],
- modeOverride?: MothershipMode
+ modeOverride?: MothershipMode,
+ assistantSearch?: WorkspaceSearchFilters
) => {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
- captureEvent(posthogRef.current, 'task_message_sent', {
- workspace_id: workspaceId,
- has_attachments: !!(fileAttachments && fileAttachments.length > 0),
- has_contexts: !!(contexts && contexts.length > 0),
- is_new_task: !chatId,
- })
-
/**
* Search lists documents, not a turn of the agent, and only a query can
* be searched: attachments alone have nothing to search for. Assistant
@@ -510,34 +488,23 @@ export function Home({ chatId, userName, userId }: HomeProps) {
return
}
+ captureEvent(posthogRef.current, 'task_message_sent', {
+ workspace_id: workspaceId,
+ has_attachments: !!(fileAttachments && fileAttachments.length > 0),
+ has_contexts: !!(contexts && contexts.length > 0),
+ is_new_task: !chatId,
+ })
+
if (initialViewInputRef.current) {
setIsInputEntering(true)
}
prepareResourceViewForAgentTurn()
- /**
- * An Assistant turn is grounded in the searched bases, read from the
- * query cache the Search panel shares: instant once loaded, and awaited
- * the one time a question is typed before the list has arrived.
- */
- const turnContexts = answering
- ? withSearchedKnowledgeContexts(
- contexts,
- searchedKnowledgeBases(
- await queryClient.ensureQueryData({
- queryKey: knowledgeKeys.list(workspaceId, 'active'),
- queryFn: ({ signal }) => fetchKnowledgeBases(workspaceId, 'active', signal),
- staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
- }),
- workspaceId
- )
- )
- : contexts
sendMessage(
trimmed || 'Analyze the attached file(s).',
fileAttachments,
- turnContexts,
- answering ? { requestMode: 'ask' } : undefined
+ contexts,
+ answering ? { requestMode: 'assistant', assistantSearch } : undefined
)
},
[
@@ -548,7 +515,6 @@ export function Home({ chatId, userName, userId }: HomeProps) {
editingQueuedId,
cancelQueueEdit,
prepareResourceViewForAgentTurn,
- queryClient,
sendMessage,
setSearchQuery,
]
@@ -561,7 +527,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
*/
const restoreQueuedMode = useCallback(
(requestMode: QueuedMessage['requestMode']) => {
- void setComposerMode(requestMode === 'ask' ? 'assistant' : 'build')
+ void setComposerMode(requestMode === 'assistant' ? 'assistant' : 'build')
},
[setComposerMode]
)
@@ -578,11 +544,11 @@ export function Home({ chatId, userName, userId }: HomeProps) {
* box is emptied as a send empties it, so the query does not linger as a
* draft under the answer.
*/
- const handleSummarize = (prompt: string) => {
- void setComposerMode('assistant')
+ const handleSummarize = async (prompt: string, assistantSearch: WorkspaceSearchFilters) => {
+ await setComposerMode('assistant')
initialViewUserInputRef.current?.clear()
chatViewUserInputRef.current?.clear()
- void handleSubmit(prompt, undefined, undefined, 'assistant')
+ void handleSubmit(prompt, undefined, undefined, 'assistant', assistantSearch)
}
const showSearchResults = composerMode === 'search' && searchQuery.trim().length > 0
const searchResults = showSearchResults ? (
@@ -590,7 +556,6 @@ export function Home({ chatId, userName, userId }: HomeProps) {
workspaceId={workspaceId}
query={searchQuery}
onSummarize={handleSummarize}
- onAnswer={handleSummarize}
/>
) : null
@@ -609,6 +574,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
sendMessage(detail.message, detail.fileAttachments, detail.contexts, {
...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}),
...(detail.requestMode ? { requestMode: detail.requestMode } : {}),
+ ...(detail.assistantSearch ? { assistantSearch: detail.assistantSearch } : {}),
})
}
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
@@ -644,6 +610,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
? { resumeUserMessageId: handoff.resumeUserMessageId }
: {}),
...(handoff.requestMode ? { requestMode: handoff.requestMode } : {}),
+ ...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
})
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts
index c2a8e485d3f..3c59c521738 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.test.ts
@@ -9,6 +9,13 @@ function withSearch(search: string) {
}
describe('chatUrl', () => {
+ it('routes organization conversations to their owner without inventing a workspace', () => {
+ window.history.replaceState(null, '', '/o/org-1/home')
+ expect(chatUrl({ organizationId: 'org-1' }, 'chat-1', 'assistant')).toBe(
+ '/o/org-1/chat/chat-1?mode=assistant'
+ )
+ })
+
it('carries the mode and the open resource onto the chat path', () => {
withSearch('?mode=assistant&resource=res-1')
expect(chatUrl('ws-1', 'chat-1')).toBe(
@@ -25,4 +32,25 @@ describe('chatUrl', () => {
withSearch('?q=volvo')
expect(chatUrl('ws-1', 'chat-1')).toBe('/workspace/ws-1/chat/chat-1')
})
+
+ it('uses the submitted mode only when no view has been selected', () => {
+ withSearch('')
+ expect(chatUrl('ws-1', 'chat-1', 'assistant')).toBe(
+ '/workspace/ws-1/chat/chat-1?mode=assistant'
+ )
+ expect(chatUrl('ws-1', 'chat-1', 'agent')).toBe('/workspace/ws-1/chat/chat-1?mode=build')
+ })
+
+ it.each([
+ ['?mode=build', 'assistant', '?mode=build'],
+ ['?mode=assistant', 'agent', '?mode=assistant'],
+ [
+ '?mode=search&q=budget&source=upload&updated=7d',
+ 'assistant',
+ '?mode=search&q=budget&source=upload&updated=7d',
+ ],
+ ] as const)('preserves a mode selected after submission: %s', (current, submitted, expected) => {
+ withSearch(current)
+ expect(chatUrl('ws-1', 'chat-1', submitted)).toBe(`/workspace/ws-1/chat/chat-1${expected}`)
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts
index 2046927bc57..3f02aec4e30 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts
@@ -1,22 +1,34 @@
-import { modeParam, resourceParam } from '@/app/workspace/[workspaceId]/home/search-params'
-
-/** The composer's URL state that belongs on a chat page: the mode and the open resource. */
-const CHAT_URL_PARAMS = [modeParam.key, resourceParam.key] as const
+import {
+ modeParam,
+ resourceParam,
+ searchFilterParsers,
+ searchQueryParam,
+} from '@/app/workspace/[workspaceId]/home/search-params'
/**
- * The URL a new chat is handed off to once the server names it. Only the
- * params that belong on a chat ride along, so the mode survives the path swap
- * (the first Assistant message must not bounce the person back to Build) while
- * a search's `q` and filters, which never join a transcript, are left behind
- * whatever the URL held at that instant.
+ * Preserve the view selected while a new chat was starting. The submitted turn
+ * supplies a fallback only; it cannot overwrite a subsequent mode switch.
*/
-export function chatUrl(workspaceId: string, chatId: string): string {
+export function chatUrl(
+ owner: string | { organizationId: string },
+ chatId: string,
+ requestMode?: 'agent' | 'assistant'
+): string {
const current = new URLSearchParams(window.location.search)
const carried = new URLSearchParams()
- for (const key of CHAT_URL_PARAMS) {
+ const mode =
+ modeParam.parser.parse(current.get(modeParam.key) ?? '') ??
+ (requestMode === 'assistant' ? 'assistant' : requestMode === 'agent' ? 'build' : null)
+ if (mode) carried.set(modeParam.key, mode)
+ const keys =
+ mode === 'search'
+ ? [resourceParam.key, searchQueryParam.key, ...Object.keys(searchFilterParsers)]
+ : [resourceParam.key]
+ for (const key of keys) {
const value = current.get(key)
if (value) carried.set(key, value)
}
const search = carried.toString()
- return `/workspace/${workspaceId}/chat/${chatId}${search ? `?${search}` : ''}`
+ const basePath = typeof owner === 'string' ? `/workspace/${owner}` : `/o/${owner.organizationId}`
+ return `${basePath}/chat/${chatId}${search ? `?${search}` : ''}`
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts
index 1ae960be93b..8f2360d9d92 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts
@@ -25,7 +25,7 @@ import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/type
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
interface FilePreviewControllerDeps {
- workspaceId: string
+ workspaceId?: string
setResources: Dispatch>
setActiveResourceId: Dispatch>
activeResourceIdRef: MutableRefObject
@@ -97,6 +97,7 @@ export function useFilePreviewController({
const seedCompletedPreviewContentCache = useCallback(
(fileId: string, previewText: string) => {
+ if (!workspaceId) return
queryClient.setQueriesData(
{ queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') },
previewText
@@ -374,7 +375,7 @@ export function useFilePreviewController({
if (hasRenderableFilePreviewContent(nextSession)) {
seedCompletedPreviewContentCache(fileId, nextSession.previewText)
}
- invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
+ if (workspaceId) invalidateResourceQueries(queryClient, workspaceId, 'file', fileId)
} else {
const activePreview =
nextState.activeSessionId !== null
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts
index e9565c7c5ba..5f17c4b057a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts
@@ -43,6 +43,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
ensureWorkflowInRegistry,
onResourceEventRef,
} = ctx.deps
+ if (!workspaceId) return
const onResourceEvent = onResourceEventRef.current
const payload = parsed.payload
const shouldClearViewId =
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts
index a4e077be89d..ad01148d02d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts
@@ -31,7 +31,11 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent)
deps.setResolvedChatId(payloadChatId)
}
}
- deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
+ deps.queryClient.invalidateQueries({
+ queryKey: deps.organizationId
+ ? mothershipChatKeys.organizationList(deps.organizationId)
+ : mothershipChatKeys.list(deps.workspaceId),
+ })
if (isNewChat) {
const userMsg = deps.pendingUserMsgRef.current
const activeStreamId = deps.streamIdRef.current
@@ -57,13 +61,24 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent)
}
deps.setPendingMessages([])
if (!deps.workflowIdRef.current) {
- window.history.replaceState(null, '', chatUrl(deps.workspaceId, payloadChatId))
+ window.history.replaceState(
+ null,
+ '',
+ chatUrl(
+ deps.organizationId ? { organizationId: deps.organizationId } : deps.workspaceId!,
+ payloadChatId
+ )
+ )
}
}
}
if (payload.kind === MothershipStreamV1SessionKind.title) {
- deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) })
+ deps.queryClient.invalidateQueries({
+ queryKey: deps.organizationId
+ ? mothershipChatKeys.organizationList(deps.organizationId)
+ : mothershipChatKeys.list(deps.workspaceId),
+ })
deps.onTitleUpdateRef.current?.()
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
index 5f0de25c6e0..66b261a966c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
@@ -46,6 +46,7 @@ function agentIdForSpan(ctx: StreamLoopContext, spanId: string): string | undefi
*/
function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void {
const { deps } = ctx
+ if (!deps.workspaceId) return
const name = node.name
const output = node.result?.output
const isSuccess = node.status === 'success'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts
index a10dbca49cf..aeb99e8f257 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts
@@ -82,7 +82,8 @@ export interface StreamEventScope {
}
export interface StreamLoopDeps {
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
queryClient: QueryClient
assistantId: string
expectedGen: number | undefined
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx
index 374620f48b0..eb33c508b74 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx
@@ -115,7 +115,7 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise<
const mountedRoots: Root[] = []
let queryClient: QueryClient
-function renderUseChat(): {
+function renderUseChat(owner: string | { organizationId: string } = 'ws-1'): {
getResult: () => ReturnType
unmount: () => void
} {
@@ -127,7 +127,7 @@ function renderUseChat(): {
let result: ReturnType | undefined
function Probe() {
- result = useChat('ws-1', undefined)
+ result = useChat(owner, undefined)
return null
}
@@ -301,6 +301,24 @@ async function waitFor(predicate: () => boolean, budgetMs = 2000): Promise
}
describe('useChat remount send recovery', () => {
+ it('sends and recovers an organization turn without adding workspace scope', async () => {
+ navigationMocks.usePathname.mockReturnValue('/o/org-1/home')
+ const { getResult, unmount } = renderUseChat({ organizationId: 'org-1' })
+ await act(async () => {
+ void getResult().sendMessage('Find the policy')
+ })
+ await waitFor(() => state.postBodies.length === 1)
+ expect(state.postBodies[0]).toMatchObject({ organizationId: 'org-1', mode: 'assistant' })
+ expect(state.postBodies[0]).not.toHaveProperty('workspaceId')
+ unmount()
+ await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null)
+ expect(MothershipHandoffStorage.consume('org-1')).toBeNull()
+ expect(MothershipHandoffStorage.consume({ organizationId: 'org-1' })).toMatchObject({
+ message: 'Find the policy',
+ resumeUserMessageId: state.postBodies[0].userMessageId,
+ })
+ })
+
beforeEach(() => {
vi.stubGlobal('fetch', fetchStub)
navigationMocks.usePathname.mockReturnValue('/workspace/ws-1/home')
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 0e8b1b6797e..dfe600df579 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -20,6 +20,10 @@ import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
+import {
+ type WorkspaceSearchFilters,
+ workspaceSearchFiltersSchema,
+} from '@/lib/api/contracts/knowledge/search'
import {
addMothershipChatResourceContract,
removeMothershipChatResourceContract,
@@ -165,8 +169,9 @@ export interface SendMessageOptions {
* attempts instead of opening a second chat.
*/
resumeUserMessageId?: string
- /** Asked for beyond the default agent turn; `ask` answers from the attached knowledge alone. */
+ /** Assistant searches the workspace and acts through the caller's connected accounts. */
requestMode?: ChatRequestMode
+ assistantSearch?: WorkspaceSearchFilters
}
/**
@@ -191,6 +196,7 @@ interface StartSendMessageOptions {
*/
resumeUserMessageId?: string
requestMode?: ChatRequestMode
+ assistantSearch?: WorkspaceSearchFilters
}
/** A send an unmount cleanup withdrew, as handed to the next chat surface. */
@@ -200,6 +206,7 @@ interface WithdrawnSend {
contexts?: ChatContext[]
userMessageId: string
requestMode?: ChatRequestMode
+ assistantSearch?: WorkspaceSearchFilters
}
export interface UseChatReturn {
@@ -304,13 +311,15 @@ interface DetachedChatResolution {
interface QueuedSendHandoffState {
id: string
chatId?: string
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
supersededStreamId: string | null
userMessageId: string
message: string
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
requestMode?: ChatRequestMode
+ assistantSearch?: WorkspaceSearchFilters
requestedAt: number
resolveAttempts?: number
}
@@ -523,7 +532,8 @@ function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
typeof parsed.supersededStreamId === 'string' ? parsed.supersededStreamId : null
if (
typeof parsed?.id !== 'string' ||
- typeof parsed.workspaceId !== 'string' ||
+ (typeof parsed.workspaceId !== 'string' && typeof parsed.organizationId !== 'string') ||
+ (typeof parsed.workspaceId === 'string' && typeof parsed.organizationId === 'string') ||
typeof parsed.userMessageId !== 'string' ||
typeof parsed.message !== 'string' ||
typeof parsed.requestedAt !== 'number' ||
@@ -539,10 +549,14 @@ function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
return null
}
+ const assistantSearch = workspaceSearchFiltersSchema.safeParse(parsed.assistantSearch ?? {})
+ if (!assistantSearch.success) return null
+
return {
id: parsed.id,
...(chatId ? { chatId } : {}),
workspaceId: parsed.workspaceId,
+ organizationId: parsed.organizationId,
supersededStreamId,
userMessageId: parsed.userMessageId,
message: parsed.message,
@@ -552,6 +566,8 @@ function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
...(Array.isArray(parsed.contexts)
? { contexts: parsed.contexts.filter(isChatContext) }
: {}),
+ ...(parsed.requestMode === 'assistant' ? { requestMode: 'assistant' } : {}),
+ ...(parsed.assistantSearch ? { assistantSearch: assistantSearch.data } : {}),
requestedAt: parsed.requestedAt,
...(typeof parsed.resolveAttempts === 'number' &&
Number.isFinite(parsed.resolveAttempts) &&
@@ -1334,10 +1350,13 @@ export function getWorkflowCopilotUseChatOptions(
}
export function useChat(
- workspaceId: string,
+ owner: string | { organizationId: string },
initialChatId?: string,
options?: UseChatOptions
): UseChatReturn {
+ const workspaceId = typeof owner === 'string' ? owner : undefined
+ const organizationId = typeof owner === 'string' ? undefined : owner.organizationId
+ const scopeKey = typeof owner === 'string' ? owner : `organization:${owner.organizationId}`
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
@@ -1544,10 +1563,10 @@ export function useChat(
const streamReaderRef = useRef | null>(null)
const chatIdRef = useRef(initialChatId)
const pendingDesktopScopeIdRef = useRef(
- desktopChatScopeId(workspaceId, undefined, pendingChatKeyRef.current)
+ desktopChatScopeId(scopeKey, undefined, pendingChatKeyRef.current)
)
const initialDesktopScopeId = desktopChatScopeId(
- workspaceId,
+ scopeKey,
initialChatId,
pendingChatKeyRef.current
)
@@ -1686,11 +1705,7 @@ export function useChat(
chatKeyRef.current = pendingChatKeyRef.current
setChatKey(pendingChatKeyRef.current)
clearQueueDispatchState()
- const pendingDesktopScopeId = desktopChatScopeId(
- workspaceId,
- undefined,
- pendingChatKeyRef.current
- )
+ const pendingDesktopScopeId = desktopChatScopeId(scopeKey, undefined, pendingChatKeyRef.current)
pendingDesktopScopeIdRef.current = pendingDesktopScopeId
desktopScopeIdRef.current = pendingDesktopScopeId
setDesktopScopeId(pendingDesktopScopeId)
@@ -1703,6 +1718,8 @@ export function useChat(
resetEphemeralPreviewState,
setTransportIdle,
workspaceId,
+ organizationId,
+ scopeKey,
])
const flushPendingResourceReorder = useCallback(
@@ -1783,7 +1800,7 @@ export function useChat(
? activeTurn.pendingChatKey
: pendingChatKeyRef.current
chatIdRef.current = chatId
- const resolvedDesktopScopeId = desktopChatScopeId(workspaceId, chatId)
+ const resolvedDesktopScopeId = desktopChatScopeId(scopeKey, chatId)
const activeActivityTracker = resourceActivityTrackerRef.current
if (activeActivityTracker?.generation === streamGenRef.current) {
if (wasPending) {
@@ -1839,14 +1856,26 @@ export function useChat(
!workflowIdRef.current &&
typeof window !== 'undefined'
) {
- window.history.replaceState(null, '', chatUrl(workspaceId, chatId))
+ window.history.replaceState(
+ null,
+ '',
+ chatUrl(
+ organizationId ? { organizationId } : workspaceId!,
+ chatId,
+ activeTurn?.optimisticUserMessage.requestMode
+ )
+ )
}
if (options?.invalidateList) {
- queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
+ queryClient.invalidateQueries({
+ queryKey: organizationId
+ ? mothershipChatKeys.organizationList(organizationId)
+ : mothershipChatKeys.list(workspaceId),
+ })
}
flushPendingResources(chatId, pendingChatKey)
},
- [flushPendingResources, queryClient, workspaceId]
+ [flushPendingResources, queryClient, workspaceId, organizationId, scopeKey]
)
const { data: chatHistory, isPending: isChatHistoryPending } =
@@ -1962,6 +1991,7 @@ export function useChat(
*/
const reconcileHydratedWorkflowResources = useCallback(
async (chatId: string, workflowResources: MothershipResource[]) => {
+ if (!workspaceId) return
let existing: WorkflowMetadata[]
try {
existing = await getQueryClient().fetchQuery(getWorkflowListQueryOptions(workspaceId))
@@ -1980,7 +2010,7 @@ export function useChat(
removeResource('workflow', resource.id)
}
},
- [workspaceId, removeResource]
+ [workspaceId, organizationId, scopeKey, removeResource]
)
const reorderResources = useCallback(
@@ -2000,6 +2030,7 @@ export function useChat(
const ensureWorkflowToolResource = useCallback(
(toolArgs: Record): string | undefined => {
+ if (!workspaceId) return undefined
const targetWorkflowId =
typeof toolArgs.workflowId === 'string'
? toolArgs.workflowId
@@ -2019,7 +2050,7 @@ export function useChat(
return targetWorkflowId
},
- [addResource, workspaceId]
+ [addResource, workspaceId, organizationId, scopeKey]
)
const startClientWorkflowTool = useCallback(
@@ -2043,7 +2074,7 @@ export function useChat(
const startClientLocalFilesystemTool = useCallback(
(toolCallId: string, toolName: string, toolArgs: Record) => {
- if (!isUserLocalVfsToolCall(toolName, toolArgs)) {
+ if (!workspaceId || !isUserLocalVfsToolCall(toolName, toolArgs)) {
return
}
if (handledClientLocalFilesystemToolIdsRef.current.has(toolCallId)) {
@@ -2094,7 +2125,7 @@ export function useChat(
}
)
},
- [workspaceId]
+ [workspaceId, organizationId, scopeKey]
)
const openBrowserResource = useCallback(() => {
@@ -2125,7 +2156,7 @@ export function useChat(
}
}
if (targetChatId) {
- const targetScopeId = desktopChatScopeId(workspaceId, targetChatId)
+ const targetScopeId = desktopChatScopeId(scopeKey, targetChatId)
if (
tracker.generation === streamGenRef.current &&
resourceActivityTrackerRef.current === tracker
@@ -2138,7 +2169,7 @@ export function useChat(
}
return tracker
},
- [workspaceId]
+ [workspaceId, organizationId, scopeKey]
)
const clearResourceActivity = useCallback(
@@ -2150,7 +2181,7 @@ export function useChat(
if (isCurrentBoundary) {
captureResourceActivityScope(tracker, desktopScopeIdRef.current)
if (chatIdRef.current) {
- captureResourceActivityScope(tracker, desktopChatScopeId(workspaceId, chatIdRef.current))
+ captureResourceActivityScope(tracker, desktopChatScopeId(scopeKey, chatIdRef.current))
}
}
const currentTracker = resourceActivityTrackerRef.current
@@ -2167,7 +2198,7 @@ export function useChat(
resourceActivityTrackerRef.current = null
}
},
- [workspaceId]
+ [workspaceId, organizationId, scopeKey]
)
const startClientBrowserTool = useCallback(
@@ -2330,7 +2361,11 @@ export function useChat(
queryClient.invalidateQueries({
queryKey: mothershipChatKeys.detail(resolvedChatId),
})
- queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
+ queryClient.invalidateQueries({
+ queryKey: organizationId
+ ? mothershipChatKeys.organizationList(organizationId)
+ : mothershipChatKeys.list(workspaceId),
+ })
})()
.catch((error) => {
if (detachedResolutionController.signal.aborted) return
@@ -2391,7 +2426,7 @@ export function useChat(
}
clearQueueDispatchState()
const nextDesktopScopeId = desktopChatScopeId(
- workspaceId,
+ scopeKey,
initialChatId,
pendingChatKeyRef.current
)
@@ -2413,13 +2448,16 @@ export function useChat(
cancelActiveStreamRecovery,
cancelActiveStreamReader,
workspaceId,
+ organizationId,
+ scopeKey,
])
useEffect(() => {
+ if (organizationId) return
initBrowserAgentTransport()
initTerminalTransport()
void activateDesktopChatScopes(desktopScopeIdRef.current).catch(() => {})
- }, [])
+ }, [organizationId])
useEffect(() => {
if (workflowIdRef.current) return
@@ -2446,7 +2484,7 @@ export function useChat(
!sendingRef.current &&
(!activeStreamId || isTerminalStreamStatus(chatHistory.streamSnapshot?.status))
) {
- const hydratedScopeId = desktopChatScopeId(workspaceId, chatHistory.id)
+ const hydratedScopeId = desktopChatScopeId(scopeKey, chatHistory.id)
clearResourceActivityScope(hydratedScopeId)
void cancelActiveBrowserTools([hydratedScopeId])
}
@@ -2671,6 +2709,7 @@ export function useChat(
const clearStreamResourceActivity = () => clearResourceActivity(activityTracker, true)
const ctx = createStreamLoopContext({
workspaceId,
+ organizationId,
queryClient,
assistantId,
expectedGen,
@@ -3688,9 +3727,13 @@ export function useChat(
queryKey: mothershipChatKeys.detail(activeChatId),
})
}
- queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
+ queryClient.invalidateQueries({
+ queryKey: organizationId
+ ? mothershipChatKeys.organizationList(organizationId)
+ : mothershipChatKeys.list(workspaceId),
+ })
},
- [workspaceId, queryClient]
+ [workspaceId, organizationId, scopeKey, queryClient]
)
const messagesRef = useRef(messages)
@@ -3729,7 +3772,8 @@ export function useChat(
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[],
resumeUserMessageId?: string,
- requestMode?: ChatRequestMode
+ requestMode?: ChatRequestMode,
+ assistantSearch?: WorkspaceSearchFilters
): QueuedMothershipMessage => {
const id = generateId()
const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current
@@ -3751,6 +3795,7 @@ export function useChat(
contexts,
...(resumeUserMessageId ? { resumeUserMessageId } : {}),
...(requestMode ? { requestMode } : {}),
+ ...(assistantSearch ? { assistantSearch } : {}),
...(supersededStreamId || handoffChatId
? {
queuedSendHandoff: {
@@ -3805,7 +3850,9 @@ export function useChat(
void getDesktopBridge()?.settings?.notify({
title: 'Task complete',
body: 'Sim finished responding.',
- route: `/workspace/${workspaceId}/chat/${completedChatId}`,
+ route: organizationId
+ ? `/o/${organizationId}/chat/${completedChatId}`
+ : `/workspace/${workspaceId}/chat/${completedChatId}`,
})
}
reconcileTerminalPreviewSessions()
@@ -3844,7 +3891,7 @@ export function useChat(
contexts?: ChatContext[],
options?: StartSendMessageOptions
): Promise => {
- if (!message.trim() || !workspaceId) return false
+ if (!message.trim() || !scopeKey) return false
const { onOptimisticSendApplied, queuedSendHandoff } = options ?? {}
const pendingStop = options?.pendingStop ?? pendingStopPromiseRef.current
const pendingStopStreamId = pendingStop
@@ -3887,12 +3934,14 @@ export function useChat(
id: queuedSendHandoff.id,
...(chatId ? { chatId } : {}),
workspaceId,
+ organizationId,
supersededStreamId: queuedSendHandoff.supersededStreamId,
userMessageId,
message,
...(fileAttachments ? { fileAttachments } : {}),
...(contexts ? { contexts } : {}),
...(options?.requestMode ? { requestMode: options.requestMode } : {}),
+ ...(options?.assistantSearch ? { assistantSearch: options.assistantSearch } : {}),
requestedAt: Date.now(),
})
}
@@ -3939,6 +3988,7 @@ export function useChat(
const cachedUserMsg: PersistedMessage = {
id: userMessageId,
role: 'user' as const,
+ requestMode: options?.requestMode ?? 'agent',
content: message,
timestamp: new Date().toISOString(),
...(storedAttachments && { fileAttachments: storedAttachments }),
@@ -3957,6 +4007,7 @@ export function useChat(
const optimisticUserMessage: ChatMessage = {
id: userMessageId,
role: 'user',
+ requestMode: options?.requestMode ?? 'agent',
content: message,
attachments: userAttachments,
...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
@@ -3964,6 +4015,7 @@ export function useChat(
const optimisticAssistantMessage: ChatMessage = {
id: assistantId,
role: 'assistant',
+ requestMode: options?.requestMode ?? 'agent',
content: '',
contentBlocks: [],
}
@@ -4100,27 +4152,39 @@ export function useChat(
abortControllerRef.current = abortController
sendAbortSignal = abortController.signal
- const resourceAttachments = buildResourceAttachments(
- resourcesRef.current,
- activeResourceIdRef.current,
- desktopScopeIdRef.current
- )
- const desktopChatCapabilities = await getDesktopChatCapabilities(desktopScopeIdRef.current)
+ const resourceAttachments =
+ options?.requestMode === 'assistant'
+ ? undefined
+ : buildResourceAttachments(
+ resourcesRef.current,
+ activeResourceIdRef.current,
+ desktopScopeIdRef.current
+ )
+ const desktopChatCapabilities = organizationId
+ ? {}
+ : await getDesktopChatCapabilities(desktopScopeIdRef.current)
const response = await fetch(apiPathRef.current, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
- workspaceId,
+ ...(organizationId ? { organizationId } : { workspaceId }),
userMessageId,
createNewChat: !requestChatId,
...(requestChatId ? { chatId: requestChatId } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(resourceAttachments ? { resourceAttachments } : {}),
...(contexts && contexts.length > 0 ? { contexts } : {}),
- ...(options?.requestMode ? { mode: options.requestMode } : {}),
- ...(workflowIdRef.current ? { workflowId: workflowIdRef.current } : {}),
+ ...(organizationId
+ ? { mode: 'assistant' }
+ : options?.requestMode
+ ? { mode: options.requestMode }
+ : {}),
+ ...(options?.assistantSearch ? { assistantSearch: options.assistantSearch } : {}),
+ ...(options?.requestMode !== 'assistant' && workflowIdRef.current
+ ? { workflowId: workflowIdRef.current }
+ : {}),
// Desktop-only capabilities (local filesystem tools, browser
// subagent) — the server gates the features on these flags.
...desktopChatCapabilities,
@@ -4297,6 +4361,8 @@ export function useChat(
},
[
workspaceId,
+ organizationId,
+ scopeKey,
queryClient,
upsertChatHistory,
processSSEStream,
@@ -4325,7 +4391,8 @@ export function useChat(
send.contexts,
send.fileAttachments,
send.userMessageId,
- send.requestMode
+ send.requestMode,
+ send.assistantSearch
)
) {
return
@@ -4337,11 +4404,12 @@ export function useChat(
...(send.fileAttachments?.length ? { fileAttachments: send.fileAttachments } : {}),
resumeUserMessageId: send.userMessageId,
...(send.requestMode ? { requestMode: send.requestMode } : {}),
+ ...(send.assistantSearch ? { assistantSearch: send.assistantSearch } : {}),
},
- workspaceId
+ organizationId ? { organizationId } : workspaceId!
)
},
- [workspaceId]
+ [workspaceId, organizationId]
)
const sendMessage = useCallback(
@@ -4351,7 +4419,7 @@ export function useChat(
contexts?: ChatContext[],
options?: SendMessageOptions
) => {
- if (!message.trim() || !workspaceId) return
+ if (!message.trim() || !scopeKey) return
const queueStore = useMothershipQueueStore.getState()
const activeChatKey = chatKeyRef.current
@@ -4367,6 +4435,7 @@ export function useChat(
fileAttachments,
contexts,
requestMode: options?.requestMode,
+ assistantSearch: options?.assistantSearch,
})
queueStore.setEditing(activeChatKey, null)
// Resume dispatch if it paused on this slot.
@@ -4399,7 +4468,8 @@ export function useChat(
fileAttachments,
contexts,
options?.resumeUserMessageId,
- options?.requestMode
+ options?.requestMode,
+ options?.assistantSearch
)
)
if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) {
@@ -4422,6 +4492,7 @@ export function useChat(
contexts,
userMessageId: result.userMessageId,
...(options?.requestMode ? { requestMode: options.requestMode } : {}),
+ ...(options?.assistantSearch ? { assistantSearch: options.assistantSearch } : {}),
}
if (activeChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) {
handOffWithdrawnSend(withdrawn)
@@ -4436,7 +4507,8 @@ export function useChat(
fileAttachments,
contexts,
result.userMessageId,
- options?.requestMode
+ options?.requestMode,
+ options?.assistantSearch
)
)
},
@@ -4457,11 +4529,16 @@ export function useChat(
}
}, [])
useEffect(() => {
- if (!workspaceId || sendingRef.current || pendingStopPromiseRef.current) return
+ if (!scopeKey || sendingRef.current || pendingStopPromiseRef.current) return
let cancelled = false
const handoff = readQueuedSendHandoffState()
- if (!handoff || handoff.workspaceId !== workspaceId) return
+ if (
+ !handoff ||
+ handoff.workspaceId !== workspaceId ||
+ handoff.organizationId !== organizationId
+ )
+ return
if (recoveringQueuedSendHandoffRef.current?.id === handoff.id) return
const claimRetryDelayMs = queuedSendHandoffClaimRetryDelay(handoff.id)
if (claimRetryDelayMs !== null) {
@@ -4498,6 +4575,7 @@ export function useChat(
!currentHandoff ||
currentHandoff.id !== handoff.id ||
currentHandoff.workspaceId !== workspaceId ||
+ currentHandoff.organizationId !== organizationId ||
currentHandoff.userMessageId !== handoff.userMessageId ||
currentHandoff.supersededStreamId !== handoff.supersededStreamId ||
currentHandoff.chatId ||
@@ -4578,13 +4656,25 @@ export function useChat(
}
clearQueuedSendHandoffClaim(handoff.id, claimOwnerId)
}
- }, [workspaceId, queuedHandoffRecoveryEpoch, adoptResolvedChatId, resolveChatIdForStream])
+ }, [
+ workspaceId,
+ organizationId,
+ scopeKey,
+ queuedHandoffRecoveryEpoch,
+ adoptResolvedChatId,
+ resolveChatIdForStream,
+ ])
useEffect(() => {
- if (!workspaceId || !chatHistory || sendingRef.current || pendingStopPromiseRef.current) return
+ if (!scopeKey || !chatHistory || sendingRef.current || pendingStopPromiseRef.current) return
const handoff = readQueuedSendHandoffState()
if (!handoff) return
- if (handoff.workspaceId !== workspaceId || handoff.chatId !== chatHistory.id) return
+ if (
+ handoff.workspaceId !== workspaceId ||
+ handoff.organizationId !== organizationId ||
+ handoff.chatId !== chatHistory.id
+ )
+ return
if (recoveringQueuedSendHandoffRef.current?.id === handoff.id) return
if (readQueuedSendHandoffClaim() === handoff.id) return
@@ -4612,6 +4702,7 @@ export function useChat(
void startSendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, {
pendingStop: null,
...(handoff.requestMode ? { requestMode: handoff.requestMode } : {}),
+ ...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
queuedSendHandoff: {
id: handoff.id,
chatId: handoff.chatId,
@@ -4627,7 +4718,14 @@ export function useChat(
}
clearQueuedSendHandoffClaim(handoff.id, claimOwnerId)
})
- }, [workspaceId, chatHistory, queuedHandoffRecoveryEpoch, startSendMessage])
+ }, [
+ workspaceId,
+ organizationId,
+ scopeKey,
+ chatHistory,
+ queuedHandoffRecoveryEpoch,
+ startSendMessage,
+ ])
const cancelActiveWorkflowExecutions = useCallback(() => {
const execState = useExecutionStore.getState()
const consoleStore = useTerminalConsoleStore.getState()
@@ -4744,7 +4842,7 @@ export function useChat(
if (chatIdRef.current) {
captureResourceActivityScope(
stopActivityTracker,
- desktopChatScopeId(workspaceId, chatIdRef.current)
+ desktopChatScopeId(scopeKey, chatIdRef.current)
)
}
clearResourceActivity(stopActivityTracker, true)
@@ -5035,6 +5133,7 @@ export function useChat(
fileAttachments: dispatched.fileAttachments,
contexts: dispatched.contexts,
...(dispatched.requestMode ? { requestMode: dispatched.requestMode } : {}),
+ ...(dispatched.assistantSearch ? { assistantSearch: dispatched.assistantSearch } : {}),
userMessageId: withdrawnUserMessageId,
})
return
@@ -5074,6 +5173,7 @@ export function useChat(
? { resumeUserMessageId: liveMsg.resumeUserMessageId }
: {}),
...(liveMsg.requestMode ? { requestMode: liveMsg.requestMode } : {}),
+ ...(liveMsg.assistantSearch ? { assistantSearch: liveMsg.assistantSearch } : {}),
}
)
@@ -5165,7 +5265,7 @@ export function useChat(
const queuedSendHandoff =
msg.queuedSendHandoff ??
- ((sendingRef.current || pendingStopPromiseRef.current) && workspaceId
+ ((sendingRef.current || pendingStopPromiseRef.current) && scopeKey
? (() => {
const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current
const cachedActiveStreamId = handoffChatId
@@ -5197,7 +5297,7 @@ export function useChat(
queuedSendHandoff,
})
},
- [dispatchQueuedMessage, queryClient, stopGeneration, workspaceId]
+ [dispatchQueuedMessage, queryClient, stopGeneration, workspaceId, organizationId, scopeKey]
)
const sendNow = useCallback(
@@ -5238,14 +5338,22 @@ export function useChat(
const chatHistoryReady = chatHistory !== undefined
const remoteActiveStreamId = chatHistory?.activeStreamId ?? null
useEffect(() => {
- if (!workspaceId) return
+ if (!scopeKey) return
if (messageQueue.length === 0) return
if (sendingRef.current || pendingStopPromiseRef.current) return
if (queueDispatchTaskRef.current) return
if (resolvedChatId && !chatHistoryReady) return
if (remoteActiveStreamId) return
void enqueueQueueDispatchRef.current({ type: 'send_head' })
- }, [workspaceId, messageQueue.length, resolvedChatId, chatHistoryReady, remoteActiveStreamId])
+ }, [
+ workspaceId,
+ organizationId,
+ scopeKey,
+ messageQueue.length,
+ resolvedChatId,
+ chatHistoryReady,
+ remoteActiveStreamId,
+ ])
useEffect(() => {
return () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx
index 0b6cede7104..4b789cb1d92 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.test.tsx
@@ -7,14 +7,25 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MothershipMode } from '@/app/workspace/[workspaceId]/home/search-params'
-const { mockMemberAccessAvailable } = vi.hoisted(() => ({
+const { mockMemberAccessAvailable, history } = vi.hoisted(() => ({
mockMemberAccessAvailable: vi.fn(() => true),
+ history: {
+ messages: [] as { role: 'user' | 'assistant'; requestMode?: 'agent' | 'assistant' }[],
+ },
}))
const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
vi.mock('@/hooks/use-member-access', () => ({
useMemberAccessAvailable: () => mockMemberAccessAvailable(),
}))
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1', chatId: 'chat-1' }),
+ usePathname: () => '/workspace/workspace-1/home',
+ useRouter: () => ({ push: vi.fn() }),
+}))
+vi.mock('@/hooks/queries/mothership-chats', () => ({
+ useMothershipChatHistory: () => ({ data: history }),
+}))
import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode'
@@ -32,6 +43,10 @@ function mount(searchParams = '') {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
+ navigate(searchParams)
+}
+
+function navigate(searchParams: string) {
act(() =>
root?.render(
@@ -57,6 +72,7 @@ async function setMode(next: MothershipMode) {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
mockMemberAccessAvailable.mockReturnValue(true)
+ history.messages = []
mockUrlUpdate.mockClear()
})
@@ -83,6 +99,62 @@ describe('useMothershipMode', () => {
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search')
})
+ it.each([
+ ['agent', 'assistant', 'assistant'],
+ ['assistant', 'agent', 'build'],
+ ] as const)('resumes the latest user mode, %s then %s', (first, last, expected) => {
+ history.messages = [
+ { role: 'user', requestMode: first },
+ { role: 'user', requestMode: last },
+ { role: 'assistant', requestMode: first },
+ ]
+ mount()
+ expect(mode()).toBe(expected)
+ expect(mockUrlUpdate).not.toHaveBeenCalled()
+ })
+
+ it.each(['build', 'search', 'assistant'] as const)(
+ 'respects explicit URL mode %s on reload',
+ (explicit) => {
+ history.messages = [{ role: 'user', requestMode: 'assistant' }]
+ mount(`?mode=${explicit}`)
+ expect(mode()).toBe(explicit)
+ }
+ )
+
+ it('opens a query-only link in Search without writing a mode or message', () => {
+ mount('?q=budget')
+ expect(mode()).toBe('search')
+ expect(mockUrlUpdate).not.toHaveBeenCalled()
+ })
+
+ it('keeps explicit Build even when the URL also contains a query', () => {
+ mount('?mode=build&q=budget')
+ expect(mode()).toBe('build')
+ })
+
+ it('follows back and forward URL changes without overriding them from history', () => {
+ history.messages = [{ role: 'user', requestMode: 'assistant' }]
+ mount('?mode=build')
+ expect(mode()).toBe('build')
+ navigate('?mode=search&q=budget')
+ expect(mode()).toBe('search')
+ navigate('?mode=build')
+ expect(mode()).toBe('build')
+ navigate('')
+ expect(mode()).toBe('assistant')
+ expect(mockUrlUpdate).not.toHaveBeenCalled()
+ })
+
+ it('keeps the selected mode when an earlier in-flight turn finishes persisting', async () => {
+ history.messages = [{ role: 'user', requestMode: 'agent' }]
+ mount()
+ await setMode('search')
+ history.messages = [...history.messages, { role: 'user', requestMode: 'assistant' }]
+ navigate('?mode=search')
+ expect(mode()).toBe('search')
+ })
+
describe('without per-member access', () => {
beforeEach(() => {
mockMemberAccessAvailable.mockReturnValue(false)
@@ -107,7 +179,7 @@ describe('useMothershipMode', () => {
await setMode('build')
expect(mode()).toBe('build')
- expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('')
+ expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('mode=build')
})
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts
index f97e4409458..13af3aa9a2c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-mode.ts
@@ -1,6 +1,7 @@
'use client'
import { useCallback } from 'react'
+import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import {
CLEARED_SEARCH_FILTERS,
@@ -8,25 +9,31 @@ import {
type MothershipMode,
resourceUrlKeys,
} from '@/app/workspace/[workspaceId]/home/search-params'
+import { useMothershipChatHistory } from '@/hooks/queries/mothership-chats'
import { useMemberAccessAvailable } from '@/hooks/use-member-access'
/**
- * The composer's mode, read from and written to the URL's `mode` param so a
- * refresh, back, forward, or shared link lands in the same mode, as Glean's
- * separate Search and Assistant routes do. Build is the clean URL.
- *
- * Search and Assistant both answer from the workspace's indexed sources, so
- * both exist only where per-member access is on. With the feature off the mode
- * reads Build whatever the URL says, and a write to either is dropped rather
- * than leaving a mode in the URL that the next read would contradict.
+ * URL selection owns the current view and next turn. A bare chat link resumes
+ * the latest persisted user mode without changing the mode of any active run.
*/
export function useMothershipMode() {
const memberAccessAvailable = useMemberAccessAvailable()
- const [{ mode }, setParams] = useQueryStates(composerModeParsers, resourceUrlKeys)
+ const { chatId } = useParams<{ chatId?: string }>()
+ const [{ mode: urlMode, q: query }, setParams] = useQueryStates(
+ composerModeParsers,
+ resourceUrlKeys
+ )
+ const { data: chatHistory } = useMothershipChatHistory(chatId)
+ let persistedMode: 'agent' | 'assistant' | undefined
+ for (const message of chatHistory?.messages ?? []) {
+ if (message.role === 'user') persistedMode = message.requestMode
+ }
+ const mode =
+ urlMode ?? (query?.trim() ? 'search' : persistedMode === 'assistant' ? 'assistant' : 'build')
const setMode = useCallback(
async (next: MothershipMode) => {
if (next !== 'build' && !memberAccessAvailable) return
- await setParams(
+ return setParams(
{
mode: next,
...(next === 'search' ? {} : { q: null, ...CLEARED_SEARCH_FILTERS }),
@@ -34,7 +41,7 @@ export function useMothershipMode() {
{ history: 'replace', scroll: false }
)
},
- [memberAccessAvailable, setParams]
+ [setParams, memberAccessAvailable]
)
return [memberAccessAvailable ? mode : 'build', setMode] as const
diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts
index 18398e046b8..a96d94d71d3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts
@@ -45,15 +45,15 @@ export type MothershipMode = (typeof MOTHERSHIP_MODES)[number]
/**
* `mode` is the composer's mode, so a refresh, back, forward, or shared link
- * lands in the same mode, as Glean's separate Search and Assistant routes do.
- * Build is the default and the clean URL. A view change rather than a
- * destination, so it replaces the history entry.
+ * lands in the same mode. A missing value falls back to the latest user turn;
+ * an explicit Build selection stays in the URL to distinguish it from that fallback.
*/
export const modeParam = {
key: 'mode',
- parser: parseAsStringLiteral(MOTHERSHIP_MODES)
- .withDefault('build')
- .withOptions({ history: 'replace', clearOnDefault: true }),
+ parser: parseAsStringLiteral(MOTHERSHIP_MODES).withOptions({
+ history: 'replace',
+ clearOnDefault: true,
+ }),
} as const
/** The recency windows a search can be narrowed to. */
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index 778f6f5ba68..15e8d9a1e1c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -1,3 +1,4 @@
+import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/search'
import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors'
import type { ChatContext } from '@/stores/panel'
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
@@ -23,12 +24,8 @@ export interface FileAttachmentForApi {
path?: string
}
-/**
- * A request mode a send asks the agent for beyond the default. `ask` is an
- * Assistant turn: an answer drawn from the attached knowledge bases first,
- * with a connected integration reached only when those cannot answer.
- */
-export type ChatRequestMode = 'ask'
+/** Assistant searches as the signed-in person and uses their connected accounts. */
+export type ChatRequestMode = 'assistant'
export interface QueuedMessage {
id: string
@@ -36,6 +33,7 @@ export interface QueuedMessage {
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
requestMode?: ChatRequestMode
+ assistantSearch?: WorkspaceSearchFilters
}
export const ToolCallStatus = {
@@ -183,6 +181,7 @@ export interface ChatMessageContext {
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
+ requestMode?: 'agent' | 'assistant'
content: string
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
index d04cd899859..af31043f573 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
@@ -18,6 +18,7 @@ import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/conn
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
+import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal'
import {
ConnectServiceAccountModal,
useServiceAccountConnectTarget,
@@ -73,6 +74,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const availability = integrationAvailability.get(integration.type.toLowerCase())
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
const [oauthOpen, setOAuthOpen] = useState(false)
+ const [personalTokenOpen, setPersonalTokenOpen] = useState(false)
+ const personalTokenAvailable =
+ integration.type === 'gitlab' &&
+ (availability?.state === 'ready' || availability?.state === 'limited')
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
@@ -87,6 +92,8 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
* `providerId`s instead hides it from all of them.
*/
const connectedCredentials = useMemo(() => {
+ if (integration.type === 'gitlab')
+ return credentials.filter((c) => c.type === 'personal_token' && c.providerId === 'gitlab')
if (!oauthService) return []
return credentials.filter(
(c) =>
@@ -94,7 +101,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
c.providerId &&
credentialProviderMatchesService(c.providerId, oauthService)
)
- }, [credentials, oauthService])
+ }, [credentials, oauthService, integration.type])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId: oauthService?.serviceAccountProviderId,
@@ -116,11 +123,14 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const availableConnectMode = resolveAvailableConnectMode(connectMode, {
oauth: Boolean(oauthService) && oauthAvailable,
serviceAccount: hasServiceAccount,
+ personalToken: personalTokenAvailable,
})
if (!availableConnectMode) return
if (availableConnectMode === CONNECT_MODE.oauth) {
setOAuthOpen(true)
+ } else if (availableConnectMode === CONNECT_MODE.personalToken) {
+ setPersonalTokenOpen(true)
} else {
setServiceAccountOpen(true)
}
@@ -132,6 +142,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
oauthService,
oauthAvailable,
hasServiceAccount,
+ personalTokenAvailable,
permissionConfigLoading,
setConnectMode,
])
@@ -176,7 +187,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
Integrations
- {oauthService ? (
+ {personalTokenAvailable ? (
+ setPersonalTokenOpen(true)}>
+ Add personal token
+
+ ) : oauthService ? (
connectOptions.length > 1 ? (
+ {personalTokenAvailable && (
+
+ )}
{oauthService && oauthAvailable && (
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts
index c5c50fd6a01..73d92277a29 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts
@@ -4,10 +4,14 @@ import {
CONNECT_QUERY_PARAM,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
-const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const
+const CONNECT_MODE_VALUES = [
+ CONNECT_MODE.oauth,
+ CONNECT_MODE.serviceAccount,
+ CONNECT_MODE.personalToken,
+] as const
/**
- * Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on
+ * Typed parser for the ephemeral connection deep-link on
* the integration detail page. The param is read once to pre-open the matching
* connect modal, then stripped from the URL.
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx
new file mode 100644
index 00000000000..70e653f9ebe
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal.tsx
@@ -0,0 +1,123 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ SecretInput,
+} from '@sim/emcn'
+import { GitlabIcon } from '@/components/icons'
+import {
+ useCreateWorkspaceCredential,
+ useUpdateWorkspaceCredential,
+} from '@/hooks/queries/credentials'
+
+interface ConnectPersonalTokenModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ credentialId?: string
+ instanceUrl?: string
+ onConnected?: () => void
+}
+
+/** Personal connections use the existing credential modal and mutation conventions. */
+export function ConnectPersonalTokenModal(props: ConnectPersonalTokenModalProps) {
+ if (!props.open) return null
+ return (
+
+ )
+}
+
+function PersonalTokenForm({
+ open,
+ onOpenChange,
+ workspaceId,
+ credentialId,
+ instanceUrl,
+ onConnected,
+}: ConnectPersonalTokenModalProps) {
+ const [host, setHost] = useState(instanceUrl ? new URL(instanceUrl).host : 'gitlab.com')
+ const [token, setToken] = useState('')
+ const create = useCreateWorkspaceCredential()
+ const update = useUpdateWorkspaceCredential()
+ const pending = create.isPending || update.isPending
+ const error = (credentialId ? update.error : create.error)?.message
+ function submit() {
+ if (!host.trim() || !token.trim() || pending) return
+ const onSuccess = () => {
+ onConnected?.()
+ onOpenChange(false)
+ }
+ if (credentialId) update.mutate({ credentialId, apiToken: token.trim() }, { onSuccess })
+ else
+ create.mutate(
+ {
+ workspaceId,
+ type: 'personal_token',
+ providerId: 'gitlab',
+ apiToken: token.trim(),
+ domain: host.trim(),
+ },
+ { onSuccess }
+ )
+ }
+ return (
+
+ onOpenChange(false)}>
+ Connect your GitLab account
+
+
+ {credentialId ? (
+
+ ) : (
+
+ )}
+
+
+
+ {error}
+
+ onOpenChange(false)}
+ primaryAction={{
+ label: pending ? 'Connecting…' : credentialId ? 'Reconnect' : 'Connect',
+ onClick: submit,
+ disabled: pending || !host.trim() || !token.trim(),
+ }}
+ secondaryActions={[
+ {
+ label: 'Create a token',
+ onClick: () =>
+ window.open(
+ 'https://docs.gitlab.com/user/profile/personal_access_tokens/',
+ '_blank',
+ 'noopener,noreferrer'
+ ),
+ },
+ ]}
+ />
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
index 631d99be287..d01953901f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ComponentType, useEffect, useState } from 'react'
+import { type ComponentType, useState } from 'react'
import {
ChipModal,
ChipModalBody,
@@ -13,6 +13,11 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
+import {
+ resourceScopeFields,
+ resourceScopeFromOwner,
+ resourceScopeKey,
+} from '@/lib/core/resource-scope'
import {
AUTH_METHOD_FIELD_ID,
type ClientCredentialAccountDescriptor,
@@ -22,9 +27,9 @@ import {
} from '@/lib/credentials/client-credential-accounts/descriptors'
import { withBrandIcon } from '@/blocks/brand-icon'
import {
- useCreateWorkspaceCredential,
- useUpdateWorkspaceCredential,
-} from '@/hooks/queries/credentials'
+ useCreateScopedCredential,
+ useUpdateScopedCredential,
+} from '@/hooks/queries/scoped-credentials'
const logger = createLogger('ClientCredentialAccountModal')
@@ -74,7 +79,8 @@ function openDocs(url: string): void {
interface ClientCredentialAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
descriptor: ClientCredentialAccountDescriptor
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
@@ -99,10 +105,21 @@ interface ClientCredentialAccountModalProps {
* selecting a method shows only that branch's fields and gates submit on that
* branch's requirements, mirroring the server-side secret builder.
*/
-export function ClientCredentialAccountModal({
+export function ClientCredentialAccountModal(props: ClientCredentialAccountModalProps) {
+ if (!props.open) return null
+ return (
+
+ )
+}
+
+function ClientCredentialAccountModalForm({
open,
onOpenChange,
workspaceId,
+ organizationId,
descriptor,
serviceName,
serviceIcon: ServiceIcon,
@@ -116,16 +133,8 @@ export function ClientCredentialAccountModal({
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
- const createCredential = useCreateWorkspaceCredential()
- const updateCredential = useUpdateWorkspaceCredential()
-
- useEffect(() => {
- if (open) return
- setValues({})
- setDisplayName(initialDisplayName ?? '')
- setDescription(initialDescription ?? '')
- setError(null)
- }, [open, initialDisplayName, initialDescription])
+ const createCredential = useCreateScopedCredential()
+ const updateCredential = useUpdateScopedCredential()
const authMethodField = descriptor.fields.find((field) => field.id === AUTH_METHOD_FIELD_ID)
/**
@@ -185,6 +194,7 @@ export function ClientCredentialAccountModal({
}
if (credentialId) {
await updateCredential.mutateAsync({
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
credentialId,
...secretFields,
displayName: displayName.trim() || undefined,
@@ -192,7 +202,7 @@ export function ClientCredentialAccountModal({
})
} else {
const created = await createCredential.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
type: 'service_account',
providerId: descriptor.providerId,
...secretFields,
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
index 0c87db8c28d..76760f10e4b 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ComponentType, useEffect, useState } from 'react'
+import { type ComponentType, useState } from 'react'
import {
ChipModal,
ChipModalBody,
@@ -13,7 +13,12 @@ import {
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isApiClientError } from '@/lib/api/client/errors'
-import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
+import { type AtlassianProduct, serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
+import {
+ resourceScopeFields,
+ resourceScopeFromOwner,
+ resourceScopeKey,
+} from '@/lib/core/resource-scope'
import {
type ClientCredentialAccountProviderId,
getClientCredentialAccountDescriptor,
@@ -32,9 +37,9 @@ import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrat
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import { withBrandIcon } from '@/blocks/brand-icon'
import {
- useCreateWorkspaceCredential,
- useUpdateWorkspaceCredential,
-} from '@/hooks/queries/credentials'
+ useCreateScopedCredential,
+ useUpdateScopedCredential,
+} from '@/hooks/queries/scoped-credentials'
const logger = createLogger('ConnectServiceAccountModal')
@@ -107,8 +112,10 @@ function messageForAtlassianError(err: unknown): string {
interface ConnectServiceAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
serviceAccountProviderId: ServiceAccountProviderId
+ atlassianProduct?: AtlassianProduct
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/**
@@ -128,7 +135,7 @@ interface ConnectServiceAccountModalProps {
/**
* Connect-service-account modal mounted from the per-integration detail page.
* Self-contained: takes the resolved SA provider + service metadata from the
- * caller and submits via `useCreateWorkspaceCredential`. Branches the body
+ * caller and submits via `useCreateScopedCredential`. Branches the body
* based on `serviceAccountProviderId`:
*
* - `google-service-account`: JSON-paste + drag/drop. Validated client-side
@@ -141,7 +148,9 @@ export function ConnectServiceAccountModal({
open,
onOpenChange,
workspaceId,
+ organizationId,
serviceAccountProviderId,
+ atlassianProduct,
serviceName,
serviceIcon,
credentialId,
@@ -156,6 +165,7 @@ export function ConnectServiceAccountModal({
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
+ organizationId={organizationId}
descriptor={clientCredentialDescriptor}
serviceName={serviceName}
serviceIcon={serviceIcon}
@@ -173,6 +183,7 @@ export function ConnectServiceAccountModal({
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
+ organizationId={organizationId}
descriptor={tokenDescriptor}
serviceName={serviceName}
serviceIcon={serviceIcon}
@@ -189,6 +200,7 @@ export function ConnectServiceAccountModal({
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
+ organizationId={organizationId}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
@@ -199,9 +211,11 @@ export function ConnectServiceAccountModal({
if (serviceAccountProviderId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
return (
void
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/** When set, reconnect (rotate secrets on) this credential in place. */
@@ -246,10 +262,21 @@ interface ProviderModalProps {
* and validates against the shared `serviceAccountJsonSchema` so the same
* shape errors render here as in the server route.
*/
-function GoogleServiceAccountModal({
+function GoogleServiceAccountModal(props: ProviderModalProps) {
+ if (!props.open) return null
+ return (
+
+ )
+}
+
+function GoogleServiceAccountModalForm({
open,
onOpenChange,
workspaceId,
+ organizationId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
@@ -263,17 +290,8 @@ function GoogleServiceAccountModal({
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
- const createCredential = useCreateWorkspaceCredential()
- const updateCredential = useUpdateWorkspaceCredential()
-
- useEffect(() => {
- if (open) return
- setJsonInput('')
- setUploadedFileName(null)
- setDisplayName(initialDisplayName ?? '')
- setDescription(initialDescription ?? '')
- setError(null)
- }, [open, initialDisplayName, initialDescription])
+ const createCredential = useCreateScopedCredential()
+ const updateCredential = useUpdateScopedCredential()
/**
* Try to auto-populate display name from the JSON `client_email`. Silent on
@@ -328,6 +346,7 @@ function GoogleServiceAccountModal({
let connectedCredentialId = credentialId
if (credentialId) {
await updateCredential.mutateAsync({
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
credentialId,
serviceAccountJson: trimmed,
displayName: displayName.trim() || undefined,
@@ -335,7 +354,7 @@ function GoogleServiceAccountModal({
})
} else {
const created = await createCredential.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
type: 'service_account',
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
@@ -437,34 +456,39 @@ function GoogleServiceAccountModal({
* `error.code` to descriptive copy so users know whether the token, domain,
* or upstream availability is at fault.
*/
-function AtlassianServiceAccountModal({
+function AtlassianServiceAccountModal(
+ props: ProviderModalProps & { atlassianProduct?: AtlassianProduct }
+) {
+ if (!props.open) return null
+ return (
+
+ )
+}
+
+function AtlassianServiceAccountModalForm({
+ atlassianProduct,
open,
onOpenChange,
workspaceId,
+ organizationId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
initialDisplayName,
initialDescription,
onCreated,
-}: ProviderModalProps) {
+}: ProviderModalProps & { atlassianProduct?: AtlassianProduct }) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
- const createCredential = useCreateWorkspaceCredential()
- const updateCredential = useUpdateWorkspaceCredential()
-
- useEffect(() => {
- if (open) return
- setApiToken('')
- setDomain('')
- setDisplayName(initialDisplayName ?? '')
- setDescription(initialDescription ?? '')
- setError(null)
- }, [open, initialDisplayName, initialDescription])
+ const createCredential = useCreateScopedCredential()
+ const updateCredential = useUpdateScopedCredential()
const trimmedToken = apiToken.trim()
const normalizedDomain = normalizeAtlassianDomain(domain)
@@ -481,19 +505,22 @@ function AtlassianServiceAccountModal({
let connectedCredentialId = credentialId
if (credentialId) {
await updateCredential.mutateAsync({
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
credentialId,
apiToken: trimmedToken,
domain: normalizedDomain,
+
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
} else {
const created = await createCredential.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
type: 'service_account',
providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
apiToken: trimmedToken,
domain: normalizedDomain,
+ atlassianProduct,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
index 16daa225452..126b5e534ac 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ComponentType, useEffect, useState } from 'react'
+import { type ComponentType, useState } from 'react'
import {
ChipModal,
ChipModalBody,
@@ -12,6 +12,11 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
+import {
+ resourceScopeFields,
+ resourceScopeFromOwner,
+ resourceScopeKey,
+} from '@/lib/core/resource-scope'
import {
getTokenServiceAccountErrorMessage,
type TokenServiceAccountDescriptor,
@@ -19,9 +24,9 @@ import {
} from '@/lib/credentials/token-service-accounts/descriptors'
import { withBrandIcon } from '@/blocks/brand-icon'
import {
- useCreateWorkspaceCredential,
- useUpdateWorkspaceCredential,
-} from '@/hooks/queries/credentials'
+ useCreateScopedCredential,
+ useUpdateScopedCredential,
+} from '@/hooks/queries/scoped-credentials'
const logger = createLogger('TokenServiceAccountModal')
@@ -39,7 +44,8 @@ function openDocs(url: string): void {
interface TokenServiceAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
descriptor: TokenServiceAccountDescriptor
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
@@ -58,10 +64,21 @@ interface TokenServiceAccountModalProps {
* same create/update credential mutations as the other service-account modals.
* Server-side verification failures are mapped from the route's `error.code`.
*/
-export function TokenServiceAccountModal({
+export function TokenServiceAccountModal(props: TokenServiceAccountModalProps) {
+ if (!props.open) return null
+ return (
+
+ )
+}
+
+function TokenServiceAccountModalForm({
open,
onOpenChange,
workspaceId,
+ organizationId,
descriptor,
serviceName,
serviceIcon: ServiceIcon,
@@ -76,17 +93,8 @@ export function TokenServiceAccountModal({
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
- const createCredential = useCreateWorkspaceCredential()
- const updateCredential = useUpdateWorkspaceCredential()
-
- useEffect(() => {
- if (open) return
- setApiToken('')
- setDomain('')
- setDisplayName(initialDisplayName ?? '')
- setDescription(initialDescription ?? '')
- setError(null)
- }, [open, initialDisplayName, initialDescription])
+ const createCredential = useCreateScopedCredential()
+ const updateCredential = useUpdateScopedCredential()
const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
const domainField = descriptor.fields.find((field) => field.id === 'domain')
@@ -111,6 +119,7 @@ export function TokenServiceAccountModal({
}
if (credentialId) {
await updateCredential.mutateAsync({
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
credentialId,
...secretFields,
displayName: displayName.trim() || undefined,
@@ -119,7 +128,7 @@ export function TokenServiceAccountModal({
onCreated?.(credentialId)
} else {
const created = await createCredential.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })),
type: 'service_account',
providerId: descriptor.providerId,
...secretFields,
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
index fc42525d229..db3c2276fb7 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
@@ -1,15 +1,15 @@
'use client'
-import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { type ReactNode, useEffect, useMemo, useState } from 'react'
import {
Button,
Chip,
ChipDropdown,
type ChipDropdownOption,
ChipInput,
+ ChipModalField,
Code,
CopyCodeButton,
- Label,
SecretInput,
Wizard,
} from '@sim/emcn'
@@ -18,12 +18,17 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { SlackIcon } from '@/components/icons'
+import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import {
+ SLACK_MANAGED_USER_SCOPES,
+ SLACK_SEARCH_USER_SCOPES,
+} from '@/lib/credential-groups/slack-managed-user-scopes'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
- useCreateWorkspaceCredential,
- useUpdateWorkspaceCredential,
-} from '@/hooks/queries/credentials'
+ useCreateScopedCredential,
+ useUpdateScopedCredential,
+} from '@/hooks/queries/scoped-credentials'
import {
buildSlackManifest,
getSlackManagedUserAuthorizationManifestConfig,
@@ -86,7 +91,8 @@ function getAgentDescriptionError(description: string): string | null {
interface ConnectSlackBotModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- workspaceId: string
+ workspaceId?: string
+ organizationId?: string
/**
* When set, the modal reconnects (rotates secrets on) this existing credential
* instead of creating a new one — the id is reused so the Slack ingest URL
@@ -104,7 +110,7 @@ interface ConnectSlackBotModalProps {
/**
* One-time setup for a reusable custom Slack bot credential — the same guided
- * wizard as the legacy in-block setup, but it persists a workspace credential
+ * wizard as the legacy in-block setup, but it persists a scoped credential
* instead of writing sub-block values. The credential id is pre-generated so the
* ingest URL `/api/webhooks/slack/custom/{id}` (and the manifest that embeds it)
* can be shown up front; the credential is created on the final step once the
@@ -114,25 +120,31 @@ export function ConnectSlackBotModal({
open,
onOpenChange,
workspaceId,
+ organizationId,
credentialId: reconnectCredentialId,
initialDisplayName,
initialDescription,
onCreated,
}: ConnectSlackBotModalProps) {
+ const scope = resourceScopeFromOwner({ workspaceId, organizationId })
+ const searchOnly = scope.kind === 'organization'
const isReconnect = Boolean(reconnectCredentialId)
const [step, setStep] = useState(0)
const [credentialId, setCredentialId] = useState(() => reconnectCredentialId ?? generateId())
const [appName, setAppName] = useState(initialDisplayName ?? '')
const [appDescription, setAppDescription] = useState(initialDescription ?? '')
const [selected, setSelected] = useState>(() => new Set(ALL_CAPABILITIES))
+ const [memberAccess, setMemberAccess] = useState<'search' | 'workflow'>(
+ isReconnect ? 'workflow' : 'search'
+ )
const [slashCommands, setSlashCommands] = useState([])
const [signingSecret, setSigningSecret] = useState('')
const [botToken, setBotToken] = useState('')
const [createError, setCreateError] = useState(null)
const [created, setCreated] = useState(false)
- const createCredential = useCreateWorkspaceCredential()
- const updateCredential = useUpdateWorkspaceCredential()
+ const createCredential = useCreateScopedCredential()
+ const updateCredential = useUpdateScopedCredential()
useEffect(() => {
if (open) return
@@ -140,6 +152,7 @@ export function ConnectSlackBotModal({
setAppName(initialDisplayName ?? '')
setAppDescription(initialDescription ?? '')
setSelected(new Set(ALL_CAPABILITIES))
+ setMemberAccess(isReconnect ? 'workflow' : 'search')
setSlashCommands([])
setSigningSecret('')
setBotToken('')
@@ -158,43 +171,63 @@ export function ConnectSlackBotModal({
// Shared server-side derivation: uses the app public base (not
// window.location.origin) so Slack's servers can reach it.
- const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId])
+ const requestUrl = buildSlackCustomBotRequestUrl(credentialId)
const descriptionError = getAgentDescriptionError(appDescription)
- const slashCommandsError = getSlashCommandsError(slashCommands)
+ const slashCommandsError = searchOnly ? null : getSlashCommandsError(slashCommands)
const manifestConfigurationError = descriptionError ?? slashCommandsError
const manifestJson = useMemo(() => {
if (manifestConfigurationError) return ''
- const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id)
- ? getSlackManagedUserAuthorizationManifestConfig(getBaseUrl())
+ const capabilities = searchOnly ? ALL_CAPABILITIES : selected
+ const managedUserAuthorization = capabilities.has(
+ SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id
+ )
+ ? getSlackManagedUserAuthorizationManifestConfig(
+ getBaseUrl(),
+ searchOnly || memberAccess === 'search'
+ ? SLACK_SEARCH_USER_SCOPES
+ : SLACK_MANAGED_USER_SCOPES
+ )
: undefined
- const manifest = buildSlackManifest(selected, {
+ const manifest = buildSlackManifest(capabilities, {
appName: appName.trim() || DEFAULT_APP_NAME,
webhookUrl: requestUrl,
description: appDescription,
- slashCommands: slashCommands.map(({ command, description, usageHint }) => ({
- command,
- description,
- usageHint,
- })),
+ slashCommands: (searchOnly ? [] : slashCommands).map(
+ ({ command, description, usageHint }) => ({
+ command,
+ description,
+ usageHint,
+ })
+ ),
...(managedUserAuthorization ? { managedUserAuthorization } : {}),
})
return JSON.stringify(manifest, null, 2)
- }, [manifestConfigurationError, selected, appName, appDescription, slashCommands, requestUrl])
+ }, [
+ manifestConfigurationError,
+ selected,
+ appName,
+ appDescription,
+ slashCommands,
+ requestUrl,
+ memberAccess,
+ searchOnly,
+ ])
- const capabilityIds = useMemo(() => [...selected], [selected])
- const setCapabilityIds = useCallback((next: string[]) => setSelected(new Set(next)), [])
+ const capabilityIds = [...selected]
+ const setCapabilityIds = (next: string[]) => setSelected(new Set(next))
const isPending = createCredential.isPending || updateCredential.isPending
- const runCreate = useCallback(async () => {
+ const runCreate = async () => {
setCreateError(null)
try {
if (isReconnect) {
// Rotate secrets on the existing credential in place — same id, so the
// Slack app's Request URL and any shares stay intact.
await updateCredential.mutateAsync({
+ ...resourceScopeFields(scope),
credentialId,
signingSecret: signingSecret.trim(),
botToken: botToken.trim(),
@@ -203,7 +236,7 @@ export function ConnectSlackBotModal({
})
} else {
await createCredential.mutateAsync({
- workspaceId,
+ ...resourceScopeFields(scope),
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
id: credentialId,
@@ -219,52 +252,39 @@ export function ConnectSlackBotModal({
setCreateError(getErrorMessage(err, 'Could not connect the Slack bot.'))
logger.error('Failed to add custom Slack bot credential', err)
}
- }, [
- isReconnect,
- updateCredential,
- createCredential,
- workspaceId,
- credentialId,
- signingSecret,
- botToken,
- appName,
- appDescription,
- onCreated,
- ])
+ }
- // Create the credential once when the final step is first reached (reachable
- // only after both secrets are entered). A ref guards against re-firing on
- // failure — retry is manual via the "Try again" button.
- const attemptedRef = useRef(false)
- useEffect(() => {
- if (step !== DONE_STEP) {
- attemptedRef.current = false
- return
- }
- if (attemptedRef.current) return
- attemptedRef.current = true
- void runCreate()
- }, [step, runCreate])
+ const handleStepChange = (nextStep: number) => {
+ setStep(nextStep)
+ if (nextStep === DONE_STEP && step !== DONE_STEP) void runCreate()
+ }
return (
{/* Bot name is required so the credential name, the manifest app name, and
uniqueness all use the user's choice — never the shared Slack team name
fallback, which collides for a second bot in the same workspace. */}
0 && !descriptionError && !slashCommandsError}
>
@@ -287,7 +309,13 @@ export function ConnectSlackBotModal({
-
+
)
@@ -318,6 +346,7 @@ function SubStep({ n, children }: SubStepProps) {
}
interface StepConfigureProps {
+ searchOnly: boolean
appName: string
onAppNameChange: (next: string) => void
appDescription: string
@@ -328,8 +357,11 @@ interface StepConfigureProps {
slashCommandsError: string | null
capabilityIds: string[]
onCapabilityIdsChange: (next: string[]) => void
+ memberAccess: 'search' | 'workflow'
+ onMemberAccessChange: (access: 'search' | 'workflow') => void
}
function StepConfigure({
+ searchOnly,
appName,
onAppNameChange,
appDescription,
@@ -340,62 +372,73 @@ function StepConfigure({
slashCommandsError,
capabilityIds,
onCapabilityIdsChange,
+ memberAccess,
+ onMemberAccessChange,
}: StepConfigureProps) {
const allSelected = capabilityIds.length === CUSTOM_BOT_CAPABILITIES.length
return (
-
-
-
- Bot name
-
- onAppNameChange(e.target.value)}
- placeholder={DEFAULT_APP_NAME}
- />
-
-
-
- Description
-
-
onAppDescriptionChange(e.target.value)}
- placeholder="Optional — shown on the bot's Slack profile"
- maxLength={140}
- error={Boolean(descriptionError)}
+ <>
+
+
+ {!searchOnly && (
+
+
+
+ )}
+ {!searchOnly && capabilityIds.includes(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id) && (
+ {
+ if (value === 'search' || value === 'workflow') onMemberAccessChange(value)
+ }}
+ options={[
+ { value: 'search', label: 'Search documents' },
+ { value: 'workflow', label: 'Workflow tools' },
+ ]}
+ hint='Choose the same access when configuring this app for member accounts.'
/>
- {descriptionError && (
- {descriptionError}
- )}
-
-
-
Additional permissions
-
- {allSelected && (
-
- All additional permissions enabled — the bot can read messages, react, access files and
- users, and people can authorize it through Credential Groups.
-
- )}
-
-
-
+ )}
+ >
)
}
@@ -421,18 +464,10 @@ function SlashCommandsEditor({ commands, onChange, error }: SlashCommandsEditorP
}
return (
-
-
- Slash commands (optional)
- = 50}
- >
- Add
-
-
+
+ = 50}>
+ Add
+
{commands.length > 0 && (
{commands.map((entry, index) => (
@@ -476,8 +511,7 @@ function SlashCommandsEditor({ commands, onChange, error }: SlashCommandsEditorP
))}
)}
- {error && {error}
}
-
+
)
}
@@ -493,10 +527,7 @@ function StepCreate({ manifestJson }: StepCreateProps) {
@@ -577,20 +608,20 @@ interface SecretFieldProps {
}
function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
return (
-
- {label}
+
-
+
)
}
interface StepDoneProps {
+ searchOnly: boolean
pending: boolean
created: boolean
error: string | null
onRetry: () => void
}
-function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
+function StepDone({ searchOnly, pending, created, error, onRetry }: StepDoneProps) {
if (pending) {
return (
@@ -603,9 +634,7 @@ function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
return (
{error}
-
- Try again
-
+
Try again
)
}
@@ -613,10 +642,13 @@ function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
return (
-
Bot connected
+
+ {searchOnly ? 'Slack app connected' : 'Bot connected'}
+
- It's now selectable in Slack triggers and actions across this workspace. Click Done to
- finish.
+ {searchOnly
+ ? 'Click Done to verify member access.'
+ : "It's now selectable in Slack triggers and actions across this workspace. Click Done to finish."}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx
index 08dae05a2cf..0f06c325ce3 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx
@@ -1,22 +1,35 @@
import type { ReactNode } from 'react'
-import { RESOURCE_LIST_GRID } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import {
+ RESOURCE_LIST_GRID,
+ RESOURCE_LIST_STACK,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
interface IntegrationSectionProps {
label: string
+ description?: string
+ layout?: 'grid' | 'list'
children: ReactNode
}
/**
* Labeled section used throughout the integrations surface: the shared
* {@link SettingsSection} label/divider chrome wrapped around the shared
- * responsive card grid, so the integrations list, the connected credentials
+ * resource grid or list, so the integrations list, the connected credentials
* list, and the integration detail templates cannot drift from settings.
*/
-export function IntegrationSection({ label, children }: IntegrationSectionProps) {
+export function IntegrationSection({
+ label,
+ description,
+ layout = 'grid',
+ children,
+}: IntegrationSectionProps) {
return (
- {children}
+ {description && (
+ {description}
+ )}
+ {children}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connect-route.ts b/apps/sim/app/workspace/[workspaceId]/integrations/connect-route.ts
index 31ac7efd97e..f85a85f0ef5 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connect-route.ts
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connect-route.ts
@@ -1,7 +1,7 @@
/**
* Shared protocol for deep-linking to an integration detail page with a
* pre-opened connect modal. Owned by the integrations route; consumed by
- * the detail page's `?connect=oauth|service-account` query handler.
+ * the detail page's `connect` query handler.
*/
export const CONNECT_QUERY_PARAM = 'connect' as const
@@ -9,6 +9,7 @@ export const CONNECT_QUERY_PARAM = 'connect' as const
export const CONNECT_MODE = {
oauth: 'oauth',
serviceAccount: 'service-account',
+ personalToken: 'personal-token',
} as const
export type ConnectMode = (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE]
@@ -16,6 +17,7 @@ export type ConnectMode = (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE]
interface ConnectModeAvailability {
oauth: boolean
serviceAccount: boolean
+ personalToken?: boolean
}
/** `null` lets callers preserve the deep-link while deployment and block visibility hydrate. */
@@ -23,6 +25,7 @@ export function resolveAvailableConnectMode(
connectMode: ConnectMode,
availability: ConnectModeAvailability
): ConnectMode | null {
+ if (connectMode === CONNECT_MODE.personalToken && availability.personalToken) return connectMode
if (connectMode === CONNECT_MODE.oauth && availability.oauth) return connectMode
if (connectMode === CONNECT_MODE.serviceAccount && availability.serviceAccount) {
return connectMode
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index 1279b4489b3..c9bfb6937dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -33,6 +33,7 @@ import {
RESOURCE_TILE_BASE,
RESOURCE_TILE_PLAIN,
} from '@/app/workspace/[workspaceId]/components/resource-tile'
+import { ConnectPersonalTokenModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-personal-token-modal'
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
@@ -207,10 +208,12 @@ export function ConnectedCredentialDetail({
const actions =
credential && isAdmin ? (
<>
- {(credential.type === 'oauth' || credential.type === 'service_account') && (
+ {(credential.type === 'oauth' ||
+ credential.type === 'service_account' ||
+ credential.type === 'personal_token') && (
setReconnectOpen(true)
: credential.providerId === 'quickbooks'
? () => setReconnectOpen(true)
@@ -226,9 +229,11 @@ export function ConnectedCredentialDetail({
Reconnect
)}
-
setIsShareModalOpen(true)}>
- Share
-
+ {credential.type !== 'personal_token' && (
+
setIsShareModalOpen(true)}>
+ Share
+
+ )}
setShowDeleteConfirmDialog(true)}
disabled={deleteCredential.isPending}
@@ -311,7 +316,14 @@ export function ConnectedCredentialDetail({
/>
-
+ {credential.type !== 'personal_token' && (
+
+ )}
+ {credential.type === 'personal_token' && credential.instanceUrl && (
+
+
+
+ )}
-
+ {credential.type !== 'personal_token' && (
+
+ )}
+ {credential.type === 'personal_token' && (
+
+ )}
{credential.type === 'service_account' && credential.providerId && (
credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
+ () =>
+ credentials.filter(
+ (c) => c.type === 'oauth' || c.type === 'service_account' || c.type === 'personal_token'
+ ),
[credentials]
)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 7520a10b93e..1de2b420b96 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -56,7 +56,7 @@ import {
documentParsers,
documentUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params'
-import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
+import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index 53701c0dc96..5224b2d1d19 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -3,7 +3,7 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
- Button,
+ Chip,
ChipConfirmModal,
type ChipConfirmTextSegment,
ChipDatePicker,
@@ -16,7 +16,6 @@ import {
cellIconNodeClass,
chipContentGap,
chipContentLabelClass,
- chipVariants,
cn,
FloatingTooltip,
isTextClipped,
@@ -101,6 +100,7 @@ import {
kbDocumentSortParams,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
+import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { BrandIcon } from '@/blocks/brand-icon'
@@ -432,6 +432,7 @@ export function KnowledgeBase({
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
+ const canDeleteBase = canDeleteKnowledgeBase(knowledgeBase, userPermissions)
const { data: connectors = EMPTY_CONNECTORS, isLoading: isLoadingConnectors } =
useConnectorList(id)
@@ -588,9 +589,6 @@ export function KnowledgeBase({
)
}
- /**
- * Handles retrying a failed document processing
- */
const handleRetryDocument = (docId: string) => {
updateDocument(docId, {
processingStatus: 'pending',
@@ -736,7 +734,7 @@ export function KnowledgeBase({
* Handles deleting the entire knowledge base
*/
const handleDeleteKnowledgeBase = () => {
- if (!knowledgeBase) return
+ if (!knowledgeBase || !canDeleteBase) return
deleteKnowledgeBaseMutation(
{ knowledgeBaseId: id },
@@ -981,14 +979,11 @@ export function KnowledgeBase({
disabled: !userPermissions.canEdit,
onClick: () => setShowTagsModal(true),
},
- {
- label: 'Delete',
- icon: Trash,
- disabled: !userPermissions.canEdit,
- onClick: () => setShowDeleteDialog(true),
- },
]
: []),
+ ...(canDeleteBase
+ ? [{ label: 'Delete', icon: Trash, onClick: () => setShowDeleteDialog(true) }]
+ : []),
],
},
],
@@ -1008,6 +1003,7 @@ export function KnowledgeBase({
kbRename.startRename,
userPermissions.canEdit,
userPermissions.isLoading,
+ canDeleteBase,
]
)
@@ -1067,20 +1063,18 @@ export function KnowledgeBase({
() => (
-
+
Status
{enabledFilter !== 'all' && (
- {
setEnabledFilter('all')
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
- className='-mr-1 h-auto px-1 py-0.5 text-[var(--text-muted)] text-caption hover-hover:text-[var(--text-secondary)]'
>
Clear
-
+
)}
setShowConnectorsModal(true)}
- className={cn(chipVariants({ variant: 'filled' }), 'max-w-[180px]')}
+ className='max-w-[180px]'
+ leftAdornment={
+
+ {syncInFlight ? (
+
+ ) : (
+ ConnectorIcon &&
+ )}
+ {connector.status !== 'active' && !syncInFlight && (
+
+ )}
+
+ }
>
-
- {syncInFlight ? (
-
- ) : (
- ConnectorIcon &&
- )}
- {connector.status !== 'active' && !syncInFlight && (
-
- )}
-
-
- {def?.name || connector.connectorType}
-
-
+ {def?.name || connector.connectorType}
+
)
})}
>
@@ -1259,7 +1252,7 @@ export function KnowledgeBase({
),
},
- size: { label: formatFileSize(doc.fileSize) },
+ size: { label: formatFileSize(doc.fileSize, { includeBytes: true }) },
tokens: {
label:
doc.processingStatus === 'completed'
@@ -1454,13 +1447,14 @@ export function KnowledgeBase({
chunkingConfig={knowledgeBase?.chunkingConfig}
/>
- {showAddConnectorModal && (
+ {showAddConnectorModal && knowledgeBase && (
)}
@@ -1498,6 +1492,7 @@ export function KnowledgeBase({
workspaceId={workspaceId}
knowledgeBaseId={id}
connectors={connectors}
+ isSearchIndex={knowledgeBase?.isSearchIndex}
isLoading={isLoadingConnectors}
canEdit={userPermissions.canEdit}
className='mt-0'
@@ -1553,6 +1548,13 @@ export function KnowledgeBase({
? () => handleViewDocumentTags(contextMenuDocument)
: undefined
}
+ onRetry={
+ contextMenuDocument?.processingStatus === 'failed' &&
+ selectedDocumentCount === 1 &&
+ userPermissions.canEdit
+ ? () => handleRetryDocument(contextMenuDocument.id)
+ : undefined
+ }
onDelete={
contextMenuDocument
? selectedDocumentCount > 1
@@ -1768,17 +1770,9 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio
return (
-
+
Filter by tags
- {activeCount > 0 && (
- onChange([])}
- >
- Clear all
-
- )}
+ {activeCount > 0 && onChange([])}>Clear all }
)}
-
removeFilter(entry.id)}
aria-label='Remove tag filter'
- >
-
-
+ leftIcon={X}
+ />
{entry.tagSlot && (
-
-
- Add filter
-
+
+
+ Add filter
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx
new file mode 100644
index 00000000000..3eadd3aeb1f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx
@@ -0,0 +1,422 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ComponentProps } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Credential } from '@/lib/oauth'
+import type { ConnectorConfigFieldsProps } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields'
+
+const mocks = vi.hoisted(() => ({
+ create: vi.fn(),
+ accountsQuery: vi.fn(),
+ configFields: vi.fn(),
+ credentials: [] as Pick
[],
+ memberAccess: true,
+ mirroredAccess: true,
+ accountState: 'missing' as
+ | 'missing'
+ | 'loading'
+ | 'error'
+ | 'inactive'
+ | 'unconfigured'
+ | 'ready',
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+ usePathname: () => '/workspace/workspace-1/search',
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useWorkspaceHostContext: () => ({
+ ownerBilling: {},
+ features: { knowledgeSourceMirroredAccess: mocks.mirroredAccess },
+ }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useUserPermissionsContext: () => ({ canAdmin: true }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope', () => ({
+ useConnectorScope: (
+ scope?:
+ | { kind: 'workspace'; workspaceId: string }
+ | { kind: 'organization'; organizationId: string }
+ ) => ({
+ scope: scope ?? { kind: 'workspace', workspaceId: 'workspace-1' },
+ canAdmin: true,
+ memberAccessAvailable: mocks.memberAccess,
+ mirroredAccessAvailable: mocks.mirroredAccess,
+ hasMaxAccess: true,
+ }),
+}))
+vi.mock('@/hooks/use-member-access', () => ({
+ useMemberAccessAvailable: () => mocks.memberAccess,
+}))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ integrationAvailability: new Map([
+ ['slack', { oauthAvailable: true, state: 'ready' }],
+ ['slack_v2', { oauthAvailable: true, state: 'ready' }],
+ ]),
+ oauthServiceAvailability: new Map(
+ [
+ 'confluence',
+ 'google-drive',
+ 'google_drive',
+ 'google-email',
+ 'google-calendar',
+ 'jira',
+ 'github-repositories',
+ ].map((providerId) => [providerId, true])
+ ),
+ isIntegrationAvailabilityReady: true,
+ isIntegrationAvailabilityLoading: false,
+ integrationAvailabilityError: null,
+ refetchIntegrationAvailability: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ useCreateConnector: () => ({ mutate: mocks.create, isPending: false }),
+}))
+vi.mock('@/hooks/queries/source-accounts', () => ({
+ useSourceAccounts: (scope?: { workspaceId?: string; organizationId?: string }) => {
+ mocks.accountsQuery(scope?.organizationId ?? scope?.workspaceId)
+ return {
+ data:
+ mocks.accountState === 'loading' || mocks.accountState === 'error'
+ ? undefined
+ : {
+ credentialGroup:
+ mocks.accountState === 'missing'
+ ? null
+ : {
+ status: mocks.accountState === 'inactive' ? 'inactive' : 'active',
+ options: [
+ {
+ provider: 'slack',
+ status: 'active',
+ configurationStatus:
+ mocks.accountState === 'unconfigured' ? 'missing' : 'ready',
+ },
+ ],
+ },
+ },
+ isLoading: mocks.accountState === 'loading',
+ isPending: mocks.accountState === 'loading',
+ isSuccess: mocks.accountState !== 'loading' && mocks.accountState !== 'error',
+ isError: mocks.accountState === 'error',
+ isFetching: mocks.accountState === 'loading',
+ refetch: vi.fn(),
+ error: mocks.accountState === 'error' ? new Error('Could not load accounts') : null,
+ }
+ },
+}))
+vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
+ useOAuthCredentials: () => ({
+ data: mocks.credentials,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.fn() }))
+vi.mock('@/hooks/use-credential-refresh-triggers', () => ({
+ useCredentialRefreshTriggers: vi.fn(),
+}))
+vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({
+ ConnectOAuthModal: () => null,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal',
+ () => ({
+ ConnectServiceAccountModal: () => null,
+ useServiceAccountConnectTarget: () => null,
+ })
+)
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields', () => ({
+ ConnectorConfigFields: (props: ConnectorConfigFieldsProps) => {
+ mocks.configFields(props)
+ return null
+ },
+}))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields', () => ({
+ useConnectorConfigFields: () => ({
+ sourceConfig: {},
+ setSourceConfig: vi.fn(),
+ canonicalModes: {},
+ setCanonicalModes: vi.fn(),
+ canonicalGroups: [],
+ isFieldVisible: () => true,
+ isFieldPopulated: () => true,
+ handleFieldChange: vi.fn(),
+ toggleCanonicalMode: vi.fn(),
+ resolveSourceConfig: () => ({}),
+ }),
+}))
+
+import { AddConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal'
+import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
+import { useConnectorSetupStore } from '@/stores/connector-setup/store'
+
+let root: Root
+let container: HTMLDivElement
+
+async function render(props: Partial> = {}) {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+}
+
+function button(label: string): HTMLButtonElement {
+ const match = Array.from(document.querySelectorAll('button')).find(
+ (node) => node.textContent?.trim() === label
+ )
+ if (!match) throw new Error(`Missing button: ${label}`)
+ return match
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.memberAccess = true
+ mocks.mirroredAccess = true
+ mocks.accountState = 'missing'
+ mocks.credentials = [{ id: 'credential-1', name: 'Source account', type: 'oauth' }]
+ useConnectorSetupStore.getState().reset()
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+describe('Slack member setup readiness', () => {
+ it('uses the organization account container and returns to organization setup', async () => {
+ await render({ scope: { kind: 'organization', organizationId: 'org-1' } })
+ expect(mocks.accountsQuery).toHaveBeenCalledWith('org-1')
+ const setup = Array.from(document.querySelectorAll('a')).find(
+ (link) => link.textContent?.trim() === 'Set up Slack'
+ )
+ expect(setup?.getAttribute('href')).toBe(
+ '/o/org-1/settings/integrations?search-setup=slack&connectedAccounts=slack'
+ )
+ expect(document.body.textContent).not.toContain('Create & Invite')
+ })
+ it.each(['missing', 'loading', 'error', 'inactive', 'unconfigured'] as const)(
+ 'refuses creation while workspace Slack setup is %s',
+ async (state) => {
+ mocks.accountState = state
+ await render()
+ expect(document.body.textContent).not.toContain('Create & Invite')
+ expect(document.body.textContent).toContain('Connection method')
+ expect(document.body.textContent).not.toContain('Browse with')
+ expect(document.body.textContent).not.toContain('Sync documents with')
+ expect(document.body.textContent).not.toContain('Document details (optional)')
+ expect(button('Cancel')).toBeEnabled()
+ expect(mocks.create).not.toHaveBeenCalled()
+ expect(mocks.accountsQuery).toHaveBeenCalledWith('workspace-1')
+ if (state === 'error') {
+ expect(document.body.textContent).toContain('Could not load accounts')
+ expect(button('Try again')).toBeEnabled()
+ expect(document.body.textContent).not.toContain('Set up Slack')
+ }
+ }
+ )
+
+ it.each([true, false])(
+ 'allows member creation once Slack is ready (Search: %s)',
+ async (isSearchIndex) => {
+ mocks.accountState = 'ready'
+ await render({ isSearchIndex })
+ expect(document.body.textContent).toContain('Browse with')
+ expect(document.body.textContent).toContain('Sync documents with')
+ expect(document.body.textContent).toContain('Document details (optional)')
+ expect(button('Create & Invite')).toBeEnabled()
+ await act(async () => button('Create & Invite').click())
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({ connectorType: 'slack', accessMode: 'members' }),
+ expect.any(Object)
+ )
+ }
+ )
+
+ it('also blocks unconfigured Slack members in a general knowledge base', async () => {
+ await render({ isSearchIndex: false })
+ expect(document.body.textContent).not.toContain('Create & Invite')
+ expect(document.body.textContent).toContain('Set up Slack')
+ })
+
+ it('reveals the configuration once Slack setup becomes ready', async () => {
+ await render()
+ expect(document.body.textContent).toContain('Set up Slack')
+ expect(document.body.textContent).not.toContain('Browse with')
+ mocks.accountState = 'ready'
+ await render()
+ expect(document.body.textContent).not.toContain('Set up Slack')
+ expect(document.body.textContent).toContain('Browse with')
+ expect(button('Create & Invite')).toBeEnabled()
+ })
+
+ it('does not require Slack setup for a workspace-mode connection', async () => {
+ await render({ isSearchIndex: false, initialAccessMode: 'workspace' })
+ expect(button('Connect & Sync')).toBeEnabled()
+ expect(mocks.accountsQuery).not.toHaveBeenCalledWith('workspace-1')
+ })
+})
+
+describe('Search methods requiring member identity', () => {
+ it('keeps organization connected-account setup in members mode', async () => {
+ await render({
+ scope: { kind: 'organization', organizationId: 'org-1' },
+ initialConnectorType: 'confluence',
+ initialAccessMode: 'admin',
+ membersOnly: true,
+ })
+ expect(document.body.textContent).not.toContain('Connection method')
+ expect(document.body.textContent).not.toContain('Choose another source')
+ await act(async () => button('Add source').click())
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectorType: 'confluence',
+ accessMode: 'members',
+ }),
+ expect.any(Object)
+ )
+ })
+
+ it('blocks a new Confluence admin connection when member identity is unavailable', async () => {
+ mocks.memberAccess = false
+ await render({ initialConnectorType: 'confluence', initialAccessMode: 'admin' })
+ expect(button('Connect & Sync')).toBeDisabled()
+ expect(document.querySelector('[role="radiogroup"]')).toBeNull()
+ await act(async () => button('Connect & Sync').click())
+ expect(mocks.create).not.toHaveBeenCalled()
+ })
+
+ it('allows Confluence central syncing once both feature gates are available', async () => {
+ await render({ initialConnectorType: 'confluence', initialAccessMode: 'admin' })
+ expect(button('Connect & Sync')).toBeEnabled()
+ await act(async () => button('Connect & Sync').click())
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({ connectorType: 'confluence', accessMode: 'admin' }),
+ expect.any(Object)
+ )
+ expect(mocks.accountsQuery).not.toHaveBeenCalledWith('workspace-1')
+ })
+
+ it('also requires member identity for Confluence admin mode in general knowledge bases', async () => {
+ mocks.memberAccess = false
+ await render({
+ isSearchIndex: false,
+ initialConnectorType: 'confluence',
+ initialAccessMode: 'admin',
+ })
+ expect(button('Connect & Sync')).toBeDisabled()
+ expect(button('Admin or service account')).toBeDisabled()
+ expect(button('Workspace')).toBeEnabled()
+ })
+})
+
+describe('Service-account source fields', () => {
+ it.each([
+ {
+ name: 'connected members with no browsing account',
+ browse: null,
+ content: null,
+ show: false,
+ },
+ { name: 'connected members browsing with OAuth', browse: 'oauth', content: null, show: false },
+ {
+ name: 'connected members browsing with a service account',
+ browse: 'service',
+ content: null,
+ show: false,
+ },
+ {
+ name: 'a dedicated OAuth indexing account',
+ browse: 'service',
+ content: 'oauth',
+ show: false,
+ },
+ {
+ name: 'a dedicated service indexing account',
+ browse: 'oauth',
+ content: 'service',
+ show: true,
+ },
+ ])(
+ 'only offers an impersonation subject for $name when applicable',
+ async ({ browse, content, show }) => {
+ mocks.credentials = [
+ { id: 'oauth', name: 'Google account', type: 'oauth' },
+ { id: 'service', name: 'Indexing account', type: 'service_account' },
+ ]
+ const setupDraftKey = 'drive-setup'
+ useConnectorSetupStore.getState().saveDraft(setupDraftKey, {
+ sourceConfig: {},
+ canonicalModes: {},
+ accessMode: 'members',
+ credentialId: browse,
+ contentCredentialId: content,
+ disabledTagIds: [],
+ savedAt: Date.now(),
+ })
+ await render({
+ initialConnectorType: 'google_drive',
+ setupDraftKey,
+ scope: { kind: 'organization', organizationId: 'org-1' },
+ })
+
+ const fields: ConnectorConfigFieldsProps = mocks.configFields.mock.lastCall![0]
+ const subjectField = googleDriveConnectorMeta.configFields.find(
+ (field) => field.id === 'adminEmail'
+ )!
+ expect(fields.isFieldVisible(subjectField)).toBe(show)
+ expect(
+ fields.isFieldVisible(
+ googleDriveConnectorMeta.configFields.find((field) => field.id === 'folderSelector')!
+ )
+ ).toBe(true)
+ }
+ )
+
+ it.each(['admin', 'workspace'] as const)(
+ 'keeps the service-account subject available for %s indexing',
+ async (accessMode) => {
+ mocks.credentials = [{ id: 'service', name: 'Indexing account', type: 'service_account' }]
+ await render({
+ initialConnectorType: 'google_drive',
+ initialAccessMode: accessMode,
+ isSearchIndex: accessMode === 'admin',
+ })
+
+ const fields: ConnectorConfigFieldsProps = mocks.configFields.mock.lastCall![0]
+ expect(
+ fields.isFieldVisible(
+ googleDriveConnectorMeta.configFields.find((field) => field.id === 'adminEmail')!
+ )
+ ).toBe(true)
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index 239df07c125..3647fff9111 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -1,12 +1,11 @@
'use client'
-import { useMemo, useState } from 'react'
+import { useId, useState } from 'react'
import {
- ArrowRight,
- Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
+ Chip,
ChipCombobox,
ChipInput,
ChipModal,
@@ -16,58 +15,77 @@ import {
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
- cn,
- handleKeyboardActivation,
OverflowText,
- Search,
} from '@sim/emcn'
-import { ArrowLeft, Plus } from '@sim/emcn/icons'
-import { useParams } from 'next/navigation'
-import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
+import { ArrowLeft, ChevronDown, ChevronRight, Plus, Search } from '@sim/emcn/icons'
+import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope'
+import { getIntegrationsForCredentialProvider } from '@/lib/integrations/credential-display'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
+ getServiceAccountProviderForProviderId,
type OAuthProvider,
} from '@/lib/oauth'
+import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors'
+import { SIM_SEARCH_SYNC_INTERVAL_MINUTES } from '@/lib/sim-search/constants'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
+import {
+ ConnectServiceAccountModal,
+ useServiceAccountConnectTarget,
+} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import {
+ derivedAclCapFieldIds,
+ isConnectorFieldRequired,
+} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access'
import {
ConnectorAccessField,
type ConnectorAccessSelection,
+ ConnectorContentCredentialField,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
-import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
import {
BROWSE_WITH_HINT,
+ connectorSyncFrequencyHint,
SYNC_INTERVALS,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
+import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope'
import {
- memberCapFieldIds,
- useConnectorMemberGroupOptions,
-} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
-import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { getBlock } from '@/blocks'
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { withBrandIcon } from '@/blocks/brand-icon'
-import { getTileIconColorClass } from '@/blocks/icon-color'
+import { getConnectorApiKeyConfig, isConnectorCredentialTypeAllowed } from '@/connectors/auth'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import type { ConnectorMeta } from '@/connectors/types'
+import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
+import { useSourceAccounts } from '@/hooks/queries/source-accounts'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
-import { useMemberAccessAvailable } from '@/hooks/use-member-access'
+import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+import { useConnectorSetupStore } from '@/stores/connector-setup/store'
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_META_REGISTRY)
const WORKSPACE_ACCESS: ConnectorAccessSelection = { accessMode: 'workspace' }
interface AddConnectorModalProps {
+ scope?: ResourceScope
open: boolean
onOpenChange: (open: boolean) => void
onConnectorTypeChange?: (connectorType: string | null) => void
knowledgeBaseId: string
+ isSearchIndex?: boolean
initialConnectorType?: string | null
+ initialAccessMode?: ConnectorAccessSelection['accessMode']
+ membersOnly?: boolean
+ initialSyncIntervalMinutes?: number
+ onCreated?: (connectorType: string) => void
+ setupDraftKey?: string
}
type Step = 'select-type' | 'configure'
@@ -77,54 +95,148 @@ export function AddConnectorModal({
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
+ isSearchIndex = false,
initialConnectorType,
+ initialAccessMode = 'workspace',
+ membersOnly = false,
+ initialSyncIntervalMinutes = 1440,
+ onCreated,
+ setupDraftKey,
+ scope: explicitScope,
}: AddConnectorModalProps) {
- const [step, setStep] = useState(() => (initialConnectorType ? 'configure' : 'select-type'))
- const [selectedType, setSelectedType] = useState(initialConnectorType ?? null)
- const [syncInterval, setSyncInterval] = useState(1440)
- const [selectedCredentialId, setSelectedCredentialId] = useState(null)
- const [access, setAccess] = useState(WORKSPACE_ACCESS)
- const [disabledTagIds, setDisabledTagIds] = useState>(() => new Set())
+ const metadataId = useId()
+ const initialType =
+ initialConnectorType &&
+ (!isSearchIndex || CONNECTOR_META_REGISTRY[initialConnectorType]?.search)
+ ? initialConnectorType
+ : null
+ const { scope, canAdmin, memberAccessAvailable, mirroredAccessAvailable, hasMaxAccess } =
+ useConnectorScope(explicitScope)
+ const owner = resourceScopeFields(scope)
+ const [draft] = useState(() =>
+ setupDraftKey ? useConnectorSetupStore.getState().getDraft(setupDraftKey) : undefined
+ )
+ const [step, setStep] = useState(() => (initialType ? 'configure' : 'select-type'))
+ const [selectedType, setSelectedType] = useState(initialType)
+ const [syncInterval, setSyncInterval] = useState(
+ isSearchIndex ? SIM_SEARCH_SYNC_INTERVAL_MINUTES : initialSyncIntervalMinutes
+ )
+ const [selectedCredentialId, setSelectedCredentialId] = useState(
+ draft?.credentialId ?? null
+ )
+ const [contentCredentialId, setContentCredentialId] = useState(
+ draft?.contentCredentialId ?? null
+ )
+ const [access, setAccess] = useState(() => ({
+ accessMode:
+ (membersOnly ? 'members' : draft?.accessMode) ??
+ (isSearchIndex && initialAccessMode === 'workspace'
+ ? initialType && CONNECTOR_META_REGISTRY[initialType]?.auth.mode === 'apiKey'
+ ? 'admin'
+ : 'members'
+ : initialAccessMode),
+ }))
+ const [disabledTagIds, setDisabledTagIds] = useState>(
+ () => new Set(draft?.disabledTagIds)
+ )
+ const [showMetadata, setShowMetadata] = useState(false)
const [error, setError] = useState(null)
const [showOAuthModal, setShowOAuthModal] = useState(false)
+ const [showServiceAccountModal, setShowServiceAccountModal] = useState(false)
const [apiKeyValue, setApiKeyValue] = useState('')
+ const [useApiKey, setUseApiKey] = useState(!isSearchIndex)
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
- const { workspaceId } = useParams<{ workspaceId: string }>()
- const { ownerBilling } = useWorkspaceHostContext()
- const { canAdmin } = useUserPermissionsContext()
- const memberAccessAvailable = useMemberAccessAvailable()
+ useOAuthReturnForKBConnectors(
+ isSearchIndex ? knowledgeBaseId : undefined,
+ setSelectedCredentialId,
+ selectedType ?? undefined,
+ scope
+ )
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
- const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling)
-
const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null
- const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
+ const docsUrl = isSearchIndex ? connectorConfig?.searchDocsUrl : undefined
+ const setupGuideActions = docsUrl
+ ? [
+ {
+ label: 'Setup guide',
+ onClick: () => window.open(docsUrl, '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : undefined
const isMembersMode = access.accessMode === 'members'
- const groupOptions = useConnectorMemberGroupOptions({
- workspaceId,
- connectorConfig,
- enabled: canAdmin && memberAccessAvailable,
- })
- /** Several groups collect this provider's accounts: the admin has to say which. */
- const membersChoiceOpen =
- isMembersMode && groupOptions.needsChoice && !access.credentialGroupOptionId
- const hiddenCapFieldIds = useMemo(
- () => memberCapFieldIds(connectorConfig, access.accessMode),
- [connectorConfig, access.accessMode]
+ const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined
+ const isApiKeyMode =
+ connectorConfig?.auth.mode === 'apiKey' || Boolean(apiKeyConfig && !isMembersMode && useApiKey)
+ const {
+ integrationAvailability,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ isIntegrationAvailabilityFetching,
+ isIntegrationAvailabilityLoading,
+ integrationAvailabilityError,
+ refetchIntegrationAvailability,
+ } = usePermissionConfig()
+ const { admin: allowAdmin, members: allowMembers } = connectorConfig
+ ? getConnectorAccessAvailability(connectorConfig, integrationAvailability, {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ })
+ : { admin: false, members: false }
+ const needsSlackSetup = selectedType === 'slack' && isMembersMode
+ const { data: sourceAccounts } = useSourceAccounts(
+ canAdmin && needsSlackSetup ? scope : undefined
)
+ const slackConfigured =
+ sourceAccounts?.credentialGroup?.status === 'active' &&
+ sourceAccounts.credentialGroup.options.some(
+ (option) =>
+ option.provider === 'slack' &&
+ option.status === 'active' &&
+ option.configurationStatus === 'ready'
+ )
+ const slackSetupRequired = needsSlackSetup && !slackConfigured
+ const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode)
/** True when the connector declares its key optional (public sources need none). */
const isApiKeyOptional =
connectorConfig?.auth.mode === 'apiKey' && connectorConfig.auth.optional === true
- const connectorProviderId = useMemo(
- () =>
- connectorConfig && connectorConfig.auth.mode === 'oauth'
- ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
- : null,
- [connectorConfig]
- )
+ const connectorProviderId =
+ connectorConfig?.auth.mode === 'oauth'
+ ? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
+ : null
+
+ const serviceAccountProviderId = connectorProviderId
+ ? getServiceAccountProviderForProviderId(connectorProviderId)
+ : undefined
+ const requiresServiceAccount =
+ access.accessMode === 'admin' &&
+ connectorConfig?.auth.mode === 'oauth' &&
+ !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')
+ const serviceAccountTarget = useServiceAccountConnectTarget({
+ serviceAccountProviderId:
+ (isSearchIndex || requiresServiceAccount) &&
+ (serviceAccountProviderId === 'google-service-account' ||
+ serviceAccountProviderId === 'atlassian-service-account')
+ ? serviceAccountProviderId
+ : undefined,
+ serviceName: connectorConfig?.name,
+ serviceIcon: connectorConfig?.icon,
+ })
+ const deploymentType = connectorProviderId
+ ? (getIntegrationsForCredentialProvider(connectorProviderId)[0]?.type ?? selectedType)
+ : selectedType
+ const deploymentState = deploymentType
+ ? integrationAvailability.get(deploymentType.toLowerCase())?.state
+ : undefined
+ const canConnectServiceAccount =
+ serviceAccountTarget &&
+ !serviceAccountTarget.hidden &&
+ (deploymentState === 'ready' || deploymentState === 'limited')
const {
data: rawCredentials = [],
@@ -132,27 +244,25 @@ export function AddConnectorModal({
refetch: refetchCredentials,
} = useOAuthCredentials(connectorProviderId ?? undefined, {
enabled: Boolean(connectorConfig) && !isApiKeyMode,
- workspaceId,
+ ...owner,
})
- /**
- * The credential list also returns the provider's service accounts, but
- * `ConnectorAuthConfig` has no service-account mode: the sync engine resolves
- * connector tokens through `refreshAccessTokenIfNeeded`, which passes no scopes
- * and drops the `cloudId`/`domain`/`authStyle` a service account resolves with.
- * Offering them here would surface credentials no connector can authenticate
- * with, so — like a workflow picker that has not opted in via
- * `allowServiceAccounts` — list OAuth accounts only.
- */
- const credentials = useMemo(
- () => rawCredentials.filter((cred) => cred.type !== 'service_account'),
- [rawCredentials]
- )
-
- useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', workspaceId)
+ useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', scope)
+ const credentials = rawCredentials.filter(
+ (credential) =>
+ !connectorConfig ||
+ isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, credential.type)
+ )
+ const canConnectOAuth =
+ connectorConfig &&
+ isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')
const effectiveCredentialId =
- selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
+ selectedCredentialId && credentials.some((credential) => credential.id === selectedCredentialId)
+ ? selectedCredentialId
+ : credentials.length === 1
+ ? credentials[0].id
+ : null
const {
sourceConfig,
@@ -160,21 +270,75 @@ export function AddConnectorModal({
canonicalModes,
setCanonicalModes,
canonicalGroups,
- isFieldVisible,
+ isFieldVisible: isConfigFieldVisible,
isFieldPopulated,
handleFieldChange,
toggleCanonicalMode,
resolveSourceConfig,
- } = useConnectorConfigFields({ connectorConfig })
+ } = useConnectorConfigFields({
+ connectorConfig,
+ accessMode: access.accessMode,
+ initialSourceConfig: draft?.sourceConfig,
+ initialCanonicalModes: draft?.canonicalModes,
+ })
+
+ const indexingCredentialId = isApiKeyMode
+ ? null
+ : isMembersMode
+ ? contentCredentialId
+ : effectiveCredentialId
+ const indexingCredential = credentials.find(
+ (credential) => credential.id === indexingCredentialId
+ )
+ const isFieldVisible = (field: ConnectorConfigField) =>
+ isConfigFieldVisible(field) &&
+ (connectorConfig?.auth.mode !== 'oauth' ||
+ connectorConfig.auth.serviceAccountSubjectFieldId !== field.id ||
+ indexingCredential?.type === 'service_account')
+
+ const showCredentialPicker =
+ !isMembersMode ||
+ connectorConfig?.supportsSeparateContentCredential ||
+ connectorConfig?.configFields.some(
+ (field) => field.type === 'selector' && isFieldVisible(field)
+ )
+
+ const saveSetup = () => {
+ if (!setupDraftKey) return
+ useConnectorSetupStore.getState().saveDraft(setupDraftKey, {
+ sourceConfig,
+ canonicalModes,
+ accessMode: access.accessMode,
+ credentialId: effectiveCredentialId,
+ contentCredentialId,
+ disabledTagIds: Array.from(disabledTagIds),
+ savedAt: Date.now(),
+ })
+ }
+
+ const closeSetup = (nextOpen: boolean) => {
+ if (!nextOpen && setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey)
+ onOpenChange(nextOpen)
+ }
const handleSelectType = (type: string) => {
+ if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey)
setSelectedType(type)
setSourceConfig({})
setSelectedCredentialId(null)
- setAccess(WORKSPACE_ACCESS)
+ setContentCredentialId(null)
+ setAccess(
+ isSearchIndex
+ ? {
+ accessMode: CONNECTOR_META_REGISTRY[type]?.auth.mode === 'apiKey' ? 'admin' : 'members',
+ }
+ : WORKSPACE_ACCESS
+ )
setApiKeyValue('')
+ setUseApiKey(!isSearchIndex)
setApiKeyFocused(false)
setDisabledTagIds(new Set())
+ setShowMetadata(false)
setCanonicalModes({})
setError(null)
setSearchTerm('')
@@ -182,47 +346,32 @@ export function AddConnectorModal({
onConnectorTypeChange?.(type)
}
- const toggleTagDefinition = (tagId: string) => {
- setDisabledTagIds((prev) => {
- const next = new Set(prev)
- if (prev.has(tagId)) {
- next.delete(tagId)
- } else {
- next.add(tagId)
- }
- return next
- })
- }
-
- const canSubmit = useMemo(() => {
- if (!connectorConfig) return false
- if (isApiKeyMode) {
- if (!isApiKeyOptional && !apiKeyValue.trim()) return false
- } else if (isMembersMode) {
- if (membersChoiceOpen) return false
- } else {
- if (!effectiveCredentialId) return false
- }
-
- for (const field of connectorConfig.configFields) {
- if (!field.required) continue
- if (!isFieldVisible(field)) continue
- if (hiddenCapFieldIds.has(field.id)) continue
- if (!isFieldPopulated(field)) return false
- }
- return true
- }, [
- connectorConfig,
- isApiKeyMode,
- isMembersMode,
- membersChoiceOpen,
- hiddenCapFieldIds,
- isApiKeyOptional,
- apiKeyValue,
- effectiveCredentialId,
- isFieldVisible,
- isFieldPopulated,
- ])
+ const hasRequiredCredential = isApiKeyMode
+ ? isApiKeyOptional || Boolean(apiKeyValue.trim())
+ : isMembersMode || Boolean(effectiveCredentialId)
+ const hasSearchAccess =
+ !isSearchIndex ||
+ Boolean(
+ connectorConfig?.search &&
+ access.accessMode !== 'workspace' &&
+ (!isMembersMode || allowMembers) &&
+ (access.accessMode !== 'admin' || allowAdmin)
+ )
+ const canSubmit = Boolean(
+ connectorConfig &&
+ hasRequiredCredential &&
+ hasSearchAccess &&
+ (access.accessMode !== 'admin' || allowAdmin) &&
+ (!isMembersMode || allowMembers) &&
+ !slackSetupRequired &&
+ connectorConfig.configFields.every(
+ (field) =>
+ !isConnectorFieldRequired(field, connectorConfig, access.accessMode) ||
+ !isFieldVisible(field) ||
+ hiddenCapFieldIds.has(field.id) ||
+ isFieldPopulated(field)
+ )
+ )
const handleSubmit = () => {
if (!selectedType || !canSubmit) return
@@ -252,6 +401,7 @@ export function AddConnectorModal({
{
knowledgeBaseId,
connectorType: selectedType,
+ accessMode: access.accessMode,
...(isApiKeyMode
? apiKeyValue.trim()
? { apiKey: apiKeyValue }
@@ -259,16 +409,16 @@ export function AddConnectorModal({
: isMembersMode
? {
accessMode: 'members' as const,
- credentialGroupId: access.credentialGroupId,
- credentialGroupOptionId: access.credentialGroupOptionId,
+ credentialId: contentCredentialId ?? undefined,
}
- : { credentialId: effectiveCredentialId! }),
+ : { accessMode: access.accessMode, credentialId: effectiveCredentialId! }),
sourceConfig: finalSourceConfig,
syncIntervalMinutes: syncInterval,
},
{
onSuccess: () => {
- onOpenChange(false)
+ closeSetup(false)
+ onCreated?.(selectedType)
},
onError: (err) => {
setError(err.message)
@@ -277,37 +427,41 @@ export function AddConnectorModal({
)
}
- const filteredEntries = useMemo(() => {
- const term = searchTerm.toLowerCase().trim()
- if (!term) return CONNECTOR_ENTRIES
- return CONNECTOR_ENTRIES.filter(
- ([, config]) =>
- config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
- )
- }, [searchTerm])
+ const term = searchTerm.toLowerCase().trim()
+ const entries = isSearchIndex
+ ? CONNECTOR_ENTRIES.filter(([, config]) => config.search)
+ : CONNECTOR_ENTRIES
+ const filteredEntries = term
+ ? entries.filter(
+ ([, config]) =>
+ config.name.toLowerCase().includes(term) ||
+ config.description.toLowerCase().includes(term)
+ )
+ : entries
return (
<>
- onOpenChange(false)}>
+ closeSetup(false)}>
{step === 'configure' ? (
- {
- setStep('select-type')
- onConnectorTypeChange?.('')
- }}
- >
-
-
+ {!membersOnly && (
+ {
+ if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey)
+ setStep('select-type')
+ onConnectorTypeChange?.('')
+ }}
+ />
+ )}
{`Configure ${connectorConfig?.name}`}
) : (
@@ -316,7 +470,13 @@ export function AddConnectorModal({
{step === 'select-type' ? (
@@ -337,184 +497,300 @@ export function AddConnectorModal({
/>
))}
{filteredEntries.length === 0 && (
-
+
{CONNECTOR_ENTRIES.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
-
+
)}
) : connectorConfig ? (
<>
- {!isApiKeyMode && memberAccessAvailable && (
-
- )}
-
- {isApiKeyMode ? (
-
- setApiKeyValue(e.target.value)}
- onFocus={() => setApiKeyFocused(true)}
- onBlur={() => setApiKeyFocused(false)}
- placeholder={
- connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder
- ? connectorConfig.auth.placeholder
- : 'Enter API key'
- }
- />
-
- ) : (
-
- ({
- label: cred.name || cred.provider,
- value: cred.id,
- icon: withBrandIcon(connectorConfig.icon),
- })
- ),
- {
- label:
- credentials.length > 0
- ? `Connect another ${connectorConfig.name} account`
- : `Connect ${connectorConfig.name} account`,
- value: '__connect_new__',
- icon: Plus,
- onSelect: () => setShowOAuthModal(true),
- },
- ]}
- value={effectiveCredentialId ?? undefined}
- onChange={(value) => setSelectedCredentialId(value)}
- onOpenChange={(isOpen) => {
- if (isOpen) void refetchCredentials()
- }}
- placeholder={`Select ${connectorConfig.name} account`}
- isLoading={credentialsLoading}
+ {integrationAvailabilityError && (
+
+ void refetchIntegrationAvailability()}
+ variant='inline'
/>
)}
-
-
- isFieldVisible(field) && !hiddenCapFieldIds.has(field.id)
- }
- onFieldChange={handleFieldChange}
- onToggleCanonicalMode={toggleCanonicalMode}
- disabled={isCreating}
- />
-
- {connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
-
-
- {connectorConfig.tagDefinitions.map((tagDef) => (
-
toggleTagDefinition(tagDef.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
+ {!membersOnly &&
+ (memberAccessAvailable || mirroredAccessAvailable || slackSetupRequired) && (
+
+ )}
+
+ {!slackSetupRequired && (
+ <>
+ {connectorConfig.auth.mode === 'oauth' && apiKeyConfig && !isMembersMode && (
+
+ {
+ setUseApiKey(value === 'apiKey')
+ setApiKeyValue('')
+ setSelectedCredentialId(null)
}}
- >
- e.stopPropagation()}
- onCheckedChange={(checked) => {
- setDisabledTagIds((prev) => {
- const next = new Set(prev)
- if (checked) {
- next.delete(tagDef.id)
- } else {
- next.add(tagDef.id)
- }
- return next
+ />
+
+ )}
+ {isApiKeyMode ? (
+
+ setApiKeyValue(e.target.value)}
+ onFocus={() => setApiKeyFocused(true)}
+ onBlur={() => setApiKeyFocused(false)}
+ placeholder={apiKeyConfig?.placeholder || 'Enter API key'}
+ />
+
+ ) : showCredentialPicker ? (
+
+ ({
+ label: cred.name || cred.provider,
+ value: cred.id,
+ icon: withBrandIcon(connectorConfig.icon),
})
- }}
- />
-
- {tagDef.displayName}
-
-
- ({tagDef.fieldType})
-
+ ),
+ ...(canConnectOAuth
+ ? [
+ {
+ label:
+ credentials.length > 0
+ ? `Connect another ${connectorConfig.name} account`
+ : `Connect ${connectorConfig.name} account`,
+ value: '__connect_new__',
+ icon: Plus,
+ onSelect: () => {
+ saveSetup()
+ setShowOAuthModal(true)
+ },
+ },
+ ]
+ : []),
+ ...(canConnectServiceAccount
+ ? [
+ {
+ label: serviceAccountTarget.label,
+ value: '__service_account__',
+ icon: Plus,
+ onSelect: () => setShowServiceAccountModal(true),
+ },
+ ]
+ : []),
+ ]}
+ value={effectiveCredentialId ?? undefined}
+ onChange={(value) => setSelectedCredentialId(value)}
+ onOpenChange={(isOpen) => {
+ if (isOpen) void refetchCredentials()
+ }}
+ placeholder={
+ canConnectOAuth
+ ? `Select ${connectorConfig.name} account`
+ : 'Select a service account'
+ }
+ isLoading={credentialsLoading || isIntegrationAvailabilityLoading}
+ disabled={!isIntegrationAvailabilityReady}
+ />
+
+ ) : null}
+
+ {isMembersMode && connectorConfig.supportsSeparateContentCredential && (
+
({
+ value: credential.id,
+ label: credential.name || credential.provider,
+ }))}
+ isLoading={credentialsLoading}
+ disabled={isCreating}
+ />
+ )}
+
+
+ isFieldVisible(field) && !hiddenCapFieldIds.has(field.id)
+ }
+ onFieldChange={handleFieldChange}
+ onToggleCanonicalMode={toggleCanonicalMode}
+ disabled={isCreating}
+ />
+
+ {connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
+ <>
+
+ setShowMetadata((visible) => !visible)}
+ >
+ Document details (optional)
+
- ))}
-
-
- )}
+ {showMetadata && (
+
+
+ {connectorConfig.tagDefinitions.map((tagDef) => (
+
+ {
+ setDisabledTagIds((prev) => {
+ const next = new Set(prev)
+ if (checked) {
+ next.delete(tagDef.id)
+ } else {
+ next.add(tagDef.id)
+ }
+ return next
+ })
+ }}
+ />
+
+
+ ({tagDef.fieldType})
+
+
+ ))}
+
+
+ )}
+ >
+ )}
-
- setSyncInterval(Number(val))}
- >
- {SYNC_INTERVALS.map((interval) => (
-
- {interval.label}
- {interval.requiresMax && !hasMaxAccess && }
-
- ))}
-
-
+
setSyncInterval(Number(val))}
+ >
+ {SYNC_INTERVALS.map((interval) => (
+
+ {interval.label}
+ {interval.requiresMax && !hasMaxAccess && }
+
+ ))}
+
+
+ )}
-
{error}
+
{error}
+ >
+ )}
>
) : null}
- {step === 'configure' && (
-
onOpenChange(false)}
- primaryAction={{
- label: isCreating
- ? isMembersMode
- ? 'Creating…'
- : 'Connecting…'
- : isMembersMode
- ? 'Create & Invite'
- : 'Connect & Sync',
- onClick: handleSubmit,
- disabled: !canSubmit || isCreating,
- }}
- />
- )}
+ {step === 'configure' &&
+ (slackSetupRequired ? (
+ closeSetup(false)}
+ secondaryActions={setupGuideActions}
+ defaultAction='none'
+ />
+ ) : (
+ closeSetup(false)}
+ secondaryActions={setupGuideActions}
+ primaryAction={{
+ label: isCreating
+ ? isMembersMode
+ ? 'Creating…'
+ : 'Connecting…'
+ : isMembersMode
+ ? scope.kind === 'organization'
+ ? 'Add source'
+ : 'Create & Invite'
+ : 'Connect & Sync',
+ onClick: handleSubmit,
+ disabled: !canSubmit || isCreating,
+ }}
+ />
+ ))}
+ {showServiceAccountModal && canConnectServiceAccount && (
+
+ )}
{showOAuthModal &&
connectorConfig &&
connectorConfig.auth.mode === 'oauth' &&
@@ -525,15 +801,15 @@ export function AddConnectorModal({
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
- consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={connectorProviderId}
serviceId={connectorConfig.auth.provider}
providerId={connectorProviderId}
+ docsUrl={docsUrl}
requiredScopes={getCanonicalScopesForProvider(connectorProviderId)}
- workspaceId={workspaceId}
+ {...owner}
knowledgeBaseId={knowledgeBaseId}
connectorType={selectedType ?? undefined}
/>
@@ -549,41 +825,15 @@ interface ConnectorTypeCardProps {
}
function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
- const Icon = config.icon
- const brandBg = getBlock(type)?.bgColor ?? null
-
return (
- }
+ title={config.name}
+ description={config.description}
onClick={onClick}
- >
-
-
-
-
-
-
-
+ clickLabel={config.name}
+ navigable
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx
new file mode 100644
index 00000000000..a8340126da1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx
@@ -0,0 +1,248 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ComponentProps } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ accounts: vi.fn(),
+ configured: false,
+ loading: false,
+ retrying: false,
+ error: null as Error | null,
+ refetch: vi.fn(),
+}))
+
+vi.mock('@/hooks/queries/source-accounts', () => ({
+ useSourceAccounts: (scope?: { kind: 'workspace' | 'organization' }) => {
+ mocks.accounts(scope)
+ return {
+ data: {
+ credentialGroup: mocks.configured
+ ? {
+ status: 'active',
+ options: [{ provider: 'slack', status: 'active', configurationStatus: 'ready' }],
+ }
+ : null,
+ },
+ isLoading: mocks.loading,
+ isPending: mocks.loading,
+ isError: Boolean(mocks.error),
+ isSuccess: !mocks.loading && !mocks.error,
+ isFetching: mocks.loading || mocks.retrying,
+ error: mocks.error,
+ refetch: mocks.refetch,
+ }
+ },
+}))
+
+import { ConnectorAccessField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field'
+import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
+import { gitlabConnectorMeta } from '@/connectors/gitlab/meta'
+import { slackConnectorMeta } from '@/connectors/slack/meta'
+
+let root: Root
+let container: HTMLDivElement
+const onChange = vi.fn()
+
+async function render(props: Partial> = {}) {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+}
+
+function radio(label: string): HTMLButtonElement {
+ const match = Array.from(container.querySelectorAll('[role="radio"]')).find(
+ (node) => node.textContent === label
+ )
+ if (!match) throw new Error(`Missing connection method: ${label}`)
+ return match
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.configured = false
+ mocks.loading = false
+ mocks.retrying = false
+ mocks.error = null
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+describe('connection method selection', () => {
+ it('offers supported methods to admins without changing their contract values', async () => {
+ await render()
+ expect(container.textContent).toContain('Connection method')
+ expect(radio('Member accounts')).toHaveAttribute('aria-checked', 'true')
+ expect(container.querySelectorAll('[role="radio"]')).toHaveLength(2)
+ await act(async () => radio('Admin or service account').click())
+ expect(onChange).toHaveBeenCalledWith({ accessMode: 'admin' })
+ })
+
+ it('summarizes a single supported method without a selector', async () => {
+ await render({ connectorConfig: gitlabConnectorMeta, value: { accessMode: 'admin' } })
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull()
+ expect(container.textContent).toContain('Admin or service account')
+ expect(container.textContent).toContain(
+ 'Each person sees only documents they can open in GitLab.'
+ )
+ })
+
+ it('explains the Confluence identity connection even with central syncing', async () => {
+ await render({ value: { accessMode: 'admin' }, allowMembers: false })
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull()
+ expect(container.textContent).toContain(
+ 'Teammates still connect their Confluence accounts to confirm their identity.'
+ )
+ expect(container.textContent).toContain(
+ 'Each person sees only documents they can open in Confluence.'
+ )
+ })
+
+ it('shows ordinary members a summary without editable or disabled choices', async () => {
+ await render({ canAdmin: false, footer: Apply changes })
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull()
+ expect(container.textContent).toContain('Member accounts')
+ expect(container.textContent).toContain('Each teammate connects their Confluence account.')
+ expect(container.querySelector('button')).toBeNull()
+ expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
+ })
+
+ it('keeps the workspace method for general knowledge bases', async () => {
+ await render({ value: { accessMode: 'workspace' }, allowWorkspace: true })
+ expect(radio('Workspace')).toHaveAttribute('aria-checked', 'true')
+ expect(container.textContent).toContain(
+ 'Everyone in this workspace can search these documents.'
+ )
+ await act(async () => radio('Member accounts').click())
+ expect(onChange).toHaveBeenCalledWith({ accessMode: 'members' })
+ })
+
+ it.each([
+ { current: 'members', target: 'workspace', label: 'Member accounts', targetLabel: 'Workspace' },
+ {
+ current: 'admin',
+ target: 'members',
+ label: 'Admin or service account',
+ targetLabel: 'Member accounts',
+ },
+ { current: 'workspace', target: 'members', label: 'Workspace', targetLabel: 'Member accounts' },
+ ] as const)(
+ 'keeps recovery from unavailable $current to $target',
+ async ({ current, target, label, targetLabel }) => {
+ await render({
+ value: { accessMode: current },
+ allowWorkspace: target === 'workspace',
+ allowMembers: target === 'members',
+ allowAdmin: false,
+ })
+ expect(radio(label)).toHaveAttribute('aria-checked', 'true')
+ expect(radio(label)).toBeDisabled()
+ await act(async () => radio(label).click())
+ expect(onChange).not.toHaveBeenCalled()
+ expect(radio(targetLabel)).toBeEnabled()
+ await act(async () => radio(targetLabel).click())
+ expect(onChange).toHaveBeenCalledWith({ accessMode: target })
+ }
+ )
+
+ it('keeps available choices disabled during an in-flight change', async () => {
+ await render({ disabled: true })
+ expect(radio('Member accounts')).toBeDisabled()
+ expect(radio('Admin or service account')).toBeDisabled()
+ await act(async () => radio('Admin or service account').click())
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('keeps the current method readable if no replacement is allowed', async () => {
+ await render({ value: { accessMode: 'admin' }, allowMembers: false, allowAdmin: false })
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull()
+ expect(container.textContent).toContain('Admin or service account')
+ })
+})
+
+describe('Slack setup continuity', () => {
+ it('keeps the setup link and draft callback when the method selector is hidden', async () => {
+ const onNavigate = vi.fn()
+ await render({
+ connectorConfig: slackConnectorMeta,
+ allowAdmin: false,
+ searchSetupSource: 'slack',
+ onSetupNavigate: onNavigate,
+ footer: Apply changes ,
+ })
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull()
+ const link = container.querySelector('a')
+ const target = new URL(link?.getAttribute('href') ?? '', 'http://localhost')
+ expect(target.pathname).toBe('/workspace/workspace-1/settings/credential-groups')
+ expect(target.searchParams.get('search-setup')).toBe('slack')
+ expect(target.searchParams.get('credential-group-provider')).toBe('slack')
+ link?.addEventListener('click', (event) => event.preventDefault())
+ await act(async () => link?.click())
+ expect(onNavigate).toHaveBeenCalledOnce()
+ expect(container.textContent).toContain('Apply changes')
+ expect(mocks.accounts).toHaveBeenLastCalledWith({
+ kind: 'workspace',
+ workspaceId: 'workspace-1',
+ })
+ })
+
+ it.each(['loading', 'configured'] as const)('hides the Slack detour while %s', async (state) => {
+ mocks.loading = state === 'loading'
+ mocks.configured = state === 'configured'
+ await render({ connectorConfig: slackConnectorMeta, allowAdmin: false })
+ expect(container.querySelector('a')).toBeNull()
+ if (state === 'loading') expect(container.textContent).toContain('Checking Slack setup…')
+ })
+
+ it('retries a failed check without treating it as missing Slack configuration', async () => {
+ mocks.error = new Error('Could not load workspace accounts')
+ await render({ connectorConfig: slackConnectorMeta, allowAdmin: false })
+ expect(container.textContent).toContain('Could not load workspace accounts')
+ expect(container.querySelector('a')).toBeNull()
+ const retry = container.querySelector('button')
+ expect(retry?.textContent).toBe('Try again')
+ await act(async () => retry?.click())
+ expect(mocks.refetch).toHaveBeenCalledOnce()
+
+ mocks.error = null
+ await render({ connectorConfig: slackConnectorMeta, allowAdmin: false })
+ expect(container.querySelector('a')).not.toBeNull()
+ })
+
+ it('locks the retry action while the failed check is being retried', async () => {
+ mocks.error = new Error('Could not load workspace accounts')
+ mocks.retrying = true
+ await render({ connectorConfig: slackConnectorMeta, allowAdmin: false })
+ expect(container.querySelector('a')).toBeNull()
+ expect(container.querySelector('button')).toBeDisabled()
+ expect(container.querySelector('button')?.textContent).toBe('Retrying…')
+ })
+
+ it('does not fetch or show Slack setup controls to ordinary members', async () => {
+ mocks.error = new Error('Could not load workspace accounts')
+ await render({ connectorConfig: slackConnectorMeta, allowAdmin: false, canAdmin: false })
+ expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
+ expect(container.querySelector('a, button')).toBeNull()
+ expect(container.textContent).not.toContain('Could not load workspace accounts')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx
index e8a2fa4cdef..2050a7b5144 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx
@@ -1,129 +1,250 @@
'use client'
import type { ReactNode } from 'react'
-import { ButtonGroup, ButtonGroupItem, ChipCombobox, ChipModalField } from '@sim/emcn'
import {
- type ConnectorMemberGroupOptions,
- decodeConnectorMemberGroupOption,
- encodeConnectorMemberGroupOption,
-} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
+ ButtonGroup,
+ ButtonGroupItem,
+ ChipCombobox,
+ ChipLink,
+ ChipModalField,
+ type ComboboxOption,
+} from '@sim/emcn'
+import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
+import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope'
+import { slackSearchSetupHref } from '@/lib/sim-search/setup-navigation'
+import { connectorMemberProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
import type { ConnectorMeta } from '@/connectors/types'
+import { useSourceAccounts } from '@/hooks/queries/source-accounts'
-/** What the caller chose; `members` may name the option the connector crawls with. */
export interface ConnectorAccessSelection {
- accessMode: 'workspace' | 'members'
- credentialGroupId?: string
- credentialGroupOptionId?: string
+ accessMode: ConnectorAccessMode
+}
+
+interface ConnectorContentCredentialFieldProps {
+ credentialId: string | null
+ onChange: (credentialId: string | null) => void
+ options: ComboboxOption[]
+ isLoading: boolean
+ disabled?: boolean
+}
+
+/** A dedicated source account supplies content; member accounts supply visibility only. */
+export function ConnectorContentCredentialField({
+ credentialId,
+ onChange,
+ options,
+ isLoading,
+ disabled,
+}: ConnectorContentCredentialFieldProps) {
+ return (
+
+ onChange(value === '__connected_members__' ? null : value)}
+ isLoading={isLoading}
+ disabled={disabled}
+ placeholder='Choose an account'
+ />
+
+ )
}
interface ConnectorAccessFieldProps {
+ workspaceId?: string
+ scope?: ResourceScope
connectorConfig: ConnectorMeta
value: ConnectorAccessSelection
onChange: (value: ConnectorAccessSelection) => void
- /** From `useConnectorMemberGroupOptions`; shared with the modal so both agree on what is required. */
- groupOptions: ConnectorMemberGroupOptions
- /** Only an admin may put a connector into members mode. */
+ /** Only an admin may move a connector out of workspace mode. */
canAdmin: boolean
disabled?: boolean
- /** Whether per-member access may be chosen; false leaves only the way back to workspace access. */
+ /** Whether member accounts may be chosen; an existing selection remains visible for recovery. */
allowMembers?: boolean
- /**
- * Whether the connector already syncs per member, so any matching group may
- * be chosen, not only when several make the choice necessary.
- */
- canRebind?: boolean
+ /** Whether administrator access may be chosen; it needs a connector that mirrors source permissions. */
+ allowAdmin?: boolean
+ allowWorkspace?: boolean
/** Rendered under the selection, for a caller that applies the change with its own control. */
footer?: ReactNode
+ searchSetupSource?: 'slack'
+ onSetupNavigate?: () => void
}
-/**
- * The Access section of a connector's settings: sync as the workspace, or
- * crawl once per member so each person sees only what the source lets them
- * read. Per-member access needs nothing from the admin: a Credential Group is
- * found or created for the connector's provider, everyone in the workspace is
- * invited, and each person connects their own account. Only a workspace with
- * several matching groups is asked which one to use.
- */
+function accessHint(mode: ConnectorAccessMode, connectorConfig: ConnectorMeta): string {
+ const sourceName = connectorConfig.name
+ if (mode === 'members') {
+ return (
+ connectorConfig.memberSetupHint ??
+ `Each teammate connects their ${sourceName} account. They see only documents they can open there.`
+ )
+ }
+ if (mode === 'admin') {
+ const identityHint = connectorConfig.requiresMemberIdentity
+ ? ` Teammates still connect their ${sourceName} accounts to confirm their identity.`
+ : ''
+ return `${connectorConfig.adminSetupHint ?? 'An admin or service account syncs documents and permissions.'}${identityHint} Each person sees only documents they can open in ${sourceName}.`
+ }
+ return 'Everyone in this workspace can search these documents.'
+}
+
+/** Chooses how a source connects while preserving its document permissions. */
export function ConnectorAccessField({
+ workspaceId,
+ scope: explicitScope,
connectorConfig,
value,
onChange,
- groupOptions,
canAdmin,
disabled = false,
allowMembers = true,
- canRebind = false,
+ allowAdmin = false,
+ allowWorkspace = true,
footer,
+ searchSetupSource,
+ onSetupNavigate,
}: ConnectorAccessFieldProps) {
- if (!groupOptions.supported) return null
-
- if (!canAdmin) {
- if (value.accessMode !== 'members') return null
- return (
-
-
-
- Workspace
-
-
- Per member
-
-
-
+ const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
+ /**
+ * Member access needs a supported sign-in provider. Source permissions may
+ * also be available for providers authenticated with an API key.
+ */
+ const provider = connectorMemberProvider(connectorConfig)
+ const membersSupported = provider !== null
+ const accountsQuery = useSourceAccounts(
+ canAdmin && provider && value.accessMode === 'members' ? scope : undefined
+ )
+ const accounts = accountsQuery.data?.credentialGroup
+ const showSlackSetup =
+ canAdmin && value.accessMode === 'members' && connectorConfig.id === 'slack'
+ const configured =
+ accounts?.status === 'active' &&
+ accounts.options.some(
+ (option) =>
+ option.provider === provider &&
+ option.status === 'active' &&
+ option.configurationStatus === 'ready'
)
- }
+ const adminSupported = Boolean(connectorConfig.mirrorsSourceAcls)
+ if (!membersSupported && !adminSupported && value.accessMode === 'workspace') return null
+
+ const modes: { mode: ConnectorAccessMode; label: string; allowed: boolean }[] = [
+ { mode: 'workspace', label: 'Workspace', allowed: allowWorkspace },
+ { mode: 'members', label: 'Member accounts', allowed: membersSupported && allowMembers },
+ {
+ mode: 'admin',
+ label: isConnectorCredentialTypeAllowed(connectorConfig.auth, 'admin', 'oauth')
+ ? 'Admin or service account'
+ : 'Service account',
+ allowed: adminSupported && allowAdmin,
+ },
+ ]
+ /** Keep a retired current method visible so an admin can select an available replacement. */
+ const visibleModes = modes.filter((entry) => entry.allowed || entry.mode === value.accessMode)
+ const showModeSelector =
+ canAdmin && visibleModes.some((entry) => entry.allowed && entry.mode !== value.accessMode)
- const selectedValue =
- value.accessMode === 'members' && value.credentialGroupId && value.credentialGroupOptionId
- ? encodeConnectorMemberGroupOption(value.credentialGroupId, value.credentialGroupOptionId)
- : undefined
- const { options, needsChoice, isLoading, error } = groupOptions
- const showPicker = needsChoice || (canRebind && options.length > 0)
+ if (!canAdmin && value.accessMode === 'workspace') return null
return (
entry.mode === value.accessMode)?.allowed
+ ? `This connection method is not available in this ${scope.kind}.`
+ : accessHint(value.accessMode, connectorConfig)
}
>
-
- onChange(mode === 'members' ? { accessMode: 'members' } : { accessMode: 'workspace' })
- }
- >
-
- Workspace
-
-
- Per member
-
-
-
- {value.accessMode === 'members' && showPicker && (
-
{
- const decoded = decodeConnectorMemberGroupOption(next)
- if (decoded) onChange({ accessMode: 'members', ...decoded })
+ {showModeSelector ? (
+ {
+ const selection = modes.find((entry) => entry.mode === mode && entry.allowed)
+ if (!disabled && selection) onChange({ accessMode: selection.mode })
}}
- placeholder='Choose which credential group members connect through'
- isLoading={isLoading}
- disabled={disabled || Boolean(error)}
- />
+ >
+ {visibleModes.map((entry) => (
+
+ {entry.label}
+
+ ))}
+
+ ) : (
+
+ {modes.find((entry) => entry.mode === value.accessMode)?.label}
+
)}
- {footer}
+ {showSlackSetup &&
+ (accountsQuery.isError ? (
+ void accountsQuery.refetch()}
+ variant='inline'
+ />
+ ) : accountsQuery.isPending ? (
+ Checking Slack setup…
+ ) : accountsQuery.isSuccess && !configured ? (
+
+ ) : null)}
+
+ {canAdmin && footer}
)
}
+
+interface SlackMemberSetupProps {
+ workspaceId?: string
+ scope?: ResourceScope
+ searchSetupSource?: 'slack' | 'search'
+ onNavigate?: () => void
+}
+
+/** Uses the existing app and credential-group setup to collect Slack user authorization. */
+export function SlackMemberSetup({
+ workspaceId,
+ scope: explicitScope,
+ searchSetupSource,
+ onNavigate,
+}: SlackMemberSetupProps) {
+ const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
+ const href =
+ scope.kind === 'organization' || searchSetupSource
+ ? slackSearchSetupHref(scope, searchSetupSource ?? 'search')
+ : `/workspace/${scope.workspaceId}/settings/credential-groups`
+ return (
+
+
+ Set up your Slack app to continue.
+
+
+
+ Set up Slack
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.test.ts
similarity index 63%
rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts
rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.test.ts
index 6a7c8cd4701..d65d4c32250 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.test.ts
@@ -1,24 +1,21 @@
/**
* `supported` is what decides whether the Access field renders at all, and it is
- * exactly `connectorMemberGroupProvider(...) !== null`. A connector that declares
+ * exactly `connectorMemberProvider(...) !== null`. A connector that declares
* `permissionScopedListing` crawls once per member, so resolving it to `null`
* hides per-member access from the one kind of connector that has it.
*
* @vitest-environment node
*/
-import { assert, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@/hooks/queries/credential-groups', () => ({ useCredentialGroups: vi.fn() }))
-
+import { assert, describe, expect, it } from 'vitest'
import { canConnectPersonally } from '@/lib/sim-search/connectors'
-import { connectorMemberGroupProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
+import { connectorMemberProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access'
import { getAllConnectorMeta } from '@/connectors/registry'
const permissionScopedOAuthConnectors = Object.entries(getAllConnectorMeta()).filter(([, meta]) =>
canConnectPersonally(meta)
)
-describe('connectorMemberGroupProvider', () => {
+describe('connectorMemberProvider', () => {
/** A registry-driven `it.each([])` runs zero cases, so the suite must not be empty. */
it('has permission-scoped OAuth connectors to check', () => {
expect(permissionScopedOAuthConnectors.length).toBeGreaterThan(0)
@@ -27,15 +24,15 @@ describe('connectorMemberGroupProvider', () => {
it.each(permissionScopedOAuthConnectors)(
'resolves a credential-group provider for %s',
(_id, meta) => {
- expect(connectorMemberGroupProvider(meta)).not.toBeNull()
+ expect(connectorMemberProvider(meta)).not.toBeNull()
}
)
it('returns null for a connector that does not crawl per member', () => {
const plain = Object.values(getAllConnectorMeta()).find(
- (meta) => meta.auth.mode === 'oauth' && !canConnectPersonally(meta)
+ (meta) => meta.auth.mode === 'oauth' && !meta.permissionScopedListing
)
assert(plain)
- expect(connectorMemberGroupProvider(plain)).toBeNull()
+ expect(connectorMemberProvider(plain)).toBeNull()
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.ts
new file mode 100644
index 00000000000..5b4171e0859
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access.ts
@@ -0,0 +1,39 @@
+import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
+import {
+ type CredentialGroupProvider,
+ findCredentialGroupProviderFromProviderId,
+} from '@/lib/credential-groups/providers'
+import { aclIsDerived } from '@/lib/knowledge/connectors/access-modes'
+import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
+
+/** Administrator crawls require the same impersonation subject enforced by the server. */
+export function isConnectorFieldRequired(
+ field: ConnectorConfigField,
+ connectorConfig: ConnectorMeta,
+ accessMode: ConnectorAccessMode
+): boolean {
+ return Boolean(
+ field.required ||
+ (accessMode === 'admin' &&
+ connectorConfig.auth.mode === 'oauth' &&
+ connectorConfig.auth.serviceAccountSubjectFieldId === field.id)
+ )
+}
+
+/** The credential-group provider that collects accounts for this connector, if any. */
+export function connectorMemberProvider(
+ connectorConfig: ConnectorMeta
+): CredentialGroupProvider | null {
+ if (connectorConfig.auth.mode !== 'oauth' || !connectorConfig.permissionScopedListing) return null
+ return findCredentialGroupProviderFromProviderId(connectorConfig.auth.provider)
+}
+
+/** Derived ACL modes hide listing caps, which the server clears. */
+export function derivedAclCapFieldIds(
+ connectorConfig: ConnectorMeta | null,
+ accessMode: ConnectorAccessMode
+): ReadonlySet {
+ return new Set(
+ aclIsDerived(accessMode) ? (connectorConfig?.permissionScopedListing?.capFieldIds ?? []) : []
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
index 07ff925b5a1..949bf11d37d 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
@@ -2,7 +2,10 @@
import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn'
import { ArrowLeftRight, CircleInfo } from '@sim/emcn/icons'
+import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
+import type { ResourceScope } from '@/lib/core/resource-scope'
import type { SelectorKey } from '@/lib/selectors/manifest'
+import { isConnectorFieldRequired } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
ConfigFieldMap,
@@ -11,6 +14,8 @@ import type {
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
export interface ConnectorConfigFieldsProps {
+ scope?: ResourceScope
+ accessMode?: ConnectorAccessMode
/** Registry definition whose `configFields` drive the rendered rows. */
connectorConfig: ConnectorMeta
/** Current values keyed by field ID. */
@@ -27,7 +32,7 @@ export interface ConnectorConfigFieldsProps {
onFieldChange: (fieldId: string, value: ConfigFieldValue) => void
/** Swaps a canonical pair between selector and manual input. */
onToggleCanonicalMode: (canonicalId: string) => void
- /** Disables selector fields during submission. */
+ /** Disables configuration fields during submission. */
disabled: boolean
}
@@ -38,6 +43,8 @@ export interface ConnectorConfigFieldsProps {
* switch stays identical in both flows.
*/
export function ConnectorConfigFields({
+ scope,
+ accessMode = 'workspace',
connectorConfig,
sourceConfig,
credentialId,
@@ -75,7 +82,9 @@ export function ConnectorConfigFields({
{field.title}
- {field.required && * }
+ {isConnectorFieldRequired(field, connectorConfig, accessMode) && (
+ *
+ )}
{field.description && (
@@ -83,10 +92,10 @@ export function ConnectorConfigFields({
-
+
{field.description}
@@ -98,11 +107,13 @@ export function ConnectorConfigFields({
onToggleCanonicalMode(canonicalId)}
>
-
+
@@ -115,6 +126,7 @@ export function ConnectorConfigFields({
>
{field.type === 'selector' && field.selectorKey ? (
onFieldChange(field.id, value)}
@@ -126,6 +138,7 @@ export function ConnectorConfigFields({
/>
) : field.type === 'dropdown' && field.options ? (
({
label: opt.label,
value: opt.id,
@@ -140,6 +153,7 @@ export function ConnectorConfigFields({
/>
) : (
void
@@ -32,6 +34,7 @@ interface ConnectorSelectorFieldProps {
}
export function ConnectorSelectorField({
+ scope: explicitScope,
field,
value,
onChange,
@@ -41,7 +44,8 @@ export function ConnectorSelectorField({
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
- const { workspaceId } = useParams<{ workspaceId: string }>()
+ const params = useParams<{ workspaceId?: string; organizationId?: string }>()
+ const scope = explicitScope ?? resourceScopeFromOwner(params)
const isMulti = Boolean(field.multi)
const [searchTerm, setSearchTerm] = useState('')
@@ -92,7 +96,7 @@ export function ConnectorSelectorField({
error,
} = useSelectorOptions(field.selectorKey, {
context,
- scope: { kind: 'workspace', workspaceId },
+ scope,
search: debouncedSearch,
enabled: isEnabled,
surfaceId: `connector:${field.id}`,
@@ -112,7 +116,7 @@ export function ConnectorSelectorField({
field.selectorKey,
{
context,
- scope: { kind: 'workspace', workspaceId },
+ scope,
detailIds: isEnabled ? selectedIds : [],
surfaceId: `connector:${field.id}`,
}
@@ -127,7 +131,7 @@ export function ConnectorSelectorField({
const resolvesUnknownIds = getSelectorManifestEntry(field.selectorKey).resolvesUnknownIds
const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, {
context,
- scope: { kind: 'workspace', workspaceId },
+ scope,
detailId:
resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined,
surfaceId: `connector:${field.id}`,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
index 2121d040e41..3d30827eadc 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
@@ -29,6 +29,7 @@ const {
vi.mock('@sim/emcn/icons', () => ({
ChevronDown: icon('chevron-down'),
+ ChevronUp: icon('chevron-up'),
CircleAlert: icon('circle-alert'),
CircleCheck: icon('circle-check'),
CircleX: icon('circle-x'),
@@ -43,12 +44,17 @@ vi.mock('@sim/emcn/icons', () => ({
vi.mock('@sim/emcn', () => ({
Badge: ({ children }: { children?: ReactNode }) => {children} ,
- Button: ({
+ Chip: ({
children,
variant: _variant,
- size: _size,
+ leftIcon: _leftIcon,
+ fullWidth: _fullWidth,
...props
- }: ButtonHTMLAttributes & { variant?: string; size?: string }) => (
+ }: ButtonHTMLAttributes & {
+ variant?: string
+ leftIcon?: unknown
+ fullWidth?: boolean
+ }) => (
{children}
@@ -96,8 +102,15 @@ vi.mock('@/connectors/registry', () => ({
slack: {
id: 'slack',
name: 'Slack',
+ configFields: [],
auth: { mode: 'oauth', provider: 'slack', requiredScopes: ['channels:read'] },
},
+ confluence: {
+ id: 'confluence',
+ name: 'Confluence',
+ configFields: [{ id: 'domain' }, { id: 'spaceKey' }],
+ auth: { mode: 'oauth', provider: 'confluence' },
+ },
},
}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
@@ -176,7 +189,7 @@ function makeConnector(overrides: Partial = {}): ConnectorData {
}
}
-function renderSection(connector: ConnectorData) {
+function renderSection(connector: ConnectorData, additionalConnectors: ConnectorData[] = []) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
@@ -186,7 +199,7 @@ function renderSection(connector: ConnectorData) {
@@ -211,6 +224,25 @@ afterEach(() => {
})
describe('Connector credential reauthorization', () => {
+ it('distinguishes configured sites and spaces without exposing credential fields', () => {
+ const container = renderSection(
+ makeConnector({
+ connectorType: 'confluence',
+ sourceConfig: { domain: 'first.atlassian.net', spaceKey: 'ENG', apiKey: 'private-token' },
+ }),
+ [
+ makeConnector({
+ id: 'connector-2',
+ connectorType: 'confluence',
+ sourceConfig: { domain: 'second.atlassian.net', spaceKey: 'OPS' },
+ }),
+ ]
+ )
+ expect(container.textContent).toContain('first.atlassian.net · ENG')
+ expect(container.textContent).toContain('second.atlassian.net · OPS')
+ expect(container.textContent).not.toContain('private-token')
+ })
+
it('fails closed when the connector credential cannot be resolved', () => {
const container = renderSection(makeConnector())
const reconnectButton = Array.from(container.querySelectorAll('button')).find(
@@ -250,7 +282,7 @@ describe('Connector credential reauthorization', () => {
expect(credentialRefreshTriggersMock).toHaveBeenLastCalledWith(
expect.any(Function),
'slack-custom',
- 'workspace-1'
+ { kind: 'workspace', workspaceId: 'workspace-1' }
)
})
@@ -345,6 +377,14 @@ describe('SyncHistory', () => {
expect(container.textContent).not.toContain('No changes')
})
+ it('renders a continued listing as partial with the work already completed', () => {
+ const container = render(makeLog({ status: 'partial', docsAdded: 3 }))
+ expect(icons(container)).toEqual(['icon-triangle-alert'])
+ expect(container.textContent).toContain('Partial')
+ expect(container.textContent).toContain('+3')
+ expect(container.textContent).not.toContain('In progress…')
+ })
+
it('renders a "completed" row as a success with its change counts', () => {
const container = render(makeLog({ status: 'completed', docsAdded: 3 }))
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 5d249935a55..7b6bf01fd67 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -3,8 +3,8 @@
import { useEffect, useId, useMemo, useState } from 'react'
import {
Badge,
- Button,
Checkbox,
+ Chip,
ChipConfirmModal,
cn,
DropdownMenu,
@@ -16,6 +16,7 @@ import {
} from '@sim/emcn'
import {
ChevronDown,
+ ChevronUp,
CircleAlert,
CircleCheck,
CircleX,
@@ -30,6 +31,11 @@ import {
} from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow, isPast } from 'date-fns'
+import {
+ type ResourceScope,
+ resourceScopeFields,
+ resourceScopeFromOwner,
+} from '@/lib/core/resource-scope'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
CONNECTOR_SYNC_STALE_LOCK_TTL_MS,
@@ -38,10 +44,10 @@ import {
import type { MemberSyncStatus } from '@/lib/knowledge/types'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
+import { describeSearchSource } from '@/lib/sim-search/source-identity'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
-import { getBlock } from '@/blocks'
-import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type {
ConnectorData,
@@ -62,8 +68,10 @@ import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-tri
const logger = createLogger('ConnectorsSection')
interface ConnectorsSectionProps {
- workspaceId: string
+ scope?: ResourceScope
+ workspaceId?: string
knowledgeBaseId: string
+ isSearchIndex?: boolean
connectors: ConnectorData[]
isLoading: boolean
canEdit: boolean
@@ -102,17 +110,17 @@ const MEMBER_SYNC_STATUS_AS_CONNECTOR_STATUS = {
disabled: 'disabled',
} as const satisfies Record
-const CONNECTOR_ACTION_BUTTON_CLASSES =
- 'size-7 rounded-lg p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
-
export function ConnectorsSection({
workspaceId,
+ scope: explicitScope,
knowledgeBaseId,
+ isSearchIndex = false,
connectors,
isLoading,
canEdit,
className,
}: ConnectorsSectionProps) {
+ const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
const { mutate: triggerSync } = useTriggerSync()
const {
mutate: updateConnector,
@@ -213,9 +221,10 @@ export function ConnectorsSection({
Resume
* immediately, so without a guard a second click would send
@@ -235,9 +244,11 @@ export function ConnectorsSection({
{editingConnector && (
!val && setEditingConnector(null)}
knowledgeBaseId={knowledgeBaseId}
+ isSearchIndex={isSearchIndex}
connector={editingConnector}
/>
)}
@@ -283,9 +294,10 @@ export function ConnectorsSection({
interface ConnectorCardProps {
connector: ConnectorData
- workspaceId: string
+ scope: ResourceScope
knowledgeBaseId: string
canEdit: boolean
+ isSearchIndex: boolean
isUpdating: boolean
onSync: (rehydrate?: boolean) => void
onEdit: () => void
@@ -295,9 +307,10 @@ interface ConnectorCardProps {
function ConnectorCard({
connector,
- workspaceId,
+ scope,
knowledgeBaseId,
canEdit,
+ isSearchIndex,
isUpdating,
onSync,
onEdit,
@@ -308,8 +321,11 @@ function ConnectorCard({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const connectorDef = CONNECTOR_META_REGISTRY[connector.connectorType]
+ const docsUrl = isSearchIndex ? connectorDef?.searchDocsUrl : undefined
+ const sourceDescription = connectorDef
+ ? describeSearchSource(connectorDef, connector.sourceConfig)
+ : ''
const Icon = connectorDef?.icon
- const brandBg = getBlock(connector.connectorType)?.bgColor ?? null
/**
* A members-mode connector's content status stays `active` while the member
* engine does the work, so its badge reads the member engine's status. A
@@ -334,7 +350,7 @@ function ConnectorCard({
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(providerId, {
- workspaceId,
+ ...resourceScopeFields(scope),
})
const selectedCredential = useMemo(() => {
@@ -345,7 +361,7 @@ function ConnectorCard({
useCredentialRefreshTriggers(
refetchCredentials,
selectedCredential?.provider ?? providerId ?? '',
- workspaceId
+ scope
)
const missingScopes = useMemo(
@@ -410,24 +426,7 @@ function ConnectorCard({
>
-
- {Icon && (
-
- )}
-
+ {Icon &&
}
@@ -443,6 +442,11 @@ function ConnectorCard({
)}
+ {sourceDescription && (
+
+
+
+ )}
{lastSyncAt && (
Last sync: {format(new Date(lastSyncAt), 'MMM d, h:mm a')}
@@ -495,14 +499,11 @@ function ConnectorCard({
{/* span keeps the tooltip hoverable while the trigger button is disabled */}
-
-
-
+ leftIcon={RefreshCw}
+ />
@@ -518,15 +519,12 @@ function ConnectorCard({
{/* span keeps the tooltip hoverable while the button is disabled */}
- onSync(false)}
- >
-
-
+ leftIcon={RefreshCw}
+ />
{syncTooltip}
@@ -535,31 +533,27 @@ function ConnectorCard({
-
-
-
+
Settings
-
- {connector.status === 'paused' || connector.status === 'disabled' ? (
-
- ) : (
-
- )}
-
+ aria-label={
+ connector.status === 'paused' || connector.status === 'disabled'
+ ? 'Resume'
+ : 'Pause'
+ }
+ leftIcon={
+ connector.status === 'paused' || connector.status === 'disabled'
+ ? Play
+ : Pause
+ }
+ />
{connector.status === 'paused' || connector.status === 'disabled'
@@ -570,13 +564,7 @@ function ConnectorCard({
-
-
-
+
Delete
@@ -585,15 +573,12 @@ function ConnectorCard({
- setExpanded((prev) => !prev)}
- >
-
-
+ aria-label={expanded ? 'Hide history' : 'Sync history'}
+ aria-expanded={expanded}
+ leftIcon={expanded ? ChevronUp : ChevronDown}
+ />
{expanded ? 'Hide history' : 'Sync history'}
@@ -630,7 +615,7 @@ function ConnectorCard({
: ' Use the resume button to re-enable syncing.'}
{canEdit && serviceId && providerId && (
- {
@@ -642,18 +627,17 @@ function ConnectorCard({
displayName: connectorDef?.name ?? connector.connectorType,
providerId: selectedCredential.provider,
preCount: credentials?.length ?? 0,
- workspaceId,
+ ...resourceScopeFields(scope),
reconnect: true,
requestedAt: Date.now(),
})
}
setShowOAuthModal(true)
}}
- size='sm'
- className='w-full'
+ fullWidth
>
Reconnect
-
+
)}
@@ -667,7 +651,7 @@ function ConnectorCard({
Additional permissions required
{canEdit && (
-
{
if (connector.credentialId) {
@@ -678,18 +662,17 @@ function ConnectorCard({
displayName: connectorDef?.name ?? connector.connectorType,
providerId: selectedCredential.provider,
preCount: credentials?.length ?? 0,
- workspaceId,
+ ...resourceScopeFields(scope),
reconnect: true,
requestedAt: Date.now(),
})
}
setShowOAuthModal(true)
}}
- size='sm'
- className='w-full'
+ fullWidth
>
Update access
-
+
)}
@@ -718,8 +701,9 @@ function ConnectorCard({
}}
serviceId={serviceId}
providerId={providerId}
+ docsUrl={docsUrl}
requiredScopes={getCanonicalScopesForProvider(providerId)}
- workspaceId={workspaceId}
+ {...resourceScopeFields(scope)}
knowledgeBaseId={knowledgeBaseId}
/>
)}
@@ -743,8 +727,9 @@ function ConnectorCard({
newScopes={missingScopes}
serviceId={serviceId}
providerId={selectedCredential.provider}
+ docsUrl={docsUrl}
reconnectTarget={{
- workspaceId,
+ ...resourceScopeFields(scope),
credentialId: selectedCredential.id,
displayName: selectedCredential.name,
}}
@@ -770,12 +755,14 @@ function ConnectorCard({
* Rendering it as still running is the failure this state exists to fix; the
* same TTL already governs the reclaim that takes its lock away.
*/
-type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed'
+type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed' | 'partial'
function getSyncLogState(log: SyncLogData, now: number): SyncLogState {
switch (log.status) {
case 'completed':
return 'completed'
+ case 'partial':
+ return 'partial'
case 'failed':
return 'failed'
case 'started': {
@@ -830,7 +817,7 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
{state === 'running' ? (
- ) : state === 'interrupted' ? (
+ ) : state === 'interrupted' || state === 'partial' ? (
) : state === 'failed' ? (
@@ -844,7 +831,7 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
{format(new Date(log.startedAt), 'MMM d, h:mm a')}
- {state === 'completed' && (
+ {(state === 'completed' || state === 'partial') && (
{totalChanges > 0 ? (
<>
@@ -886,6 +873,7 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
)}
)}
+ {state === 'partial' &&
Partial }
{state === 'running' && (
In progress…
)}
@@ -909,6 +897,8 @@ function getMemberSyncLogState(log: MemberSyncLogData, now: number): SyncLogStat
switch (log.status) {
case 'completed':
return 'completed'
+ case 'partial':
+ return 'partial'
case 'failed':
return 'failed'
case 'started': {
@@ -971,7 +961,7 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps)
{state === 'running' ? (
- ) : state === 'interrupted' ? (
+ ) : state === 'interrupted' || state === 'partial' ? (
) : state === 'failed' ? (
@@ -982,7 +972,7 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps)
{format(new Date(log.startedAt), 'MMM d, h:mm a')}
- {state === 'completed' && (
+ {(state === 'completed' || state === 'partial') && (
{log.membersCompleted + log.membersIncomplete + log.membersFailed} member
{log.membersCompleted + log.membersIncomplete + log.membersFailed === 1
@@ -1014,6 +1004,7 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps)
)}
)}
+ {state === 'partial' &&
Partial }
{state === 'running' &&
In progress… }
{state === 'interrupted' && (
Interrupted
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts
index 6551b3be525..82c71b5a462 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts
@@ -1,6 +1,27 @@
+import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
+import { effectiveConnectorSyncIntervalMinutes } from '@/lib/knowledge/connectors/access-modes'
+
/** Under the account picker of a per-member connector, whose account only browses. */
export const BROWSE_WITH_HINT =
- 'Only used to pick folders and spaces below. The connector syncs as each member, not as this account.'
+ 'Only used to choose what to sync below. It does not change who indexes documents or who can read them.'
+
+/** Explain when permission refresh requires a more frequent pass than content indexing. */
+export function connectorSyncFrequencyHint(
+ accessMode: ConnectorAccessMode,
+ syncInterval: number,
+ hasContentCredential: boolean
+): string | undefined {
+ if (accessMode === 'workspace') return undefined
+ if (syncInterval === 0) {
+ return 'Content and permissions update only when you sync. Documents become unavailable after 24 hours without a successful permission check.'
+ }
+ if (effectiveConnectorSyncIntervalMinutes(accessMode, syncInterval) === syncInterval) {
+ return 'Permissions are checked on every sync.'
+ }
+ return accessMode === 'members' && hasContentCredential
+ ? 'Content follows this schedule. Member permissions are checked every hour.'
+ : 'Source permissions require a sync every hour, even when a longer interval is selected. Unchanged documents are not re-indexed.'
+}
export const SYNC_INTERVALS = [
{ label: 'Live', value: 5, requiresMax: true },
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.test.tsx
new file mode 100644
index 00000000000..091ec3e1fbe
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DocumentContextMenu } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu'
+
+class ResizeObserverMock {
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+}
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+describe('DocumentContextMenu retry', () => {
+ it('offers an accessible Retry action and invokes the provided handler on selection', () => {
+ const onRetry = vi.fn()
+ const onClose = vi.fn()
+
+ act(() => {
+ root.render(
+
+ )
+ })
+
+ const item = document.body.querySelector
('[role="menuitem"]')
+ expect(item).toHaveAccessibleName('Retry')
+ expect(item).not.toHaveAttribute('aria-disabled', 'true')
+
+ act(() => item?.click())
+
+ expect(onRetry).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it.each([
+ { scenario: 'no retry handler', selectedCount: 1, hasDocument: true, canRetry: false },
+ { scenario: 'multiple documents', selectedCount: 2, hasDocument: true, canRetry: true },
+ { scenario: 'empty space', selectedCount: 0, hasDocument: false, canRetry: true },
+ ])('does not offer Retry for $scenario', ({ selectedCount, hasDocument, canRetry }) => {
+ const onRetry = vi.fn()
+
+ act(() => {
+ root.render(
+
+ )
+ })
+
+ expect(document.body.querySelector('[role="menuitem"]')).toBeNull()
+ expect(onRetry).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
index c6d9075d0b1..ec9df1f1865 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
-import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
+import { Eye, Pencil, Plus, RefreshCw, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
import {
selectionActionLabel,
selectionToggleActionLabel,
@@ -22,6 +22,7 @@ interface DocumentContextMenuProps {
onRename?: () => void
onToggleEnabled?: () => void
onViewTags?: () => void
+ onRetry?: () => void
onDelete?: () => void
onAddDocument?: () => void
isDocumentEnabled?: boolean
@@ -50,6 +51,7 @@ export function DocumentContextMenu({
onRename,
onToggleEnabled,
onViewTags,
+ onRetry,
onDelete,
onAddDocument,
isDocumentEnabled = true,
@@ -74,7 +76,7 @@ export function DocumentContextMenu({
const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource)
const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags)
- const hasStateSection = !!onToggleEnabled
+ const hasStateSection = !!onToggleEnabled || (!isMultiSelect && !!onRetry)
const hasDestructiveSection = !!onDelete
const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection
@@ -132,6 +134,12 @@ export function DocumentContextMenu({
{toggleLabel}
)}
+ {!isMultiSelect && onRetry && (
+
+
+ Retry
+
+ )}
{hasActionsAboveDestructive && hasDestructiveSection && }
{onDelete && (
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
index 9cab5298d6f..60856bc70d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
@@ -2,10 +2,11 @@
import { useMemo, useState } from 'react'
import {
- Button,
ButtonGroup,
ButtonGroupItem,
+ Chip,
ChipCombobox,
+ ChipLink,
ChipModal,
ChipModalBody,
ChipModalError,
@@ -17,18 +18,34 @@ import {
Skeleton,
Tooltip,
} from '@sim/emcn'
-import { RefreshCw, SquareArrowUpRight } from '@sim/emcn/icons'
+import { Plus, RefreshCw, SquareArrowUpRight } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
-import { useParams } from 'next/navigation'
-import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
+import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
+import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope'
+import { isContentEngineAccessMode } from '@/lib/knowledge/connectors/access-modes'
+import {
+ getProviderIdFromServiceId,
+ getServiceAccountProviderForProviderId,
+ type OAuthProvider,
+} from '@/lib/oauth'
+import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors'
+import {
+ ConnectServiceAccountModal,
+ useServiceAccountConnectTarget,
+} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
+import {
+ derivedAclCapFieldIds,
+ isConnectorFieldRequired,
+} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access'
import {
ConnectorAccessField,
type ConnectorAccessSelection,
+ ConnectorContentCredentialField,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
-import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
import {
BROWSE_WITH_HINT,
+ connectorSyncFrequencyHint,
SYNC_INTERVALS,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
@@ -37,13 +54,10 @@ import type {
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
-import {
- memberCapFieldIds,
- useConnectorMemberGroupOptions,
-} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
-import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope'
+import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { withBrandIcon } from '@/blocks/brand-icon'
+import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
@@ -55,10 +69,18 @@ import {
useUpdateConnectorAccess,
} from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
-import { useMemberAccessAvailable } from '@/hooks/use-member-access'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('EditConnectorModal')
+const SWITCH_NOTICE: Record = {
+ workspace: 'Every workspace member can read every synced document once the next sync completes.',
+ members:
+ 'Teammates are invited to connect their accounts. Documents become available after their next sync. Item limits are removed.',
+ admin:
+ 'Documents become available after the next sync updates their source permissions. Item limits are removed.',
+}
+
/** Keys injected by the sync engine or modal state — not user-editable */
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds', '_canonicalModes'])
@@ -66,25 +88,11 @@ const CANONICAL_MODES_KEY = '_canonicalModes'
/** The access a connector row currently has, as the Access field edits it. */
function currentAccess(connector: ConnectorData): ConnectorAccessSelection {
- if (connector.accessMode === 'members') {
- return {
- accessMode: 'members',
- credentialGroupId: connector.credentialGroupId ?? undefined,
- credentialGroupOptionId: connector.credentialGroupOptionId ?? undefined,
- }
- }
+ if (connector.accessMode === 'members') return { accessMode: 'members' }
+ if (connector.accessMode === 'admin') return { accessMode: 'admin' }
return { accessMode: 'workspace' }
}
-function accessChanged(current: ConnectorAccessSelection, next: ConnectorAccessSelection): boolean {
- if (current.accessMode !== next.accessMode) return true
- if (next.accessMode === 'workspace') return false
- return (
- current.credentialGroupId !== next.credentialGroupId ||
- current.credentialGroupOptionId !== next.credentialGroupOptionId
- )
-}
-
function readPersistedCanonicalModes(
sourceConfig: Record
): Record {
@@ -154,9 +162,11 @@ function didCanonicalModesChange(
}
interface EditConnectorModalProps {
+ scope?: ResourceScope
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
+ isSearchIndex?: boolean
connector: ConnectorData
}
@@ -164,7 +174,9 @@ export function EditConnectorModal({
open,
onOpenChange,
knowledgeBaseId,
+ isSearchIndex = false,
connector,
+ scope: explicitScope,
}: EditConnectorModalProps) {
const connectorConfig = CONNECTOR_META_REGISTRY[connector.connectorType] ?? null
@@ -172,6 +184,9 @@ export function EditConnectorModal({
const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes)
const [access, setAccess] = useState(() => currentAccess(connector))
const [workspaceCredentialId, setWorkspaceCredentialId] = useState(null)
+ const [contentCredentialId, setContentCredentialId] = useState(
+ connector.accessMode === 'members' ? connector.credentialId : null
+ )
const [error, setError] = useState(null)
/**
@@ -224,49 +239,99 @@ export function EditConnectorModal({
canonicalModes,
canonicalGroups,
isFieldVisible,
+ isFieldPopulated,
handleFieldChange,
toggleCanonicalMode,
resolveSourceConfig,
} = useConnectorConfigFields({
connectorConfig,
+ accessMode: access.accessMode,
initialSourceConfig,
initialCanonicalModes,
})
- const { ownerBilling } = useWorkspaceHostContext()
- const { canAdmin } = useUserPermissionsContext()
- const { workspaceId } = useParams<{ workspaceId: string }>()
+ const { scope, canAdmin, memberAccessAvailable, mirroredAccessAvailable, hasMaxAccess } =
+ useConnectorScope(explicitScope)
const { mutate: updateConnector, isPending: isSavingSettings } = useUpdateConnector()
const { mutate: updateAccess, isPending: isSwitchingAccess } = useUpdateConnectorAccess()
const isSaving = isSavingSettings || isSwitchingAccess
- /**
- * The field shows where the flag is on. A connector already syncing per
- * member keeps it where the flag has since been turned off, so an admin can
- * still bring it back to workspace mode; per-member cannot be re-chosen.
- */
- const memberAccessAvailable = useMemberAccessAvailable()
- const showAccessField = memberAccessAvailable || connector.accessMode === 'members'
-
- const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling)
-
- const accessDirty = accessChanged(currentAccess(connector), access)
- const groupOptions = useConnectorMemberGroupOptions({
- workspaceId,
- connectorConfig,
- enabled: canAdmin && memberAccessAvailable,
- })
- /** Leaving members mode needs the credential the connector syncs as from then on. */
+ const {
+ integrationAvailability,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ isIntegrationAvailabilityFetching,
+ integrationAvailabilityError,
+ refetchIntegrationAvailability,
+ } = usePermissionConfig()
+ const { admin: allowAdmin, members: allowMembers } = connectorConfig
+ ? getConnectorAccessAvailability(connectorConfig, integrationAvailability, {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ })
+ : { admin: false, members: false }
+ const persistedAccess = currentAccess(connector)
+ const docsUrl = isSearchIndex ? connectorConfig?.searchDocsUrl : undefined
+ const searchSourceSupported = !isSearchIndex || connectorConfig?.search === true
+ const searchAccessAllowed = !isSearchIndex || access.accessMode !== 'workspace'
+ const searchSettingsAllowed =
+ searchSourceSupported && (!isSearchIndex || persistedAccess.accessMode !== 'workspace')
+ const searchSetupError = !searchSourceSupported
+ ? 'This source is not supported in Search. Use a separate knowledge base.'
+ : !searchAccessAllowed
+ ? 'Choose Member accounts or Admin or service account for Search.'
+ : null
+ /** Keep existing permission-scoped settings visible after their feature is disabled. */
+ const showAccessField =
+ memberAccessAvailable || mirroredAccessAvailable || persistedAccess.accessMode !== 'workspace'
+
+ const accessModeChanged = persistedAccess.accessMode !== access.accessMode
+ const accessDirty =
+ accessModeChanged ||
+ (isContentEngineAccessMode(access.accessMode) &&
+ workspaceCredentialId !== null &&
+ workspaceCredentialId !== connector.credentialId) ||
+ (access.accessMode === 'members' &&
+ contentCredentialId !== (connector.accessMode === 'members' ? connector.credentialId : null))
+ /** Exposes credential selection for mode changes and administrator credential recovery. */
const needsWorkspaceCredential =
- accessDirty && access.accessMode === 'workspace' && connector.accessMode === 'members'
+ connectorConfig?.auth.mode === 'oauth' &&
+ isContentEngineAccessMode(access.accessMode) &&
+ (persistedAccess.accessMode === 'members' ||
+ !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth'))
+ const missingAdminField =
+ accessDirty && access.accessMode === 'admin'
+ ? connectorConfig?.configFields.find((field) => {
+ const value = connector.sourceConfig[field.id]
+ return (
+ !field.required &&
+ isConnectorFieldRequired(field, connectorConfig, 'admin') &&
+ (typeof value !== 'string' || !value.trim())
+ )
+ })
+ : undefined
+ const accessSetupHint = missingAdminField
+ ? `Set ${missingAdminField.title} and save your settings before changing the connection method.`
+ : undefined
const accessComplete =
- !accessDirty ||
- (access.accessMode === 'members'
- ? !groupOptions.needsChoice || Boolean(access.credentialGroupOptionId)
- : !needsWorkspaceCredential || Boolean(workspaceCredentialId))
+ searchSourceSupported &&
+ searchAccessAllowed &&
+ !missingAdminField &&
+ (access.accessMode === 'workspace' ||
+ (access.accessMode === 'members' ? allowMembers : allowAdmin)) &&
+ (!accessDirty || !needsWorkspaceCredential || Boolean(workspaceCredentialId))
/** A disabled member sync is re-enabled by applying the current binding again. */
const canReenableMemberSync =
!accessDirty && connector.accessMode === 'members' && connector.memberSyncStatus === 'disabled'
- const hiddenCapFieldIds = memberCapFieldIds(connectorConfig, access.accessMode)
+ const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode)
+ const settingsComplete = connectorConfig?.configFields.every(
+ (field) =>
+ !isConnectorFieldRequired(field, connectorConfig, persistedAccess.accessMode) ||
+ !isFieldVisible(field) ||
+ hiddenCapFieldIds.has(field.id) ||
+ isFieldPopulated(field)
+ )
const persistedCanonicalModes = useMemo(
() => readPersistedCanonicalModes(connector.sourceConfig),
@@ -291,6 +356,7 @@ export function EditConnectorModal({
])
const handleSave = () => {
+ if (!searchSettingsAllowed || !settingsComplete || accessDirty) return
setError(null)
const updates: { sourceConfig?: Record; syncIntervalMinutes?: number } = {}
@@ -340,6 +406,7 @@ export function EditConnectorModal({
* than folded into a settings save that would race the run it starts.
*/
const handleApplyAccess = () => {
+ if (!accessComplete) return
setError(null)
updateAccess(
{
@@ -349,12 +416,11 @@ export function EditConnectorModal({
access.accessMode === 'members'
? {
accessMode: 'members',
- credentialGroupId: access.credentialGroupId,
- credentialGroupOptionId: access.credentialGroupOptionId,
+ credentialId: contentCredentialId,
}
: {
- accessMode: 'workspace',
- credentialId: workspaceCredentialId ?? undefined,
+ accessMode: access.accessMode,
+ credentialId: workspaceCredentialId ?? connector.credentialId ?? undefined,
},
},
{
@@ -384,6 +450,17 @@ export function EditConnectorModal({
+ {integrationAvailabilityError && (
+
+ void refetchIntegrationAvailability()}
+ variant='inline'
+ />
+
+ )}
setAccess(currentAccess(connector))}
- workspaceId={workspaceId}
+ onResetAccess={() => {
+ setAccess(currentAccess(connector))
+ setWorkspaceCredentialId(null)
+ setContentCredentialId(
+ connector.accessMode === 'members' ? connector.credentialId : null
+ )
+ }}
+ contentCredentialId={contentCredentialId}
+ onContentCredentialChange={setContentCredentialId}
+ scope={scope}
needsWorkspaceCredential={needsWorkspaceCredential}
workspaceCredentialId={workspaceCredentialId}
onWorkspaceCredentialChange={setWorkspaceCredentialId}
@@ -435,11 +523,22 @@ export function EditConnectorModal({
{activeTab === 'settings' && (
onOpenChange(false)}
+ secondaryActions={
+ docsUrl
+ ? [
+ {
+ label: 'Setup guide',
+ onClick: () => window.open(docsUrl, '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : undefined
+ }
primaryAction={{
label: isSaving ? 'Saving…' : 'Save',
onClick: handleSave,
/** An open access change is applied by its own control, never folded into Save. */
- disabled: !hasChanges || accessDirty || isSaving,
+ disabled:
+ !hasChanges || accessDirty || isSaving || !searchSettingsAllowed || !settingsComplete,
}}
/>
)}
@@ -448,9 +547,9 @@ export function EditConnectorModal({
}
interface SettingsTabProps {
+ isSearchIndex: boolean
connectorConfig: ConnectorMeta | null
/** The mode the connector is saved in, which the draft `access` may differ from. */
- persistedAccessMode: 'workspace' | 'members'
sourceConfig: ConfigFieldMap
credentialId: string | null
canonicalGroups: Map
@@ -468,22 +567,27 @@ interface SettingsTabProps {
canAdmin: boolean
showAccessField: boolean
allowMembers: boolean
- groupOptions: ReturnType
+ allowAdmin: boolean
+ allowWorkspace: boolean
canReenableMemberSync: boolean
accessDirty: boolean
+ accessModeChanged: boolean
accessComplete: boolean
+ accessSetupHint?: string
isSwitchingAccess: boolean
onApplyAccess: () => void
onResetAccess: () => void
- workspaceId: string
+ scope: ResourceScope
needsWorkspaceCredential: boolean
workspaceCredentialId: string | null
+ contentCredentialId: string | null
+ onContentCredentialChange: (credentialId: string | null) => void
onWorkspaceCredentialChange: (credentialId: string) => void
}
function SettingsTab({
+ isSearchIndex,
connectorConfig,
- persistedAccessMode,
sourceConfig,
credentialId,
canonicalGroups,
@@ -501,16 +605,21 @@ function SettingsTab({
canAdmin,
showAccessField,
allowMembers,
- groupOptions,
+ allowAdmin,
+ allowWorkspace,
canReenableMemberSync,
accessDirty,
+ accessModeChanged,
accessComplete,
+ accessSetupHint,
isSwitchingAccess,
onApplyAccess,
onResetAccess,
- workspaceId,
+ scope,
needsWorkspaceCredential,
workspaceCredentialId,
+ contentCredentialId,
+ onContentCredentialChange,
onWorkspaceCredentialChange,
}: SettingsTabProps) {
const providerId =
@@ -518,11 +627,31 @@ function SettingsTab({
? (getProviderIdFromServiceId(connectorConfig.auth.provider) as OAuthProvider)
: null
const syncsPerMember = access.accessMode === 'members'
- /** Staying per member but through a different group. */
- const isRebind = accessDirty && persistedAccessMode === 'members' && syncsPerMember
+ const requiresServiceAccount = Boolean(
+ connectorConfig &&
+ !isConnectorCredentialTypeAllowed(connectorConfig.auth, access.accessMode, 'oauth')
+ )
+ const serviceAccountProviderId = providerId
+ ? getServiceAccountProviderForProviderId(providerId)
+ : undefined
+ const serviceAccountTarget = useServiceAccountConnectTarget({
+ serviceAccountProviderId:
+ requiresServiceAccount &&
+ (serviceAccountProviderId === 'google-service-account' ||
+ serviceAccountProviderId === 'atlassian-service-account')
+ ? serviceAccountProviderId
+ : undefined,
+ serviceName: connectorConfig?.name,
+ serviceIcon: connectorConfig?.icon,
+ })
+ const [showServiceAccountModal, setShowServiceAccountModal] = useState(false)
+ const isContentCredentialChange = accessDirty && !accessModeChanged
const { data: rawCredentials = [], isLoading: credentialsLoading } = useOAuthCredentials(
providerId ?? undefined,
- { enabled: (needsWorkspaceCredential || syncsPerMember) && Boolean(providerId), workspaceId }
+ {
+ enabled: (needsWorkspaceCredential || syncsPerMember) && Boolean(providerId),
+ ...resourceScopeFields(scope),
+ }
)
const [browseCredentialId, setBrowseCredentialId] = useState(null)
/** A per-member connector has no credential of its own; the admin's account browses the source. */
@@ -530,33 +659,55 @@ function SettingsTab({
const credentialOptions = useMemo(
() =>
rawCredentials
- .filter((credential) => credential.type !== 'service_account')
+ .filter(
+ (credential) =>
+ !connectorConfig ||
+ isConnectorCredentialTypeAllowed(
+ connectorConfig.auth,
+ access.accessMode,
+ credential.type
+ )
+ )
.map((credential) => ({
label: credential.name || credential.provider,
value: credential.id,
})),
- [rawCredentials]
+ [rawCredentials, connectorConfig, access.accessMode]
)
return (
<>
- {connectorConfig && connectorConfig.auth.mode === 'oauth' && showAccessField && (
+ {syncsPerMember && connectorConfig?.supportsSeparateContentCredential && (
+
+ )}
+ {connectorConfig && showAccessField && (
-
+
{isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'}
-
+
Members and their documents are kept; the next sync restores their access.
@@ -564,48 +715,29 @@ function SettingsTab({
) : accessDirty ? (
- {needsWorkspaceCredential && (
- <>
-
- {!credentialsLoading && credentialOptions.length === 0 && (
-
- Connect a {connectorConfig.name} account in Integrations first.
-
- )}
- >
- )}
-
{isSwitchingAccess
? 'Switching…'
- : isRebind
- ? 'Change credential group'
- : access.accessMode === 'members'
- ? 'Switch to per-member access'
- : 'Switch to workspace access'}
-
-
- Cancel
-
+ : isContentCredentialChange
+ ? 'Change indexing account'
+ : 'Apply connection method'}
+
+
+ {accessSetupHint ? 'Edit settings' : 'Cancel'}
+
- {isRebind
- ? 'Members of the previous group lose access; members of the new group are invited to connect.'
- : access.accessMode === 'members'
- ? 'Everyone in the workspace is invited to connect their account. Documents stay hidden until members connect and sync; listing caps are cleared.'
- : 'Every workspace member can read every synced document once the next sync completes.'}
+ {accessSetupHint ??
+ (isContentCredentialChange
+ ? syncsPerMember
+ ? 'The next sync uses this indexing account. Members keep their connected accounts and source permissions.'
+ : 'The next sync uses this account and refreshes source permissions.'
+ : SWITCH_NOTICE[access.accessMode])}
) : undefined
@@ -613,21 +745,76 @@ function SettingsTab({
/>
)}
- {connectorConfig && syncsPerMember && (
-
+ {connectorConfig && needsWorkspaceCredential && canAdmin && (
+
setShowServiceAccountModal(true),
+ },
+ ]
+ : []),
+ ]}
+ value={workspaceCredentialId ?? credentialId ?? undefined}
+ onChange={onWorkspaceCredentialChange}
+ placeholder='Select the account to sync as'
isLoading={credentialsLoading}
disabled={isSaving}
/>
)}
+ {showServiceAccountModal && serviceAccountTarget && canAdmin && (
+
+ )}
+
+ {connectorConfig &&
+ syncsPerMember &&
+ connectorConfig.configFields.some(
+ (field) => field.type === 'selector' && isFieldVisible(field)
+ ) && (
+
+
+
+ )}
+
{connectorConfig && (
)}
-
- setSyncInterval(Number(val))}
+ {!isSearchIndex && (
+
- {SYNC_INTERVALS.map((interval) => (
-
- {interval.label}
- {interval.requiresMax && !hasMaxAccess && }
-
- ))}
-
-
+ setSyncInterval(Number(val))}
+ >
+ {SYNC_INTERVALS.map((interval) => (
+
+ {interval.label}
+ {interval.requiresMax && !hasMaxAccess && }
+
+ ))}
+
+
+ )}
{error}
>
@@ -730,22 +927,20 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
{doc.sourceUrl && (
-
-
-
+ leftIcon={SquareArrowUpRight}
+ aria-label='Open source document'
+ />
Open source document
)}
-
@@ -754,27 +949,14 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
: excludeDoc({ knowledgeBaseId, connectorId, documentIds: [doc.id] })
}
>
- {doc.userExcluded ? (
- <>
-
- Restore
- >
- ) : (
- 'Exclude'
- )}
-
+ {doc.userExcluded ? 'Restore' : 'Exclude'}
+
))}
{hasMoreVisibleDocuments && (
-
fetchNextPage()}
- >
+ fetchNextPage()}>
{isFetchingNextPage ? 'Loading…' : 'Load more documents'}
-
+
)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.test.tsx
new file mode 100644
index 00000000000..891a9178fbd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.test.tsx
@@ -0,0 +1,171 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/components/icons', () => ({ GmailIcon: () => null, GoogleDriveIcon: () => null }))
+
+import {
+ type UseConnectorConfigFieldsOptions,
+ type UseConnectorConfigFieldsResult,
+ useConnectorConfigFields,
+} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
+import { gmailConnectorMeta } from '@/connectors/gmail/meta'
+import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
+import type { ConnectorMeta } from '@/connectors/types'
+
+describe('useConnectorConfigFields member configuration', () => {
+ let container: HTMLDivElement
+ let root: Root
+ let current: UseConnectorConfigFieldsResult
+
+ function Probe(options: UseConnectorConfigFieldsOptions) {
+ current = useConnectorConfigFields(options)
+ return null
+ }
+
+ function render(options: Partial = {}) {
+ act(() => root.render( ))
+ }
+
+ function visibleLabelFields() {
+ return gmailConnectorMeta.configFields
+ .filter((field) => field.canonicalParamId === 'label' && current.isFieldVisible(field))
+ .map((field) => field.id)
+ }
+
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('offers only manual label names for member Gmail setup', () => {
+ render({ accessMode: 'members' })
+
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(current!.canonicalModes.label).toBe('advanced')
+ expect(current!.canonicalGroups.get('label')?.map((field) => field.id)).toEqual(['label'])
+ })
+
+ it('resolves manual names and system IDs through the existing canonical label field', () => {
+ render({ accessMode: 'members' })
+ act(() => current.handleFieldChange('label', ' INBOX, Engineering, , Product Updates '))
+
+ expect(current!.resolveSourceConfig()).toMatchObject({
+ label: ['INBOX', 'Engineering', 'Product Updates'],
+ })
+ expect(current!.resolveSourceConfig()).not.toHaveProperty('labelSelector')
+ })
+
+ it('preserves the general knowledge-base label selector and its mailbox-local IDs', () => {
+ render()
+ act(() => current.handleFieldChange('labelSelector', ['INBOX', 'Label_7']))
+
+ expect(visibleLabelFields()).toEqual(['labelSelector'])
+ expect(current!.canonicalModes.label).toBe('basic')
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['INBOX', 'Label_7'] })
+
+ act(() => current.toggleCanonicalMode('label'))
+ act(() => current.handleFieldChange('label', 'Engineering'))
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Engineering'] })
+
+ act(() => current.toggleCanonicalMode('label'))
+ expect(visibleLabelFields()).toEqual(['labelSelector'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['INBOX', 'Label_7'] })
+ })
+
+ it('keeps a visible manual field when a saved member draft selected basic mode', () => {
+ render({
+ accessMode: 'members',
+ initialCanonicalModes: { label: 'basic' },
+ initialSourceConfig: { labelSelector: ['Label_7'], label: ['Engineering'] },
+ })
+
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(current!.canonicalModes.label).toBe('advanced')
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Engineering'] })
+ })
+
+ it('keeps fields visible and preserves edits when switching access modes without remounting', () => {
+ render({
+ initialCanonicalModes: { label: 'basic' },
+ initialSourceConfig: { labelSelector: ['Label_7'], label: ['Engineering'] },
+ })
+ expect(visibleLabelFields()).toEqual(['labelSelector'])
+
+ render({ accessMode: 'members' })
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Engineering'] })
+ act(() => current.handleFieldChange('label', 'Engineering, Support'))
+
+ render({ accessMode: 'workspace' })
+ expect(visibleLabelFields()).toEqual(['labelSelector'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Label_7'] })
+
+ render({ accessMode: 'members' })
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Engineering', 'Support'] })
+ })
+
+ it('does not let a populated hidden selector satisfy a required manual field', () => {
+ const requiredLabels: ConnectorMeta = {
+ ...gmailConnectorMeta,
+ configFields: gmailConnectorMeta.configFields.map((field) => ({
+ ...field,
+ required: field.canonicalParamId === 'label',
+ })),
+ }
+ render({
+ connectorConfig: requiredLabels,
+ accessMode: 'members',
+ initialCanonicalModes: { label: 'basic' },
+ initialSourceConfig: { labelSelector: ['Label_7'] },
+ })
+
+ function missingRequiredFields() {
+ return requiredLabels.configFields
+ .filter(
+ (field) =>
+ field.required && current.isFieldVisible(field) && !current.isFieldPopulated(field)
+ )
+ .map((field) => field.id)
+ }
+
+ expect(visibleLabelFields()).toEqual(['label'])
+ expect(missingRequiredFields()).toEqual(['label'])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: [] })
+
+ act(() => current.handleFieldChange('label', ' '))
+ expect(missingRequiredFields()).toEqual(['label'])
+
+ act(() => current.handleFieldChange('label', 'Engineering'))
+ expect(missingRequiredFields()).toEqual([])
+ expect(current!.resolveSourceConfig()).toMatchObject({ label: ['Engineering'] })
+ })
+
+ it('hides mirrored sharing settings for members without discarding a saved central policy', () => {
+ const field = googleDriveConnectorMeta.configFields.find((field) => field.id === 'openSharing')!
+ render({
+ connectorConfig: googleDriveConnectorMeta,
+ accessMode: 'members',
+ initialSourceConfig: { openSharing: 'domain' },
+ })
+
+ expect(current!.isFieldVisible(field)).toBe(false)
+ expect(current!.resolveSourceConfig()).toMatchObject({ openSharing: 'domain' })
+
+ render({ connectorConfig: googleDriveConnectorMeta, accessMode: 'admin' })
+ expect(current!.isFieldVisible(field)).toBe(true)
+ expect(current!.resolveSourceConfig()).toMatchObject({ openSharing: 'domain' })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts
index 69723548992..d9c691186d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes'
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
@@ -9,6 +10,7 @@ export type ConfigFieldMap = Record
export interface UseConnectorConfigFieldsOptions {
connectorConfig: ConnectorMeta | null
+ accessMode?: ConnectorAccessMode
initialSourceConfig?: ConfigFieldMap
initialCanonicalModes?: Record
}
@@ -69,25 +71,38 @@ function isValuePopulated(value: ConfigFieldValue): boolean {
*/
export function useConnectorConfigFields({
connectorConfig,
+ accessMode = 'workspace',
initialSourceConfig,
initialCanonicalModes,
}: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult {
const [sourceConfig, setSourceConfig] = useState(() => initialSourceConfig ?? {})
- const [canonicalModes, setCanonicalModes] = useState>(
- () => initialCanonicalModes ?? {}
- )
+ const [selectedCanonicalModes, setCanonicalModes] = useState<
+ Record
+ >(() => initialCanonicalModes ?? {})
const canonicalGroups = useMemo(() => {
const groups = new Map()
if (!connectorConfig) return groups
for (const field of connectorConfig.configFields) {
+ if (accessMode === 'members' && field.hideInMemberMode) continue
if (!field.canonicalParamId) continue
const existing = groups.get(field.canonicalParamId)
if (existing) existing.push(field)
else groups.set(field.canonicalParamId, [field])
}
return groups
- }, [connectorConfig])
+ }, [connectorConfig, accessMode])
+
+ const canonicalModes = useMemo(() => {
+ const modes = { ...selectedCanonicalModes }
+ for (const [canonicalId, fields] of canonicalGroups) {
+ const selected = modes[canonicalId] ?? 'basic'
+ modes[canonicalId] = fields.some((field) => field.mode === selected)
+ ? selected
+ : (fields[0]?.mode ?? 'basic')
+ }
+ return modes
+ }, [selectedCanonicalModes, canonicalGroups])
const fieldsById = useMemo(() => {
const map = new Map()
@@ -137,11 +152,12 @@ export function useConnectorConfigFields({
const isFieldVisible = useCallback(
(field: ConnectorConfigField): boolean => {
+ if (accessMode === 'members' && field.hideInMemberMode) return false
if (!field.canonicalParamId || !field.mode) return true
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
return field.mode === activeMode
},
- [canonicalModes]
+ [canonicalModes, accessMode]
)
const isFieldPopulated = useCallback(
@@ -150,23 +166,26 @@ export function useConnectorConfigFields({
[sourceConfig]
)
- const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => {
- setSourceConfig((prev) => {
- const next: ConfigFieldMap = { ...prev, [fieldId]: value }
- const toClear = dependentFieldIds.get(fieldId)
- if (toClear) {
- for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId))
- }
- return next
- })
- }
+ const handleFieldChange = useCallback(
+ (fieldId: string, value: ConfigFieldValue) => {
+ setSourceConfig((prev) => {
+ const next: ConfigFieldMap = { ...prev, [fieldId]: value }
+ const toClear = dependentFieldIds.get(fieldId)
+ if (toClear) {
+ for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId))
+ }
+ return next
+ })
+ },
+ [dependentFieldIds, fieldsById]
+ )
- const toggleCanonicalMode = (canonicalId: string) => {
+ const toggleCanonicalMode = useCallback((canonicalId: string) => {
setCanonicalModes((prev) => ({
...prev,
[canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced',
}))
- }
+ }, [])
const resolveSourceConfig = useCallback((): Record => {
const resolved: Record = {}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts
deleted file mode 100644
index b3a37ee395e..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-'use client'
-
-import { useMemo } from 'react'
-import type { ComboboxOption } from '@sim/emcn'
-import {
- type CredentialGroupProvider,
- findCredentialGroupProviderFromProviderId,
- getCredentialGroupProviderId,
- isCredentialGroupProvider,
-} from '@/lib/credential-groups/providers'
-import type { ConnectorMeta } from '@/connectors/types'
-import { useCredentialGroups } from '@/hooks/queries/credential-groups'
-
-/** Encodes a group and option pair as one combobox value. */
-export function encodeConnectorMemberGroupOption(
- credentialGroupId: string,
- credentialGroupOptionId: string
-): string {
- return `${credentialGroupId}:${credentialGroupOptionId}`
-}
-
-export function decodeConnectorMemberGroupOption(
- value: string
-): { credentialGroupId: string; credentialGroupOptionId: string } | null {
- const separator = value.indexOf(':')
- if (separator <= 0) return null
- return {
- credentialGroupId: value.slice(0, separator),
- credentialGroupOptionId: value.slice(separator + 1),
- }
-}
-
-/** The credential-group provider that collects accounts for this connector, if any. */
-export function connectorMemberGroupProvider(
- connectorConfig: ConnectorMeta
-): CredentialGroupProvider | null {
- if (connectorConfig.auth.mode !== 'oauth' || !connectorConfig.permissionScopedListing) return null
- return findCredentialGroupProviderFromProviderId(connectorConfig.auth.provider)
-}
-
-/** The config fields a per-member connector hides: its listing caps, which the server clears. */
-export function memberCapFieldIds(
- connectorConfig: ConnectorMeta | null,
- accessMode: 'workspace' | 'members'
-): ReadonlySet {
- return new Set(
- accessMode === 'members' ? (connectorConfig?.permissionScopedListing?.capFieldIds ?? []) : []
- )
-}
-
-interface UseConnectorMemberGroupOptionsInput {
- workspaceId: string
- connectorConfig: ConnectorMeta | null
- /** False leaves the query off and reports no options, for a viewer who cannot choose anyway. */
- enabled: boolean
-}
-
-export interface ConnectorMemberGroupOptions {
- /** Every active option in the workspace collecting the connector's accounts, as combobox entries. */
- options: ComboboxOption[]
- /** Whether the connector's provider can be collected through a Credential Group at all. */
- supported: boolean
- /** More than one candidate: the admin has to say which, or the server refuses the ambiguity. */
- needsChoice: boolean
- isLoading: boolean
- error: Error | null
-}
-
-/**
- * The Credential Group options a per-member connector could sync through.
- * One source for the Access field, which renders them, and the modals, which
- * must not submit while a choice between several is still open.
- */
-export function useConnectorMemberGroupOptions({
- workspaceId,
- connectorConfig,
- enabled,
-}: UseConnectorMemberGroupOptionsInput): ConnectorMemberGroupOptions {
- const provider = connectorConfig ? connectorMemberGroupProvider(connectorConfig) : null
- const providerId = provider ? getCredentialGroupProviderId(provider) : null
- const {
- data: settings,
- isLoading,
- error,
- } = useCredentialGroups(enabled && provider ? workspaceId : undefined)
-
- const options = useMemo(() => {
- if (!settings || !providerId) return []
- const entries: ComboboxOption[] = []
- for (const group of settings.credentialGroups) {
- if (group.status !== 'active') continue
- for (const option of group.options) {
- if (option.status !== 'active') continue
- if (!isCredentialGroupProvider(option.provider)) continue
- if (getCredentialGroupProviderId(option.provider) !== providerId) continue
- entries.push({
- label: `${group.name} · ${option.label}`,
- value: encodeConnectorMemberGroupOption(group.id, option.id),
- })
- }
- }
- return entries
- }, [settings, providerId])
-
- return {
- options,
- supported: provider !== null,
- needsChoice: options.length > 1,
- isLoading,
- error: error ?? null,
- }
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.test.ts
new file mode 100644
index 00000000000..db2632f5861
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.test.ts
@@ -0,0 +1,71 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ organization: {
+ organization: { id: 'org-1' },
+ viewer: { isAdmin: true },
+ searchAccess: { memberScoped: true, sourceMirrored: false },
+ },
+ workspace: {
+ workspace: { id: 'workspace-1' },
+ ownerBilling: {},
+ features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true },
+ },
+}))
+vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOptionalOrganizationContext: () => mocks.organization,
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useOptionalWorkspaceHostContext: () => mocks.workspace,
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useOptionalWorkspacePermissionsContext: () => ({ userPermissions: { canAdmin: true } }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements', () => ({
+ hasWorkspaceMaxConnectorAccess: () => true,
+}))
+
+import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope'
+
+beforeEach(() => {
+ mocks.organization.viewer.isAdmin = true
+})
+
+describe('connector resource authority', () => {
+ it('reads the organization role and flags independently of workspace authority', () => {
+ expect(useConnectorScope({ kind: 'organization', organizationId: 'org-1' })).toMatchObject({
+ canAdmin: true,
+ memberAccessAvailable: true,
+ mirroredAccessAvailable: false,
+ })
+ })
+ it('does not grant an organization member the surrounding workspace admin role', () => {
+ mocks.organization.viewer.isAdmin = false
+ expect(useConnectorScope({ kind: 'organization', organizationId: 'org-1' }).canAdmin).toBe(
+ false
+ )
+ })
+ it.each([
+ { kind: 'organization' as const, organizationId: 'other-org' },
+ { kind: 'workspace' as const, workspaceId: 'other-workspace' },
+ ])('refuses UI permissions from a different resource owner', (scope) => {
+ expect(useConnectorScope(scope)).toMatchObject({
+ canAdmin: false,
+ memberAccessAvailable: false,
+ mirroredAccessAvailable: false,
+ })
+ })
+ it('preserves the routed workspace capabilities', () => {
+ expect(useConnectorScope()).toMatchObject({
+ scope: { kind: 'workspace', workspaceId: 'workspace-1' },
+ canAdmin: true,
+ memberAccessAvailable: true,
+ mirroredAccessAvailable: true,
+ hasMaxAccess: true,
+ })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts
new file mode 100644
index 00000000000..fffffecf8f9
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts
@@ -0,0 +1,37 @@
+'use client'
+
+import { useParams } from 'next/navigation'
+import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope'
+import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
+import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
+import { useOptionalWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+
+/** Shared connector UI reads the permissions of its actual resource owner. */
+export function useConnectorScope(explicitScope?: ResourceScope) {
+ const params = useParams<{ workspaceId?: string; organizationId?: string }>()
+ const scope = explicitScope ?? resourceScopeFromOwner(params)
+ const organization = useOptionalOrganizationContext()
+ const workspace = useOptionalWorkspaceHostContext()
+ const permissions = useOptionalWorkspacePermissionsContext()
+
+ if (scope.kind === 'organization') {
+ const context = organization?.organization.id === scope.organizationId ? organization : null
+ return {
+ scope,
+ canAdmin: context?.viewer.isAdmin === true,
+ memberAccessAvailable: context?.searchAccess.memberScoped === true,
+ mirroredAccessAvailable: context?.searchAccess.sourceMirrored === true,
+ hasMaxAccess: false,
+ }
+ }
+
+ const context = workspace?.workspace.id === scope.workspaceId ? workspace : null
+ return {
+ scope,
+ canAdmin: context !== null && permissions?.userPermissions.canAdmin === true,
+ memberAccessAvailable: context?.features?.knowledgeMemberAccess === true,
+ mirroredAccessAvailable: context?.features?.knowledgeSourceMirroredAccess === true,
+ hasMaxAccess: context ? hasWorkspaceMaxConnectorAccess(context.ownerBilling) : false,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx
new file mode 100644
index 00000000000..6496a088c2e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx
@@ -0,0 +1,279 @@
+/** @vitest-environment jsdom */
+import { act, type MouseEvent, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { KnowledgeBaseData } from '@/lib/knowledge/types'
+import type { ResourceRow } from '@/app/workspace/[workspaceId]/components'
+import type { WorkflowFolder } from '@/stores/folders/types'
+
+const mocks = vi.hoisted(() => ({
+ bases: [] as KnowledgeBaseData[],
+ folders: [] as WorkflowFolder[],
+ permissions: { canEdit: true, canAdmin: false, isLoading: false },
+ selection: new Set(),
+ deleteKey: undefined as (() => void) | undefined,
+ table: undefined as
+ | { rows: ResourceRow[]; onRowContextMenu: (event: MouseEvent, id: string) => void }
+ | undefined,
+ menu: undefined as { showDelete: boolean; onDelete: () => void } | undefined,
+ folderMenu: undefined as
+ | { canDelete: boolean; deleteDisabledReason?: string; onDelete: () => void }
+ | undefined,
+ actionDelete: undefined as (() => void) | undefined,
+ singleModal: undefined as { isOpen: boolean; onConfirm: () => Promise } | undefined,
+ remove: vi.fn(),
+ bulkRemove: vi.fn(),
+ removeFolder: vi.fn(),
+}))
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+ usePathname: () => '/workspace/workspace-1/knowledge',
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}))
+vi.mock('nuqs', () => ({
+ useQueryStates: () => [{ search: '', connector: [], content: [], owner: [] }, vi.fn()],
+}))
+vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({ config: {} }) }))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useUserPermissionsContext: () => mocks.permissions,
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({
+ useRegisterGlobalCommands: () => {},
+}))
+vi.mock('@/hooks/kb/use-knowledge', () => ({
+ useKnowledgeBasesList: () => ({
+ knowledgeBases: mocks.bases,
+ isLoading: false,
+ isPlaceholderData: false,
+ }),
+}))
+vi.mock('@/hooks/queries/workspace', () => ({ useWorkspaceMembersQuery: () => ({ data: [] }) }))
+vi.mock('@/hooks/queries/pinned-items', () => ({
+ usePinnedIds: () => new Set(),
+ usePinItem: () => ({}),
+ useUnpinItem: () => ({}),
+}))
+vi.mock('@/hooks/queries/kb/knowledge', () => ({
+ useDeleteKnowledgeBase: () => ({ mutateAsync: mocks.remove }),
+ useBulkDeleteKnowledgeBases: () => ({ mutateAsync: mocks.bulkRemove }),
+ useBulkMoveKnowledgeBases: () => ({}),
+ useUpdateKnowledgeBase: () => ({ mutateAsync: vi.fn() }),
+}))
+vi.mock('@/hooks/queries/folders', () => ({
+ useCreateFolder: () => ({}),
+ useUpdateFolder: () => ({}),
+ useDeleteFolderMutation: () => ({ mutateAsync: mocks.removeFolder }),
+}))
+vi.mock('@/hooks/use-context-menu', () => ({
+ useContextMenu: () => ({
+ isOpen: true,
+ position: { x: 0, y: 0 },
+ handleContextMenu: vi.fn(),
+ closeMenu: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/use-inline-rename', () => ({ useInlineRename: () => ({ editingId: null }) }))
+vi.mock('@/hooks/use-debounced-search-setter', () => ({
+ useDebouncedSearchSetter: (setter: unknown) => setter,
+}))
+vi.mock('@/hooks/use-search-filter-value', () => ({
+ useSearchFilterValue: (value: string) => value,
+}))
+vi.mock('@/hooks/use-url-sort', () => ({
+ useUrlSort: () => ({ sort: 'name', dir: 'asc', onSort: vi.fn() }),
+}))
+vi.mock('@/hooks/use-resource-list-preferences', () => ({
+ useResourceListPreferences: () => ({ isReady: true }),
+}))
+vi.mock('@/blocks/brand-icon', () => ({ BrandIcon: () => null }))
+vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} }))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal', () => ({
+ BaseTagsModal: () => null,
+}))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/components', () => ({
+ CreateBaseModal: () => null,
+ EditKnowledgeBaseModal: () => null,
+ KnowledgeListContextMenu: () => null,
+ KnowledgeBaseContextMenu: (props: typeof mocks.menu) => {
+ mocks.menu = props
+ return null
+ },
+ DeleteKnowledgeBaseModal: (props: typeof mocks.singleModal) => {
+ mocks.singleModal = props
+ return null
+ },
+}))
+vi.mock('@/app/workspace/[workspaceId]/components/resource/components/action-bar', () => ({
+ ResourceActionBar: ({ onDelete }: { onDelete?: () => void }) => {
+ mocks.actionDelete = onDelete
+ return null
+ },
+}))
+vi.mock('@/app/workspace/[workspaceId]/components', () => ({
+ Resource: Object.assign(({ children }: { children: ReactNode }) => <>{children}>, {
+ Header: () => null,
+ Options: () => null,
+ Table: ({ overlay, ...props }: NonNullable & { overlay: ReactNode }) => {
+ mocks.table = props
+ return <>{overlay}>
+ },
+ }),
+ useResourceRowSelection: ({ onDeleteSelected }: { onDeleteSelected: () => void }) => {
+ mocks.deleteKey = onDeleteSelected
+ return {
+ selectedRowIds: mocks.selection,
+ selectable: {},
+ replaceSelection: vi.fn(),
+ clearSelection: vi.fn(),
+ }
+ },
+ ownerCell: () => ({ label: '' }),
+ OwnerAvatar: () => null,
+ timeCell: () => ({ label: '' }),
+ resourceListState: () => 'ready',
+ selectionLabel: () => 'selected items',
+ reportBulkOutcome: vi.fn(),
+ EMPTY_CELL_PLACEHOLDER: '',
+ FILTER_SECTION_LABEL_CLASS: '',
+}))
+vi.mock('@/app/workspace/[workspaceId]/components/folders/use-folder-navigation', () => ({
+ useFolderNavigation: () => ({
+ currentFolderId: null,
+ setCurrentFolderId: vi.fn(),
+ openFolder: vi.fn(),
+ ancestors: [],
+ folders: mocks.folders,
+ folderById: new Map(mocks.folders.map((folder) => [folder.id, folder])),
+ foldersResolved: true,
+ }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop', () => ({
+ useFolderRowDragDrop: () => ({}),
+}))
+vi.mock('@/app/workspace/[workspaceId]/components/folders/folder-context-menu', () => ({
+ FolderContextMenu: (props: typeof mocks.folderMenu) => {
+ mocks.folderMenu = props
+ return null
+ },
+}))
+
+import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
+
+const base: KnowledgeBaseData = {
+ id: 'search-index',
+ name: 'Renamed search index',
+ isSearchIndex: true,
+ userId: 'author',
+ workspaceId: 'workspace-1',
+ description: null,
+ folderId: null,
+ tokenCount: 12,
+ embeddingModel: 'embedding',
+ embeddingDimension: 1536,
+ chunkingConfig: {},
+ createdAt: '2026-09-04',
+ updatedAt: '2026-09-04',
+ deletedAt: null,
+ docCount: 2,
+}
+function folder(id: string, parentId: string | null = null): WorkflowFolder {
+ return {
+ id,
+ parentId,
+ name: id,
+ workspaceId: 'workspace-1',
+ userId: 'author',
+ resourceType: 'knowledge_base',
+ locked: false,
+ sortOrder: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ }
+}
+
+describe('knowledge list Search index delete controls', () => {
+ let root: Root
+ let container: HTMLDivElement
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ mocks.bases = [base]
+ mocks.folders = []
+ mocks.permissions = { canEdit: true, canAdmin: false, isLoading: false }
+ mocks.selection = new Set()
+ mocks.menu = undefined
+ mocks.folderMenu = undefined
+ mocks.singleModal = undefined
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ })
+ async function render() {
+ await act(async () => root.render( ))
+ }
+ async function openRow(id: string) {
+ await act(async () =>
+ mocks.table?.onRowContextMenu({ preventDefault() {}, stopPropagation() {} } as MouseEvent, id)
+ )
+ }
+ it('hides delete for a renamed Search index and refuses stale menu callbacks for an editor', async () => {
+ await render()
+ await openRow('search-index')
+ expect(mocks.menu?.showDelete).toBe(false)
+ await act(async () => mocks.menu?.onDelete())
+ expect(mocks.singleModal?.isOpen).toBe(false)
+ await act(async () => mocks.singleModal?.onConfirm())
+ expect(mocks.remove).not.toHaveBeenCalled()
+ })
+ it('lets a workspace admin delete the canonical index directly', async () => {
+ mocks.permissions.canAdmin = true
+ await render()
+ await openRow('search-index')
+ expect(mocks.menu?.showDelete).toBe(true)
+ await act(async () => mocks.menu?.onDelete())
+ expect(mocks.singleModal?.isOpen).toBe(true)
+ await act(async () => mocks.singleModal?.onConfirm())
+ expect(mocks.remove).toHaveBeenCalledWith({ knowledgeBaseId: 'search-index' })
+ })
+ it('preserves editor deletion of an ordinary knowledge base', async () => {
+ mocks.bases = [{ ...base, isSearchIndex: false }]
+ await render()
+ await openRow('search-index')
+ expect(mocks.menu?.showDelete).toBe(true)
+ await act(async () => mocks.menu?.onDelete())
+ await act(async () => mocks.singleModal?.onConfirm())
+ expect(mocks.remove).toHaveBeenCalledOnce()
+ })
+ it('blocks a mixed bulk selection from the action bar and Delete-key callback', async () => {
+ mocks.bases = [base, { ...base, id: 'ordinary', isSearchIndex: false }]
+ mocks.selection = new Set(['search-index', 'ordinary'])
+ await render()
+ expect(mocks.actionDelete).toBeUndefined()
+ await act(async () => mocks.deleteKey?.())
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.bulkRemove).not.toHaveBeenCalled()
+ })
+ it.each([false, true])(
+ 'blocks folder cascades around the canonical index (admin=%s)',
+ async (canAdmin) => {
+ mocks.permissions.canAdmin = canAdmin
+ mocks.folders = [folder('parent'), folder('child', 'parent')]
+ mocks.bases = [{ ...base, folderId: 'child' }]
+ mocks.selection = new Set(['folder:parent'])
+ await render()
+ expect(mocks.actionDelete).toBeUndefined()
+ await openRow('folder:parent')
+ expect(mocks.folderMenu?.deleteDisabledReason).toBe('Delete the search knowledge base first')
+ await act(async () => mocks.folderMenu?.onDelete())
+ await act(async () => mocks.deleteKey?.())
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.removeFolder).not.toHaveBeenCalled()
+ expect(mocks.bulkRemove).not.toHaveBeenCalled()
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index 3afca28a305..6825988de30 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -64,7 +64,7 @@ import {
KnowledgeEmptyState,
ResourceNoResults,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
-import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
+import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal'
import {
CreateBaseModal,
DeleteKnowledgeBaseModal,
@@ -73,6 +73,7 @@ import {
KnowledgeListContextMenu,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
+import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions'
import {
knowledgeListPreferenceConfig,
knowledgeParsers,
@@ -105,13 +106,9 @@ import type { ResourceListPreference } from '@/stores/resource-list-preferences'
const logger = createLogger('Knowledge')
-interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
- docCount?: number
-}
-
/** A list row, resolved to the entity it refers to. */
type KnowledgeResourceItem =
- | { kind: 'base'; base: KnowledgeBaseWithDocCount }
+ | { kind: 'base'; base: KnowledgeBaseData }
| { kind: 'folder'; folder: WorkflowFolder }
const COLUMNS: ResourceColumn[] = [
@@ -256,6 +253,23 @@ export function Knowledge() {
onBeforeOpenFolder: () => setSearchQuery(''),
})
+ const searchIndexFolders = useMemo(() => {
+ const ancestors = new Set()
+ for (const knowledgeBase of knowledgeBases) {
+ if (!knowledgeBase.isSearchIndex) continue
+ let folderId = knowledgeBase.folderId
+ while (folderId && !ancestors.has(folderId)) {
+ ancestors.add(folderId)
+ folderId = folderById.get(folderId)?.parentId ?? null
+ }
+ }
+ return ancestors
+ }, [knowledgeBases, folderById])
+ const canDeleteFolder = useCallback(
+ (folderId: string) => canEdit && !searchIndexFolders.has(folderId),
+ [canEdit, searchIndexFolders]
+ )
+
const createFolder = useCreateFolder()
const updateFolder = useUpdateFolder()
const deleteFolder = useDeleteFolderMutation()
@@ -339,9 +353,7 @@ export function Knowledge() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
- const [activeKnowledgeBase, setActiveKnowledgeBase] = useState(
- null
- )
+ const [activeKnowledgeBase, setActiveKnowledgeBase] = useState(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false)
@@ -393,8 +405,8 @@ export function Knowledge() {
* not short-circuit.
*/
const knowledgeBaseById = useMemo(() => {
- const byId = new Map()
- for (const base of knowledgeBases) byId.set(base.id, base as KnowledgeBaseWithDocCount)
+ const byId = new Map()
+ for (const base of knowledgeBases) byId.set(base.id, base)
return byId
}, [knowledgeBases])
const knowledgeBaseByIdRef = useRef(knowledgeBaseById)
@@ -492,11 +504,13 @@ export function Knowledge() {
const handleDeleteKnowledgeBase = useCallback(
async (id: string) => {
+ const knowledgeBase = knowledgeBases.find((base) => base.id === id)
+ if (!canDeleteKnowledgeBase(knowledgeBase, userPermissions)) return
await deleteKnowledgeBase.mutateAsync({ knowledgeBaseId: id })
logger.info(`Knowledge base deleted: ${id}`)
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
- []
+ [knowledgeBases, userPermissions.canEdit, userPermissions.canAdmin]
)
/**
@@ -552,7 +566,7 @@ export function Knowledge() {
}
if (contentFilter.length > 0) {
- const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0
+ const docCount = (kb: KnowledgeBaseData) => kb.docCount ?? 0
result = result.filter((kb) => {
if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true
if (contentFilter.includes('empty') && docCount(kb) === 0) return true
@@ -608,12 +622,12 @@ export function Knowledge() {
for (const kb of processedKBs) {
entries.push({
- item: { kind: 'base', base: kb as KnowledgeBaseWithDocCount },
+ item: { kind: 'base', base: kb },
pinned: pinnedBaseIds.has(kb.id),
name: kb.name,
key:
sortColumn === 'documents'
- ? ((kb as KnowledgeBaseWithDocCount).docCount ?? 0)
+ ? (kb.docCount ?? 0)
: sortColumn === 'tokens'
? (kb.tokenCount ?? 0)
: sortColumn === 'connectors'
@@ -771,6 +785,15 @@ export function Knowledge() {
() => splitFolderedRowIds(selectedRowIds),
[selectedRowIds]
)
+ const canDeleteSelection =
+ canEdit &&
+ selectedKnowledgeBaseIds.every((id) =>
+ canDeleteKnowledgeBase(
+ knowledgeBases.find((base) => base.id === id),
+ userPermissions
+ )
+ ) &&
+ selectedFolderIds.every(canDeleteFolder)
const bulkDeleteCount = selectedKnowledgeBaseIds.length + selectedFolderIds.length
const bulkDeleteFirstName =
@@ -816,9 +839,7 @@ export function Knowledge() {
return
}
- const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id) as
- | KnowledgeBaseWithDocCount
- | undefined
+ const kb = knowledgeBasesRef.current.find((k) => k.id === parsed.id)
setActiveKnowledgeBase(kb ?? null)
handleRowCtxMenu(e)
},
@@ -827,11 +848,11 @@ export function Knowledge() {
const handleConfirmDelete = useCallback(async () => {
const kb = activeKnowledgeBaseRef.current
- if (!kb) return
+ if (!kb || !canDeleteKnowledgeBase(kb, userPermissions)) return
await handleDeleteKnowledgeBase(kb.id)
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
- }, [handleDeleteKnowledgeBase])
+ }, [handleDeleteKnowledgeBase, userPermissions.canEdit, userPermissions.canAdmin])
const handleCloseDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false)
@@ -861,8 +882,9 @@ export function Knowledge() {
}, [])
const handleDelete = useCallback(() => {
+ if (!canDeleteKnowledgeBase(activeKnowledgeBaseRef.current, userPermissions)) return
setIsDeleteModalOpen(true)
- }, [])
+ }, [userPermissions.canEdit, userPermissions.canAdmin])
const handleCreateFolder = useCallback(async () => {
if (!workspaceId) return
@@ -915,15 +937,16 @@ export function Knowledge() {
}, [])
const handleRequestFolderDelete = useCallback(() => {
+ if (!activeFolderRef.current || !canDeleteFolder(activeFolderRef.current.id)) return
setFolderPendingDelete(activeFolderRef.current)
- }, [])
+ }, [canDeleteFolder])
const folderPendingDeleteRef = useRef(folderPendingDelete)
folderPendingDeleteRef.current = folderPendingDelete
const handleConfirmFolderDelete = useCallback(async () => {
const folder = folderPendingDeleteRef.current
- if (!folder) return
+ if (!folder || !canDeleteFolder(folder.id)) return
try {
await deleteFolder.mutateAsync({
workspaceId,
@@ -944,7 +967,7 @@ export function Knowledge() {
toast.error(getErrorMessage(deleteError, 'Failed to delete folder'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [workspaceId, openFolder])
+ }, [workspaceId, openFolder, canDeleteFolder])
const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders])
@@ -1090,15 +1113,17 @@ export function Knowledge() {
selectedKnowledgeBaseIds.length + selectedFolderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS
const handleBulkDelete = useCallback(() => {
+ if (!canDeleteSelection) return
if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return
if (exceedsBatchCap) {
toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to delete at once`)
return
}
setIsBulkDeleteModalOpen(true)
- }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap])
+ }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap, canDeleteSelection])
const confirmBulkDelete = useCallback(async () => {
+ if (!canDeleteSelection) return
try {
const result = await bulkDeleteKnowledgeBases.mutateAsync({
knowledgeBaseIds: selectedKnowledgeBaseIds,
@@ -1112,7 +1137,7 @@ export function Knowledge() {
logger.error('Failed to delete selected items', deleteError)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
- }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection])
+ }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection, canDeleteSelection])
/**
* Destinations for the action bar's move menu. Every selected folder — and everything beneath
@@ -1228,11 +1253,15 @@ export function Knowledge() {
breadcrumbRenameRef.current.startRename(folder.id, folder.name)
},
},
- {
- label: 'Delete',
- icon: Trash,
- onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]),
- },
+ ...(canDeleteFolder(breadcrumbs[breadcrumbs.length - 1].id)
+ ? [
+ {
+ label: 'Delete',
+ icon: Trash,
+ onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]),
+ },
+ ]
+ : []),
]
: undefined,
}),
@@ -1241,6 +1270,7 @@ export function Knowledge() {
currentFolderId,
openFolder,
canEdit,
+ canDeleteFolder,
breadcrumbRename.editingId,
breadcrumbRename.editValue,
breadcrumbRename.isSaving,
@@ -1384,7 +1414,7 @@ export function Knowledge() {
selectedCount={selectedRowIds.size}
onMove={canEdit ? handleBulkMove : undefined}
moveOptions={canEdit ? bulkMoveOptions : undefined}
- onDelete={canEdit ? handleBulkDelete : undefined}
+ onDelete={canDeleteSelection ? handleBulkDelete : undefined}
isLoading={bulkMoveKnowledgeBases.isPending || bulkDeleteKnowledgeBases.isPending}
maxSelectable={MAX_KNOWLEDGE_BATCH_ITEMS}
/>
@@ -1392,6 +1422,7 @@ export function Knowledge() {
[
selectedRowIds.size,
canEdit,
+ canDeleteSelection,
handleBulkMove,
bulkMoveOptions,
handleBulkDelete,
@@ -1517,7 +1548,12 @@ export function Knowledge() {
showOpenInNewTab
showViewTags
showEdit
- showDelete
+ showDelete={
+ hasMultiSelection
+ ? canDeleteSelection
+ : !activeKnowledgeBase.isSearchIndex ||
+ canDeleteKnowledgeBase(activeKnowledgeBase, userPermissions)
+ }
disableEdit={!canEdit}
disableDelete={!canEdit}
selectedCount={selectedRowIds.size}
@@ -1538,6 +1574,12 @@ export function Knowledge() {
onMove={handleMoveFolderFromMenu}
moveOptions={activeFolderMoveOptions}
canEdit={canEdit}
+ canDelete={hasMultiSelection ? canDeleteSelection : canEdit}
+ deleteDisabledReason={
+ searchIndexFolders.has(activeFolder.id)
+ ? 'Delete the search knowledge base first'
+ : undefined
+ }
selectedCount={selectedRowIds.size}
/>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.test.ts
new file mode 100644
index 00000000000..b4654abef1f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.test.ts
@@ -0,0 +1,22 @@
+/** @vitest-environment node */
+import { describe, expect, it } from 'vitest'
+import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions'
+
+describe('knowledge delete UI permission', () => {
+ it.each([
+ [true, false, false, false],
+ [true, true, false, false],
+ [true, true, true, true],
+ [false, true, false, true],
+ [false, false, false, false],
+ [true, false, true, false],
+ ])(
+ 'matches Search identity %s and edit/admin %s/%s',
+ (isSearchIndex, canEdit, canAdmin, expected) => {
+ expect(canDeleteKnowledgeBase({ isSearchIndex }, { canEdit, canAdmin })).toBe(expected)
+ }
+ )
+ it('offers no deletion before the canonical resource has loaded', () => {
+ expect(canDeleteKnowledgeBase(undefined, { canEdit: true, canAdmin: true })).toBe(false)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.ts
new file mode 100644
index 00000000000..621c91ed899
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/permissions.ts
@@ -0,0 +1,12 @@
+import type { KnowledgeBaseData } from '@/lib/knowledge/types'
+import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
+
+/** The workspace Search index requires an administrator even when other knowledge bases are editable. */
+export function canDeleteKnowledgeBase(
+ knowledgeBase: Pick | null | undefined,
+ permissions: Pick
+): boolean {
+ return Boolean(
+ knowledgeBase && permissions.canEdit && (!knowledgeBase.isSearchIndex || permissions.canAdmin)
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
index 26305f13a74..79f619de2cd 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
@@ -65,6 +65,10 @@ vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({
WorkspaceChrome: ({ children }: { children: ReactNode }) => children,
}))
+vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
+ Sidebar: () => null,
+}))
+
vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({
WorkspaceAccessDenied: () => Workspace access denied
,
}))
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index 01d1c56062a..1e93ff58add 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -23,6 +23,7 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
import { WorkspaceHostProvider } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
+import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
@@ -82,7 +83,10 @@ export default async function WorkspaceLayout({
-
+ }
+ initialSidebarCollapsed={initialSidebarCollapsed}
+ >
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
index f1a3d81115a..95a2735c120 100644
--- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
@@ -4,17 +4,12 @@ import type { QueryClient } from '@tanstack/react-query'
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { isChatEnabled } from '@/lib/core/config/env-flags'
-import { getUserProfile } from '@/lib/users/queries'
+import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
-import {
- mapUserProfileResponse,
- USER_PROFILE_STALE_TIME,
- userProfileKeys,
-} from '@/hooks/queries/current-user-data'
import {
MOTHERSHIP_CHAT_LIST_STALE_TIME,
mapChat,
@@ -161,19 +156,9 @@ export async function prefetchWorkspaceSidebar(
/**
* The sidebar footer renders the viewer's name and avatar, so the profile is
* sidebar data and joins this batch rather than trailing it as a client
- * waterfall. Keyed identically to `useUserProfile`, so the footer paints
- * hydrated. Unlike the settings prefetch this needs no session lookup — the
- * caller already resolved the viewer.
+ * waterfall.
*/
- queryClient.prefetchQuery({
- queryKey: userProfileKeys.profile(),
- queryFn: async () => {
- const user = await getUserProfile(userId)
- if (!user) throw new Error('User not found')
- return mapUserProfileResponse(user)
- },
- staleTime: USER_PROFILE_STALE_TIME,
- }),
+ prefetchUserProfile(queryClient, userId),
seedWorkspaceList(queryClient, userId, activeOrganizationId),
])
}
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
index af4ba896db5..3d8e7ad7a69 100644
--- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
@@ -237,6 +237,10 @@ export function useWorkspacePermissionsContext(): WorkspacePermissionsContextTyp
return context
}
+export function useOptionalWorkspacePermissionsContext(): WorkspacePermissionsContextType | null {
+ return useContext(WorkspacePermissionsContext)
+}
+
/**
* Accesses the current user's computed permissions including offline mode status.
* Convenience hook that extracts userPermissions from the context.
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx
index 43ee669f90c..0c6da1b9df2 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
-import { Button } from '@sim/emcn'
+import { Chip } from '@sim/emcn'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
@@ -74,17 +74,18 @@ export function MemberConnectorsSection({ workspaceId, connectors }: MemberConne
) : undefined
}
title={name}
- description={`${connector.knowledgeBaseName} · ${state}`}
+ description={[connector.knowledgeBaseName, connector.sourceDescription, state]
+ .filter(Boolean)
+ .join(' · ')}
trailing={
CONNECTABLE_MEMBERSHIPS.has(connector.viewerMembership) ? (
- connect(connector.knowledgeBaseId, connector.connectorId)}
disabled={isPending}
>
{enrollmentActionLabel(connector.viewerMembership, waiting)}
-
+
) : undefined
}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.test.tsx
new file mode 100644
index 00000000000..21a00b6c014
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.test.tsx
@@ -0,0 +1,324 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ createKey: vi.fn(),
+ refetchPolicy: vi.fn(),
+ isPending: false,
+ allowPersonalApiKeys: true,
+ policy: {
+ isSuccess: true,
+ isError: false,
+ isFetching: false,
+ error: null as Error | null,
+ data: { config: { disablePersonalApiKeys: false } },
+ },
+}))
+
+vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.fixture.test' }))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useWorkspaceHostContext: () => ({
+ workspace: { allowPersonalApiKeys: mocks.allowPersonalApiKeys },
+ }),
+}))
+vi.mock('@/ee/access-control/hooks/permission-groups', () => ({
+ useUserPermissionConfig: () => ({ ...mocks.policy, refetch: mocks.refetchPolicy }),
+}))
+vi.mock('@/hooks/queries/api-keys', () => ({
+ useCreateApiKey: () => ({ mutateAsync: mocks.createKey, isPending: mocks.isPending }),
+}))
+
+import { SearchMcpSetup } from '@/app/workspace/[workspaceId]/search/components/search-mcp-setup'
+
+const CREATED_KEY = {
+ id: 'key-1',
+ name: 'Search client',
+ key: 'sim_fixture_personal_secret',
+ createdAt: '2026-09-05T00:00:00.000Z',
+ lastUsed: null,
+}
+
+function findDialog(title: string) {
+ return Array.from(document.querySelectorAll('[role="dialog"]')).find((dialog) => {
+ const labelId = dialog.getAttribute('aria-labelledby')
+ return labelId && document.getElementById(labelId)?.textContent === title
+ })
+}
+
+function getDialog(title: string) {
+ const dialog = findDialog(title)
+ expect(dialog, `Expected the ${title} dialog`).toBeDefined()
+ return dialog!
+}
+
+function findButton(label: string, parent: ParentNode = document) {
+ return Array.from(parent.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === label
+ )
+}
+
+function getButton(label: string, parent: ParentNode = document) {
+ const button = findButton(label, parent)
+ expect(button, `Expected the ${label} button`).toBeDefined()
+ return button!
+}
+
+async function clickButton(label: string, parent: ParentNode = document) {
+ await act(async () => getButton(label, parent).click())
+}
+
+async function typeName(value = 'Search client') {
+ const input = getDialog('Create new API key').querySelector(
+ 'input[placeholder="e.g., Development, Production"]'
+ )!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function getAuthorizationHeader() {
+ return Array.from(getDialog('Connect Search via MCP').querySelectorAll('input')).find((input) =>
+ input.value.startsWith('Bearer ')
+ )!.value
+}
+
+describe('Search MCP setup', () => {
+ let root: Root
+ let container: HTMLDivElement
+
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.clearAllMocks()
+ mocks.createKey.mockReset()
+ mocks.createKey.mockResolvedValue({ key: CREATED_KEY })
+ mocks.isPending = false
+ mocks.allowPersonalApiKeys = true
+ mocks.policy = {
+ isSuccess: true,
+ isError: false,
+ isFetching: false,
+ error: null,
+ data: { config: { disablePersonalApiKeys: false } },
+ }
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ })
+
+ async function render(workspaceId = 'workspace-1') {
+ await act(async () => root.render( ))
+ }
+
+ async function openSetup() {
+ await render()
+ await clickButton('Set up')
+ }
+
+ async function openCreateKey() {
+ await openSetup()
+ await clickButton('Generate API key')
+ }
+
+ it('opens inline personal key generation without a settings detour or workspace choice', async () => {
+ await openSetup()
+ const setup = getDialog('Connect Search via MCP')
+ expect(
+ setup.querySelector(
+ 'input[value="https://sim.fixture.test/api/mcp/search/workspace-1"]'
+ )?.readOnly
+ ).toBe(true)
+ expect(setup.querySelector('[aria-label="Copy MCP server URL"]')).not.toBeNull()
+ expect(setup.querySelector('[aria-label="Copy authorization header value"]')).not.toBeNull()
+ expect(setup.querySelector('a')).toBeNull()
+ expect(setup.textContent).toContain('Streamable HTTP')
+ expect(setup.textContent).toContain('personal API key to search with your document access')
+ expect(getAuthorizationHeader()).toBe('Bearer YOUR_SIM_API_KEY')
+
+ await clickButton('Generate API key', setup)
+ const create = getDialog('Create new API key')
+ expect(create.querySelectorAll('input:not([aria-hidden="true"])')).toHaveLength(1)
+ expect(create.textContent).toContain('Name')
+ expect(create.textContent).not.toContain('Key type')
+ expect(findButton('Workspace', create)).toBeUndefined()
+ expect(getButton('Create', create).disabled).toBe(true)
+
+ await clickButton('Cancel', create)
+ expect(findDialog('Create new API key')).toBeUndefined()
+ expect(getDialog('Connect Search via MCP')).toBe(setup)
+ expect(mocks.createKey).not.toHaveBeenCalled()
+ })
+
+ it('retains the shared one-time reveal and generated header, then clears the key on MCP close', async () => {
+ await openCreateKey()
+ await typeName(' Search client ')
+ await clickButton('Create', getDialog('Create new API key'))
+
+ expect(mocks.createKey).toHaveBeenCalledExactlyOnceWith({
+ name: 'Search client',
+ keyType: 'personal',
+ source: 'settings',
+ })
+ expect(findDialog('Create new API key')).toBeUndefined()
+ const reveal = getDialog('Your API key has been created')
+ expect(reveal.textContent).toContain(CREATED_KEY.key)
+ expect(getAuthorizationHeader()).toBe(`Bearer ${CREATED_KEY.key}`)
+ expect(findButton('Generate API key')).toBeUndefined()
+
+ await clickButton('Done', reveal)
+ expect(findDialog('Your API key has been created')).toBeUndefined()
+ expect(getAuthorizationHeader()).toBe(`Bearer ${CREATED_KEY.key}`)
+ await clickButton('Close', getDialog('Connect Search via MCP'))
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+
+ await clickButton('Set up')
+ expect(getAuthorizationHeader()).toBe('Bearer YOUR_SIM_API_KEY')
+ expect(document.body.textContent).not.toContain(CREATED_KEY.key)
+ expect(getButton('Generate API key').disabled).toBe(false)
+ })
+
+ it('keeps the entered name after a failed creation and lets the user retry', async () => {
+ mocks.createKey.mockRejectedValueOnce(new Error('Service unavailable'))
+ await openCreateKey()
+ await typeName()
+ await clickButton('Create', getDialog('Create new API key'))
+
+ const create = getDialog('Create new API key')
+ expect(create.textContent).toContain(
+ 'Failed to create API key. Please check your connection and try again.'
+ )
+ expect(
+ create.querySelector('input[placeholder="e.g., Development, Production"]')
+ ?.value
+ ).toBe('Search client')
+ expect(getAuthorizationHeader()).toBe('Bearer YOUR_SIM_API_KEY')
+ expect(findDialog('Your API key has been created')).toBeUndefined()
+ expect(getButton('Create', create).disabled).toBe(false)
+
+ await clickButton('Create', create)
+ expect(mocks.createKey).toHaveBeenCalledTimes(2)
+ expect(getDialog('Your API key has been created').textContent).toContain(CREATED_KEY.key)
+ expect(getAuthorizationHeader()).toBe(`Bearer ${CREATED_KEY.key}`)
+ })
+
+ it('blocks generation until the permission policy has loaded', async () => {
+ mocks.policy.isSuccess = false
+ mocks.policy.isFetching = true
+ await openSetup()
+ expect(getButton('Generate API key').disabled).toBe(true)
+ await clickButton('Generate API key')
+ expect(findDialog('Create new API key')).toBeUndefined()
+ expect(mocks.createKey).not.toHaveBeenCalled()
+
+ mocks.policy.isSuccess = true
+ mocks.policy.isFetching = false
+ await render()
+ expect(getButton('Generate API key').disabled).toBe(false)
+ })
+
+ it('offers a policy retry after a failed permission check', async () => {
+ mocks.policy.isSuccess = false
+ mocks.policy.isError = true
+ mocks.policy.error = new Error('Could not load permissions')
+ await openSetup()
+ expect(findButton('Generate API key')).toBeUndefined()
+ expect(getDialog('Connect Search via MCP').textContent).toContain('Could not load permissions')
+ await clickButton('Try again')
+ expect(mocks.refetchPolicy).toHaveBeenCalledOnce()
+ expect(mocks.createKey).not.toHaveBeenCalled()
+
+ mocks.policy.isFetching = true
+ await render()
+ expect(getButton('Retrying…').disabled).toBe(true)
+ mocks.policy.isSuccess = true
+ mocks.policy.isError = false
+ mocks.policy.isFetching = false
+ mocks.policy.error = null
+ await render()
+ expect(findButton('Try again')).toBeUndefined()
+ expect(getButton('Generate API key').disabled).toBe(false)
+ })
+
+ it.each(['workspace', 'permission group'] as const)(
+ 'blocks generation when the %s disables personal keys',
+ async (policySource) => {
+ if (policySource === 'workspace') mocks.allowPersonalApiKeys = false
+ else mocks.policy.data.config.disablePersonalApiKeys = true
+ await openSetup()
+ expect(getButton('Generate API key').disabled).toBe(true)
+ expect(getDialog('Connect Search via MCP').textContent).toContain(
+ 'Personal API keys are disabled for your account.'
+ )
+ await clickButton('Generate API key')
+ expect(findDialog('Create new API key')).toBeUndefined()
+ expect(mocks.createKey).not.toHaveBeenCalled()
+ }
+ )
+
+ it('rechecks personal-key permission while the create dialog is open', async () => {
+ await openCreateKey()
+ await typeName()
+ const create = getDialog('Create new API key')
+ expect(getButton('Create', create).disabled).toBe(false)
+
+ mocks.policy.isSuccess = false
+ mocks.policy.isError = true
+ mocks.policy.error = new Error('Could not load permissions')
+ await render()
+ expect(getButton('Create', create).disabled).toBe(true)
+ await clickButton('Create', create)
+ expect(mocks.createKey).not.toHaveBeenCalled()
+
+ mocks.policy.isSuccess = true
+ mocks.policy.isError = false
+ mocks.policy.error = null
+ await render()
+ expect(getButton('Create', create).disabled).toBe(false)
+ await clickButton('Create', create)
+ expect(getAuthorizationHeader()).toBe(`Bearer ${CREATED_KEY.key}`)
+ })
+
+ it('blocks close, cancel, and Escape while creation is pending, then retains the returned key', async () => {
+ let resolveCreation!: (response: { key: typeof CREATED_KEY }) => void
+ mocks.createKey.mockImplementationOnce(
+ () =>
+ new Promise<{ key: typeof CREATED_KEY }>((resolve) => {
+ resolveCreation = resolve
+ })
+ )
+ await openCreateKey()
+ await typeName()
+ await clickButton('Create', getDialog('Create new API key'))
+ mocks.isPending = true
+ await render()
+
+ const create = getDialog('Create new API key')
+ expect(getButton('Creating...', create).disabled).toBe(true)
+ expect(getButton('Close', create).disabled).toBe(true)
+ expect(getButton('Cancel', create).disabled).toBe(true)
+ await clickButton('Close', create)
+ await clickButton('Cancel', create)
+ await act(async () => {
+ create.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+ expect(getDialog('Create new API key')).toBe(create)
+ expect(getAuthorizationHeader()).toBe('Bearer YOUR_SIM_API_KEY')
+ expect(mocks.createKey).toHaveBeenCalledOnce()
+
+ await act(async () => {
+ mocks.isPending = false
+ resolveCreation({ key: CREATED_KEY })
+ })
+ expect(findDialog('Create new API key')).toBeUndefined()
+ expect(getDialog('Your API key has been created').textContent).toContain(CREATED_KEY.key)
+ expect(getAuthorizationHeader()).toBe(`Bearer ${CREATED_KEY.key}`)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.tsx
new file mode 100644
index 00000000000..afa18756d39
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-mcp-setup.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import { useState } from 'react'
+import { Chip, ChipModal, ChipModalBody, ChipModalField, ChipModalHeader } from '@sim/emcn'
+import { McpIcon } from '@/components/icons'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
+import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
+import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
+
+interface SearchMcpSetupProps {
+ workspaceId: string
+}
+
+export function SearchMcpSetup({ workspaceId }: SearchMcpSetupProps) {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+ }
+ title='Use Search in other apps via MCP'
+ trailing={ setOpen(true)}>Set up }
+ />
+ {open && (
+ setOpen(false)}
+ />
+ )}
+ >
+ )
+}
+
+interface SearchMcpModalProps extends SearchMcpSetupProps {
+ onClose: () => void
+}
+
+function SearchMcpModal({ workspaceId, onClose }: SearchMcpModalProps) {
+ const { workspace } = useWorkspaceHostContext()
+ const policy = useUserPermissionConfig(workspaceId)
+ const [createKeyOpen, setCreateKeyOpen] = useState(false)
+ const [apiKey, setApiKey] = useState(null)
+ const allowPersonalApiKeys =
+ workspace.allowPersonalApiKeys &&
+ policy.isSuccess &&
+ !policy.data?.config?.disablePersonalApiKeys
+ const endpoint = `${getBaseUrl()}/api/mcp/search/${encodeURIComponent(workspaceId)}`
+
+ return (
+ <>
+ !open && onClose()} srTitle='Connect Search via MCP'>
+ Connect Search via MCP
+
+
+
+ {!apiKey && (
+
+ {policy.isError ? (
+ void policy.refetch()}
+ variant='inline'
+ />
+ ) : (
+ setCreateKeyOpen(true)} disabled={!allowPersonalApiKeys}>
+ Generate API key
+
+ )}
+
+ )}
+
+
+ setApiKey(key.key)}
+ />
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx
new file mode 100644
index 00000000000..7b3a9fb4384
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx
@@ -0,0 +1,72 @@
+/** @vitest-environment jsdom */
+import { act, type ReactNode } from 'react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const push = vi.hoisted(() => vi.fn())
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/workspace/workspace-1/settings/credential-groups',
+ useRouter: () => ({ push }),
+}))
+
+import { SearchSetupReturn } from '@/app/workspace/[workspaceId]/search/components/search-setup-return'
+
+let root: Root | undefined
+let container: HTMLDivElement
+
+async function render(node: ReactNode, searchParams: string) {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ await act(async () =>
+ root?.render(
+
+ {node}
+
+ )
+ )
+}
+
+beforeEach(() => {
+ push.mockReset()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+})
+afterEach(async () => {
+ await act(async () => root?.unmount())
+ container?.remove()
+ vi.unstubAllGlobals()
+})
+
+describe('returning to Search setup', () => {
+ it.each([
+ ['slack', '/workspace/workspace-1/search?addConnector=slack'],
+ ['search', '/workspace/workspace-1/search'],
+ ])('returns to the original %s setup', async (source, href) => {
+ await render( , `?search-setup=${source}`)
+ await act(async () => container.querySelector('button')?.click())
+ expect(push).toHaveBeenCalledWith(href)
+ })
+
+ it('lets the existing unsaved-settings guard defer navigation', async () => {
+ const guard = vi.fn()
+ await render(
+ ,
+ '?search-setup=slack'
+ )
+ await act(async () => container.querySelector('button')?.click())
+ expect(push).not.toHaveBeenCalled()
+ expect(guard).toHaveBeenCalledOnce()
+ guard.mock.calls[0][0]()
+ expect(push).toHaveBeenCalledWith('/workspace/workspace-1/search?addConnector=slack')
+ })
+
+ it('ignores unrecognized destinations', async () => {
+ await render(
+ ,
+ '?search-setup=https://unrelated.example'
+ )
+ expect(container.querySelector('button')).toBeNull()
+ expect(push).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx
new file mode 100644
index 00000000000..8f26e606f74
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { Chip } from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import { useRouter } from 'next/navigation'
+import { useQueryState } from 'nuqs'
+import { searchSetupReturnHref } from '@/lib/sim-search/setup-navigation'
+import { searchSetupReturnParam } from '@/app/workspace/[workspaceId]/search/search-params'
+
+interface SearchSetupReturnProps {
+ workspaceId: string
+ onNavigate?: (navigate: () => void) => void
+}
+
+/** Rejoins the original source setup from integrations or connected-account settings. */
+export function SearchSetupReturn({ workspaceId, onNavigate }: SearchSetupReturnProps) {
+ const [source] = useQueryState(searchSetupReturnParam.key, searchSetupReturnParam.parser)
+ const router = useRouter()
+ if (!source) return null
+ const navigate = () => router.push(searchSetupReturnHref(workspaceId, source))
+ return (
+ (onNavigate ? onNavigate(navigate) : navigate())}
+ >
+ Continue Search setup
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx
new file mode 100644
index 00000000000..0b63999ace1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx
@@ -0,0 +1,190 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors'
+
+vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({
+ IntegrationTile: () => null,
+}))
+
+import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
+
+const connect = vi.fn()
+const manage = vi.fn()
+let root: Root
+let container: HTMLDivElement
+
+function source(overrides: Partial = {}): SearchSourceSummary {
+ return {
+ knowledgeBaseId: 'kb-search',
+ connectorId: 'source-1',
+ connectorType: 'confluence',
+ sourceDescription: 'engineering.atlassian.net · ENG',
+ accessMode: 'members',
+ availability: 'available',
+ enabled: true,
+ isSyncing: false,
+ lastSyncAt: null,
+ hasSyncError: false,
+ viewerDocumentCount: 0,
+ viewerEmailVerified: true,
+ connectionRequired: true,
+ viewerMembership: 'invited',
+ ...overrides,
+ } as SearchSourceSummary
+}
+
+async function render(
+ data = source(),
+ props: { canAdmin?: boolean; available?: boolean; waiting?: boolean; isPending?: boolean } = {}
+) {
+ await act(async () =>
+ root.render(
+
+ )
+ )
+}
+
+function button(label: string) {
+ return Array.from(document.querySelectorAll('button')).find(
+ (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+describe('Search source viewer actions', () => {
+ it.each(['invited', 'not_enrolled'] as const)(
+ 'lets a %s viewer connect their account',
+ async (membership) => {
+ await render(source({ viewerMembership: membership }))
+ expect(document.body.textContent).toContain('engineering.atlassian.net · ENG')
+ expect(document.body.textContent).toContain('Connect your account to search this source')
+ await act(async () => button('Connect account')!.click())
+ expect(connect).toHaveBeenCalledOnce()
+ expect(button('Manage')).toBeUndefined()
+ }
+ )
+
+ it('offers Reconnect and lets a waiting viewer reopen enrollment', async () => {
+ await render(source({ viewerMembership: 'needs_reauth' }))
+ expect(button('Reconnect')).toBeDefined()
+ await render(source({ viewerMembership: 'needs_reauth' }), { waiting: true })
+ expect(document.body.textContent).toContain('Finish connecting in the other tab')
+ await act(async () => button('Open again')!.click())
+ expect(connect).toHaveBeenCalledOnce()
+ await render(source({ viewerMembership: 'needs_reauth' }), { waiting: true, isPending: true })
+ expect(button('Open again')?.disabled).toBe(true)
+ })
+
+ it.each([
+ { change: { availability: 'unavailable' as const }, status: 'Not available in this workspace' },
+ { change: { enabled: false }, status: 'Syncing is paused' },
+ { change: { viewerEmailVerified: false }, status: 'Verify your email' },
+ { change: { viewerMembership: 'unverified_email' as const }, status: 'Verify your email' },
+ { change: { viewerMembership: 'revoked' as const }, status: 'Your access was removed' },
+ { change: { viewerMembership: null }, status: 'Needs admin attention' },
+ ])('blocks connection when $status', async ({ change, status }) => {
+ await render(source({ ...change, isSyncing: true, hasSyncError: true }))
+ expect(document.body.textContent).toContain(status)
+ expect(button('Connect account')).toBeUndefined()
+ expect(button('Reconnect')).toBeUndefined()
+ expect(connect).not.toHaveBeenCalled()
+ })
+
+ it('blocks a cached available source when the client feature is disabled', async () => {
+ await render(source(), { available: false })
+ expect(document.body.textContent).toContain('Not available in this workspace')
+ expect(button('Connect account')).toBeUndefined()
+ })
+
+ it('prioritizes viewer connection over crawler health for central Confluence identity', async () => {
+ await render(source({ accessMode: 'admin', hasSyncError: true, isSyncing: true }))
+ expect(document.body.textContent).toContain('Connect your account to search this source')
+ expect(button('Connect account')).toBeDefined()
+ })
+
+ it.each(['google_drive', 'gitlab'])(
+ 'shows central %s status without prompting for a member connection',
+ async (connectorType) => {
+ await render(
+ source({
+ connectorType,
+ accessMode: 'admin',
+ connectionRequired: false,
+ viewerMembership: null,
+ viewerDocumentCount: 1,
+ })
+ )
+ expect(document.body.textContent).toContain('1 searchable document')
+ expect(document.body.textContent).not.toContain('Needs admin attention')
+ expect(button('Connect account')).toBeUndefined()
+ }
+ )
+
+ it.each([
+ {
+ change: { hasSyncError: true, viewerDocumentCount: 4 },
+ status: 'Sync needs attention · 4 searchable documents',
+ },
+ { change: { hasSyncError: true }, status: 'Sync needs admin attention' },
+ {
+ change: { isSyncing: true, viewerDocumentCount: 4 },
+ status: 'Indexing · 4 searchable documents',
+ },
+ { change: { isSyncing: true }, status: 'Indexing' },
+ { change: { viewerDocumentCount: 4 }, status: '4 searchable documents' },
+ { change: { lastSyncAt: '2026-09-05T12:00:00Z' }, status: 'No searchable documents yet' },
+ { change: {}, status: 'Waiting for the first sync' },
+ ])('reports $status after connection', async ({ change, status }) => {
+ await render(source({ viewerMembership: 'connected', ...change }))
+ expect(document.body.textContent).toContain(status)
+ expect(button('Connect account')).toBeUndefined()
+ })
+
+ it('gives admins Manage after connecting and keeps management secondary before connecting', async () => {
+ await render(source(), { canAdmin: true })
+ expect(button('Connect account')).toBeDefined()
+ expect(button('Confluence source actions')).toBeDefined()
+ expect(button('Manage')).toBeUndefined()
+ await render(source({ viewerMembership: 'connected' }), { canAdmin: true })
+ await act(async () => button('Manage')!.click())
+ expect(manage).toHaveBeenCalledOnce()
+ expect(connect).not.toHaveBeenCalled()
+ })
+
+ it.each([false, true])(
+ 'retains the legacy knowledge-base link for canAdmin=%s',
+ async (canAdmin) => {
+ await render(source({ connectorType: 'airtable' }), { canAdmin })
+ const link = document.querySelector('a')
+ expect(link?.getAttribute('href')).toBe('/workspace/workspace-1/knowledge/kb-search')
+ expect(link?.textContent).toBe(canAdmin ? 'Manage' : 'View')
+ expect(document.body.textContent).toContain('Available in its knowledge base')
+ expect(button('Connect account')).toBeUndefined()
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx
new file mode 100644
index 00000000000..ddef9eaf919
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx
@@ -0,0 +1,118 @@
+'use client'
+
+import { Chip, ChipLink } from '@sim/emcn'
+import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors'
+import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope'
+import { connectorDisplayName } from '@/lib/sim-search/connectors'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
+import { CONNECTABLE_MEMBERSHIPS } from '@/hooks/use-member-enrollment'
+
+interface SearchSourceRowProps {
+ source: SearchSourceSummary
+ workspaceId?: string
+ scope?: ResourceScope
+ canAdmin: boolean
+ available: boolean
+ waiting: boolean
+ isPending: boolean
+ onConnect: () => void
+ /** Opens management for the source; only a surface that offers management passes it. */
+ onManage?: () => void
+}
+
+/** Source health and the viewer's connection are separate; only the viewer's next action is primary. */
+export function SearchSourceRow({
+ source,
+ workspaceId,
+ scope: explicitScope,
+ canAdmin,
+ available,
+ waiting,
+ isPending,
+ onConnect,
+ onManage,
+}: SearchSourceRowProps) {
+ const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
+ const meta = CONNECTOR_META_REGISTRY[source.connectorType]
+ const name = connectorDisplayName(source.connectorType)
+ const membership = source.viewerMembership
+ const usable = available && source.availability === 'available'
+ const supported = meta?.search === true
+ const connectable =
+ usable &&
+ supported &&
+ source.enabled &&
+ source.approved !== false &&
+ source.viewerEmailVerified &&
+ source.connectionRequired &&
+ membership !== null &&
+ CONNECTABLE_MEMBERSHIPS.has(membership)
+ const count = `${source.viewerDocumentCount} searchable document${source.viewerDocumentCount === 1 ? '' : 's'}`
+ let status: string
+ if (!supported) status = 'Available in its knowledge base'
+ else if (source.approved === false) status = 'Deactivated by an organization admin'
+ else if (!usable) status = `Not available in this ${scope.kind}`
+ else if (!source.enabled) status = 'Syncing is paused'
+ else if (!source.viewerEmailVerified || membership === 'unverified_email')
+ status = 'Verify your email to search this source'
+ else if (membership === 'revoked') status = 'Your access was removed by an admin'
+ else if (source.connectionRequired && membership === null) status = 'Needs admin attention'
+ else if (connectable)
+ status = waiting
+ ? 'Finish connecting in the other tab'
+ : membership === 'needs_reauth'
+ ? 'Your account needs to be reconnected'
+ : 'Connect your account to search this source'
+ else if (source.hasSyncError)
+ status =
+ source.viewerDocumentCount > 0
+ ? `Sync needs attention · ${count}`
+ : 'Sync needs admin attention'
+ else if (source.isSyncing)
+ status = source.viewerDocumentCount > 0 ? `Indexing · ${count}` : 'Indexing'
+ else if (source.viewerDocumentCount > 0) status = count
+ else status = source.lastSyncAt ? 'No searchable documents yet' : 'Waiting for the first sync'
+
+ return (
+ : undefined
+ }
+ title={name}
+ description={[source.sourceDescription, status].filter(Boolean).join(' · ')}
+ trailing={
+ !supported && scope.kind === 'workspace' ? (
+
+ {canAdmin ? 'Manage' : 'View'}
+
+ ) : (
+
+ {connectable && (
+
+ {waiting
+ ? 'Open again'
+ : membership === 'needs_reauth'
+ ? 'Reconnect'
+ : 'Connect account'}
+
+ )}
+ {canAdmin &&
+ onManage &&
+ (connectable ? (
+
+ ) : (
+ Manage
+ ))}
+
+ )
+ }
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx
new file mode 100644
index 00000000000..06fd580eb6e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx
@@ -0,0 +1,1767 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, cloneElement, type ReactNode } from 'react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ canAdmin: true,
+ availabilityReady: true,
+ availabilityLoading: false,
+ availabilityError: null as Error | null,
+ refetchAvailability: vi.fn(),
+ unavailableProviders: [] as string[],
+ integrationAvailability: new Map<
+ string,
+ { oauthAvailable: boolean; state: 'ready' | 'limited' | 'unavailable' | 'misconfigured' }
+ >(),
+ userId: 'user-1',
+ urlUpdate: vi.fn(),
+ oauthReturn: vi.fn(),
+ sourceStatus: vi.fn(),
+ features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true },
+ create: vi.fn(),
+ update: vi.fn(),
+ applyAccess: vi.fn(),
+ prepare: vi.fn(),
+ createPending: false,
+ updatePending: false,
+ accessPending: false,
+ basesPending: false,
+ basesError: null as Error | null,
+ connectorsError: null as Error | null,
+ connectorsPending: false,
+ refetchBases: vi.fn(),
+ refetchConnectors: vi.fn(),
+ preparePending: false,
+ prepareError: null as Error | null,
+ prepareData: undefined as { knowledgeBaseId: string } | undefined,
+ bases: [{ id: 'kb-search', name: 'Sim Search', isSearchIndex: true }] as {
+ id: string
+ name: string
+ isSearchIndex?: boolean
+ }[],
+ connectors: [] as { id: string; connectorType: string; accessMode: string; status: string }[],
+ credentials: [{ id: 'cred-source', name: 'Indexing account', provider: 'slack' }] as {
+ id: string
+ name: string
+ provider: string
+ type?: 'oauth' | 'service_account'
+ }[],
+ credentialGroup: null as {
+ id: string
+ name: string
+ status: string
+ options: {
+ id: string
+ label: string
+ status: string
+ provider: string
+ configurationStatus: string
+ }[]
+ } | null,
+ basesQuery: vi.fn(),
+ connectorsQuery: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { id: mocks.userId } } }),
+}))
+vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: mocks.oauthReturn }))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ integrationAvailability: new Map([
+ ['slack', { oauthAvailable: true, state: 'ready' }],
+ ['slack_v2', { oauthAvailable: true, state: 'ready' }],
+ ...mocks.integrationAvailability,
+ ]),
+ oauthServiceAvailability: new Map(
+ [
+ 'confluence',
+ 'google-drive',
+ 'google_drive',
+ 'google-email',
+ 'google-calendar',
+ 'jira',
+ 'github-repositories',
+ ].map((providerId) => [providerId, !mocks.unavailableProviders.includes(providerId)])
+ ),
+ isIntegrationAvailabilityReady: mocks.availabilityReady,
+ isIntegrationAvailabilityLoading: mocks.availabilityLoading,
+ integrationAvailabilityError: mocks.availabilityError,
+ refetchIntegrationAvailability: mocks.refetchAvailability,
+ }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-status', () => ({
+ SearchSourceStatus: (props: { knowledgeBaseId: string; connectorType: string }) => {
+ mocks.sourceStatus(props)
+ return Source sync status
+ },
+}))
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+ usePathname: () => '/workspace/workspace-1/search',
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useWorkspaceHostContext: () => ({ ownerBilling: {}, features: mocks.features }),
+ useOptionalWorkspaceHostContext: () => ({ ownerBilling: {}, features: mocks.features }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useUserPermissionsContext: () => ({ canAdmin: mocks.canAdmin }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope', () => ({
+ useConnectorScope: (
+ scope?:
+ | { kind: 'workspace'; workspaceId: string }
+ | { kind: 'organization'; organizationId: string }
+ ) => ({
+ scope: scope ?? { kind: 'workspace', workspaceId: 'workspace-1' },
+ canAdmin: mocks.canAdmin,
+ memberAccessAvailable: mocks.features.knowledgeMemberAccess,
+ mirroredAccessAvailable: mocks.features.knowledgeSourceMirroredAccess,
+ hasMaxAccess: true,
+ }),
+}))
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ useSearchIndex: (
+ scope: { workspaceId?: string; organizationId?: string },
+ options: { enabled: boolean }
+ ) => {
+ mocks.basesQuery(scope.workspaceId ?? scope.organizationId, options)
+ return {
+ data: { knowledgeBaseId: mocks.bases.find((base) => base.isSearchIndex)?.id ?? null },
+ isPending: mocks.basesPending,
+ isError: Boolean(mocks.basesError),
+ error: mocks.basesError,
+ isFetching: false,
+ refetch: mocks.refetchBases,
+ }
+ },
+ useCreateConnector: () => ({ mutate: mocks.create, isPending: mocks.createPending }),
+ useUpdateConnector: () => ({ mutate: mocks.update, isPending: mocks.updatePending }),
+ useUpdateConnectorAccess: () => ({ mutate: mocks.applyAccess, isPending: mocks.accessPending }),
+ usePrepareSearchSource: () => ({
+ mutate: mocks.prepare,
+ data: mocks.prepareData,
+ isPending: mocks.preparePending,
+ error: mocks.prepareError,
+ }),
+ useConnectorList: (id?: string) => {
+ mocks.connectorsQuery(id)
+ return {
+ data: mocks.connectors,
+ isError: Boolean(mocks.connectorsError),
+ error: mocks.connectorsError,
+ isPending: mocks.connectorsPending,
+ isSuccess: !mocks.connectorsPending && !mocks.connectorsError,
+ isFetching: mocks.connectorsPending,
+ refetch: mocks.refetchConnectors,
+ }
+ },
+ useConnectorDocuments: () => ({ data: { documents: [], total: 0 }, isLoading: false }),
+ useExcludeConnectorDocument: () => ({ mutate: vi.fn(), isPending: false }),
+ useRestoreConnectorDocument: () => ({ mutate: vi.fn(), isPending: false }),
+}))
+vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
+ useOAuthCredentials: () => ({
+ data: mocks.credentials,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}))
+vi.mock('@/hooks/queries/source-accounts', () => ({
+ useSourceAccounts: () => ({
+ data: { credentialGroup: mocks.credentialGroup },
+ isLoading: false,
+ isPending: false,
+ isSuccess: true,
+ isError: false,
+ isFetching: false,
+ refetch: vi.fn(),
+ error: null,
+ }),
+}))
+vi.mock('@/hooks/queries/selectors', () => ({
+ useSelectorOptions: () => ({ data: [], isLoading: false, loadMore: vi.fn(), loadAll: vi.fn() }),
+ useSelectorOptionDetails: () => ({ data: [], isLoading: false }),
+ useSelectorOptionDetail: () => ({ data: undefined }),
+}))
+vi.mock('@/hooks/use-credential-refresh-triggers', () => ({
+ useCredentialRefreshTriggers: () => undefined,
+}))
+
+import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors'
+import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors'
+import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
+import { AddConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal'
+import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal'
+import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
+import { useConnectorSetupStore } from '@/stores/connector-setup/store'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+async function render(node: ReactNode, searchParams = '') {
+ if (!root) {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ }
+ await act(async () =>
+ root?.render(
+
+ {node}
+
+ )
+ )
+}
+
+function button(label: string): HTMLButtonElement {
+ const match = Array.from(document.querySelectorAll('button')).find(
+ (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label
+ )
+ expect(match, `Button ${label}`).toBeDefined()
+ return match as HTMLButtonElement
+}
+
+async function click(element: HTMLElement) {
+ await act(async () => element.click())
+}
+
+async function fill(placeholder: string, value: string) {
+ const input = document.querySelector(`input[placeholder="${placeholder}"]`)
+ expect(input, `Input ${placeholder}`).not.toBeNull()
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
+ input?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+async function chooseCombo(currentLabel: string, nextLabel: string) {
+ const combo = Array.from(document.querySelectorAll('[role="combobox"]')).find(
+ (node) => node.textContent?.includes(currentLabel)
+ )
+ expect(combo, `Combobox ${currentLabel}`).toBeDefined()
+ await click(combo!)
+ const option = Array.from(document.querySelectorAll('[role="option"]')).find(
+ (node) => node.textContent?.trim() === nextLabel
+ )
+ expect(option, `Option ${nextLabel}`).toBeDefined()
+ await act(async () => option?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
+}
+
+function connector(overrides: Partial = {}): ConnectorData {
+ return {
+ id: 'connector-1',
+ knowledgeBaseId: 'kb-search',
+ connectorType: 'slack',
+ credentialId: null,
+ sourceConfig: {},
+ syncMode: 'full',
+ syncIntervalMinutes: 1440,
+ status: 'active',
+ lastSyncAt: null,
+ lastSyncError: null,
+ lastSyncDocCount: null,
+ nextSyncAt: null,
+ consecutiveFailures: 0,
+ accessMode: 'members',
+ viewerMembership: null,
+ credentialGroupId: 'group-1',
+ credentialGroupOptionId: 'option-1',
+ memberSyncStatus: 'idle',
+ lastMemberSyncAt: null,
+ nextMemberSyncAt: null,
+ lastMemberSyncError: null,
+ memberSyncConsecutiveFailures: 0,
+ accessRewritePending: false,
+ createdAt: '2026-09-04T00:00:00Z',
+ updatedAt: '2026-09-04T00:00:00Z',
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.userId = 'user-1'
+ useConnectorSetupStore.getState().reset()
+ mocks.canAdmin = true
+ mocks.availabilityReady = true
+ mocks.availabilityLoading = false
+ mocks.availabilityError = null
+ mocks.unavailableProviders = []
+ mocks.features = { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true }
+ mocks.createPending = false
+ mocks.updatePending = false
+ mocks.accessPending = false
+ mocks.basesPending = false
+ mocks.basesError = null
+ mocks.connectorsError = null
+ mocks.connectorsPending = false
+ mocks.preparePending = false
+ mocks.prepareError = null
+ mocks.prepareData = undefined
+ mocks.bases = [{ id: 'kb-search', name: 'Sim Search', isSearchIndex: true }]
+ mocks.connectors = []
+ mocks.integrationAvailability.clear()
+ mocks.credentials = [{ id: 'cred-source', name: 'Indexing account', provider: 'slack' }]
+ mocks.credentialGroup = {
+ id: 'group-1',
+ name: 'Workspace accounts',
+ status: 'active',
+ options: [
+ {
+ id: 'option-1',
+ label: 'Slack',
+ provider: 'slack',
+ status: 'active',
+ configurationStatus: 'ready',
+ },
+ ],
+ }
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ Element.prototype.scrollIntoView = vi.fn()
+})
+
+afterEach(async () => {
+ await act(async () => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+ vi.restoreAllMocks()
+})
+
+function setup() {
+ return (
+
+ )
+}
+
+describe('Search source setup with real connector dialogs', () => {
+ it.each([false, true])(
+ 'does not fetch admin data while closed for canAdmin=%s',
+ async (canAdmin) => {
+ mocks.canAdmin = canAdmin
+ await render(setup())
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ }
+ )
+
+ it.each(['?addConnector=gitlab', '?manage-source=site-one', '?manage-source=confluence'])(
+ 'does not expose the catalog or admin queries to a reader opening %s',
+ async (searchParams) => {
+ mocks.canAdmin = false
+ await render(setup(), searchParams)
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ }
+ )
+
+ it('closes source management and disables admin queries when the viewer loses admin access', async () => {
+ mocks.connectors = [
+ { id: 'site-one', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
+ ]
+ await render(setup(), '?manage-source=site-one')
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull()
+ mocks.canAdmin = false
+ mocks.sourceStatus.mockClear()
+ await render(setup(), '?manage-source=site-one')
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ expect(mocks.sourceStatus).not.toHaveBeenCalled()
+ })
+
+ it('lists each eligible provider once and filters the add-source catalog', async () => {
+ await render(setup(), '?addConnector=')
+ expect(
+ Array.from(document.querySelectorAll('button')).filter(
+ (node) => node.textContent === 'Set up'
+ )
+ ).toHaveLength(8)
+ for (const name of [
+ 'Confluence',
+ 'GitHub',
+ 'GitLab',
+ 'Gmail',
+ 'Google Calendar',
+ 'Google Drive',
+ 'Jira',
+ 'Slack',
+ ]) {
+ expect(document.body.textContent).toContain(name)
+ }
+ await fill('Find a source…', 'no-such-source')
+ expect(document.body.textContent).toContain('No matching sources.')
+ expect(document.body.textContent).not.toContain('Google Drive')
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ })
+
+ it('waits for availability and offers a retry after it fails', async () => {
+ mocks.availabilityReady = false
+ mocks.availabilityLoading = true
+ await render(setup(), '?addConnector=')
+ expect(document.body.textContent).toContain('Loading sources…')
+ expect(
+ Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up')
+ ).toBe(false)
+ mocks.availabilityLoading = false
+ mocks.availabilityError = new Error('Availability failed')
+ await render(setup(), '?addConnector=')
+ expect(document.body.textContent).toContain('Availability failed')
+ await click(button('Try again'))
+ expect(mocks.refetchAvailability).toHaveBeenCalledOnce()
+ })
+
+ it('does not offer GitHub App setup when only its workflow token integration is available', async () => {
+ mocks.unavailableProviders = ['github-repositories']
+ await render(setup(), '?addConnector=')
+ expect(
+ Array.from(document.querySelectorAll('button')).filter(
+ (node) => node.textContent === 'Set up'
+ )
+ ).toHaveLength(7)
+ expect(document.body.textContent).toContain('GitHub')
+ expect(document.body.textContent).toContain('Not available in this workspace')
+ })
+
+ it('preserves a setup draft while an availability refresh fails and recovers', async () => {
+ await render(setup(), '?addConnector=github')
+ await fill('owner/repo', 'acme/docs')
+ expect(button('Create & Invite').disabled).toBe(false)
+ mocks.availabilityReady = false
+ mocks.availabilityError = new Error('Availability refresh failed')
+ await render(setup(), '?addConnector=github')
+ expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe(
+ 'acme/docs'
+ )
+ expect(button('Create & Invite').disabled).toBe(true)
+ await click(button('Try again'))
+ expect(mocks.refetchAvailability).toHaveBeenCalledOnce()
+ mocks.availabilityReady = true
+ mocks.availabilityError = null
+ await render(setup(), '?addConnector=github')
+ expect(button('Create & Invite').disabled).toBe(false)
+ })
+
+ it.each(['gmail', 'jira', 'github', 'google_calendar'])(
+ 'sets up %s with member access and no shared workspace or admin mode',
+ async (type) => {
+ await render(setup(), `?addConnector=${type}`)
+ expect(document.body.textContent).toContain('Member accounts')
+ expect(
+ Array.from(document.querySelectorAll('button')).some((node) =>
+ ['Workspace', 'Admin or service account'].includes(node.textContent ?? '')
+ )
+ ).toBe(false)
+ expect(document.body.textContent).not.toContain('Sync Frequency')
+ expect(document.body.textContent).not.toContain('Max Threads')
+ expect(document.body.textContent).not.toContain('Max Events')
+ expect(document.body.textContent).not.toContain('Max Files')
+ expect(document.body.textContent).not.toContain('Max Issues')
+ if (type === 'github') await fill('owner/repo', 'acme/docs')
+ if (type === 'jira') {
+ expect(button('Create & Invite').disabled).toBe(true)
+ await fill('yoursite.atlassian.net', 'acme.atlassian.net')
+ const modeToggle = document.querySelector(
+ 'button[aria-label="Switch Projects to manual input"]'
+ )
+ expect(modeToggle).not.toBeNull()
+ await click(modeToggle!)
+ await fill('e.g. ENG, PROJ (comma-separated for multiple)', 'ENG')
+ }
+ if (type === 'gmail') {
+ expect(document.body.textContent).not.toContain('Browse with')
+ expect(
+ document.querySelector('input[placeholder="e.g. INBOX, Engineering (comma-separated)"]')
+ ).not.toBeNull()
+ expect(document.querySelector('button[aria-label="Switch Labels to selector"]')).toBeNull()
+ }
+ expect(button('Create & Invite').disabled).toBe(false)
+ await click(button('Create & Invite'))
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ knowledgeBaseId: 'kb-search',
+ connectorType: type,
+ accessMode: 'members',
+ }),
+ expect.any(Object)
+ )
+ }
+ )
+
+ it('prepares a canonical index instead of an ordinary base with the Search name', async () => {
+ mocks.bases = [{ id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false }]
+ await render(setup(), '?addConnector=gitlab')
+ await click(button('Continue setup'))
+ expect(mocks.prepare).toHaveBeenCalledWith(
+ { workspaceId: 'workspace-1', connectorType: 'gitlab', accessMode: 'admin' },
+ expect.any(Object)
+ )
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ })
+
+ it('prepares organization connected-account indexing in members mode even when central access is available', async () => {
+ mocks.bases = []
+ await render(
+ ,
+ '?addConnector=slack'
+ )
+ await click(button('Continue setup'))
+ expect(mocks.prepare).toHaveBeenCalledWith(
+ { organizationId: 'org-1', connectorType: 'slack', accessMode: 'members' },
+ expect.any(Object)
+ )
+ })
+
+ it('does not reuse mutation data after the current index has been removed', async () => {
+ mocks.prepareData = { knowledgeBaseId: 'kb-search' }
+ mocks.bases = []
+ await render(setup(), '?addConnector=gitlab')
+ await click(button('Continue setup'))
+ expect(mocks.prepare).toHaveBeenCalled()
+ expect(document.querySelector('input[placeholder="Enter your GitLab PAT"]')).toBeNull()
+ })
+
+ it.each(['bases', 'connectors'] as const)(
+ 'retries a failed %s discovery query',
+ async (query) => {
+ if (query === 'bases') mocks.basesError = new Error('Base discovery failed')
+ else mocks.connectorsError = new Error('Connector discovery failed')
+ await render(setup(), query === 'bases' ? '?addConnector=' : '?manage-source=gitlab-1')
+ expect(document.body.textContent).toContain('discovery failed')
+ await click(button('Try again'))
+ expect(
+ query === 'bases' ? mocks.refetchBases : mocks.refetchConnectors
+ ).toHaveBeenCalledOnce()
+ }
+ )
+
+ it('manages the exact source ID in a renamed canonical index', async () => {
+ mocks.bases = [
+ { id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false },
+ { id: 'renamed-index', name: 'Company knowledge', isSearchIndex: true },
+ ]
+ mocks.connectors = [
+ { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
+ { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
+ ]
+ await render(setup(), '?manage-source=site-two')
+ expect(mocks.connectorsQuery).toHaveBeenLastCalledWith('renamed-index')
+ expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ knowledgeBaseId: 'renamed-index',
+ connectorType: 'confluence',
+ connectors: [mocks.connectors[1]],
+ })
+ )
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ })
+
+ it.each(['unknown-source', 'deleted-source'])(
+ 'shows unavailable for a missing connector ID: %s',
+ async (id) => {
+ mocks.connectors = [
+ {
+ id: 'existing-source',
+ connectorType: 'confluence',
+ accessMode: 'members',
+ status: 'active',
+ },
+ ]
+ await render(setup(), `?manage-source=${id}`)
+ expect(document.body.textContent).toContain('This source is no longer available.')
+ expect(document.body.textContent).not.toContain('Source sync status')
+ expect(mocks.sourceStatus).not.toHaveBeenCalled()
+ }
+ )
+
+ it('waits for connector discovery before declaring a management link unavailable', async () => {
+ mocks.connectorsPending = true
+ await render(setup(), '?manage-source=site-one')
+ expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
+ expect.objectContaining({ isLoading: true, connectors: [] })
+ )
+ expect(document.body.textContent).not.toContain('This source is no longer available.')
+ mocks.connectorsPending = false
+ mocks.connectors = [
+ { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
+ ]
+ await render(setup(), '?manage-source=site-one')
+ expect(document.body.textContent).toContain('Source sync status')
+ expect(document.body.textContent).not.toContain('This source is no longer available.')
+ expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
+ expect.objectContaining({ connectorType: 'confluence', connectors: mocks.connectors })
+ )
+ })
+
+ it('keeps a failed connector lookup retryable instead of treating it as deletion', async () => {
+ mocks.connectorsError = new Error('Source lookup failed')
+ await render(setup(), '?manage-source=site-one')
+ expect(document.body.textContent).toContain('Source lookup failed')
+ expect(document.body.textContent).not.toContain('This source is no longer available.')
+ expect(mocks.sourceStatus).not.toHaveBeenCalled()
+ await click(button('Try again'))
+ expect(mocks.refetchConnectors).toHaveBeenCalledOnce()
+ mocks.connectorsError = null
+ await render(setup(), '?manage-source=site-one')
+ expect(document.body.textContent).toContain('This source is no longer available.')
+ })
+
+ it('preserves existing provider-based management URLs', async () => {
+ mocks.connectors = [
+ { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
+ { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
+ ]
+ await render(setup(), '?manage-source=confluence')
+ expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
+ expect.objectContaining({ connectors: mocks.connectors })
+ )
+ })
+
+ it('opens GitLab with its single central method and submits the custom host and PAT', async () => {
+ await render(setup(), '?addConnector=gitlab')
+ expect(document.body.textContent).toContain('Admin or service account')
+ expect(document.body.textContent).not.toContain('Member accounts')
+ expect(button('Connect & Sync')).toBeDisabled()
+ await fill('Enter your GitLab PAT', 'test-pat')
+ await fill('gitlab.com', 'gitlab.example.test')
+ await fill('group/project or numeric ID', 'engineering/search')
+ await click(button('Connect & Sync'))
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ knowledgeBaseId: 'kb-search',
+ connectorType: 'gitlab',
+ accessMode: 'admin',
+ apiKey: 'test-pat',
+ sourceConfig: expect.objectContaining({
+ host: 'gitlab.example.test',
+ project: 'engineering/search',
+ }),
+ syncIntervalMinutes: 60,
+ }),
+ expect.any(Object)
+ )
+ expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('credentialId')
+ })
+
+ it('prepares Slack in members mode when mirrored access is disabled', async () => {
+ mocks.features.knowledgeSourceMirroredAccess = false
+ mocks.bases = []
+ await render(setup(), '?addConnector=slack')
+ await click(button('Continue setup'))
+ expect(mocks.prepare).toHaveBeenCalledWith(
+ { workspaceId: 'workspace-1', connectorType: 'slack', accessMode: 'members' },
+ expect.any(Object)
+ )
+ })
+
+ it('blocks unavailable catalog providers and duplicate preparation while preserving retry feedback', async () => {
+ mocks.features.knowledgeMemberAccess = false
+ mocks.features.knowledgeSourceMirroredAccess = false
+ await render(setup(), '?addConnector=')
+ expect(document.body.textContent).toContain('Not available in this workspace')
+ expect(
+ Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up')
+ ).toBe(false)
+ mocks.features.knowledgeSourceMirroredAccess = true
+ mocks.bases = []
+ mocks.preparePending = true
+ mocks.prepareError = new Error('Source preparation failed')
+ await render(setup(), '?addConnector=')
+ await fill('Find a source…', 'gitlab')
+ expect(button('Set up')).toBeDisabled()
+ expect(document.body.textContent).toContain('Source preparation failed')
+ })
+})
+
+describe('member content credentials in real add and edit dialogs', () => {
+ it('links Slack setup to the existing app and credential-group screens when no ready option exists', async () => {
+ mocks.credentialGroup = {
+ id: 'group-1',
+ name: 'Search',
+ status: 'active',
+ options: [
+ {
+ id: 'option-1',
+ label: 'Slack',
+ provider: 'slack',
+ status: 'active',
+ configurationStatus: 'pending',
+ },
+ ],
+ }
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Each teammate connects their Slack account.')
+ expect(
+ Array.from(document.querySelectorAll('a')).map((node) => node.getAttribute('href'))
+ ).toEqual(['/workspace/workspace-1/settings/credential-groups'])
+ })
+
+ it.each([
+ { status: 'disabled', optionStatus: 'active', provider: 'slack' },
+ { status: 'active', optionStatus: 'disabled', provider: 'slack' },
+ { status: 'active', optionStatus: 'active', provider: 'gmail' },
+ ])(
+ 'offers Slack setup when the workspace provider is unavailable: %o',
+ async ({ status, optionStatus, provider }) => {
+ mocks.credentialGroup = {
+ id: 'group-1',
+ name: 'Workspace accounts',
+ status,
+ options: [
+ {
+ id: 'option-1',
+ label: provider,
+ provider,
+ status: optionStatus,
+ configurationStatus: 'ready',
+ },
+ ],
+ }
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Set up Slack')
+ expect(document.body.textContent).not.toContain('Choose member accounts')
+ }
+ )
+
+ it('does not select a dedicated content credential just because one browse account exists', async () => {
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Connected members')
+ expect(document.body.textContent).not.toContain('Max Messages')
+ await click(button('Create & Invite'))
+ expect(mocks.create.mock.calls[0][0]).toMatchObject({
+ connectorType: 'slack',
+ accessMode: 'members',
+ })
+ expect(mocks.create.mock.calls[0][0].credentialId).toBeUndefined()
+ expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('credentialGroupId')
+ expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('credentialGroupOptionId')
+ })
+
+ it('submits a deliberately selected dedicated account and clears source-specific state on back', async () => {
+ await render(
+
+ )
+ await chooseCombo('Connected members', 'Indexing account')
+ await fill('e.g. hr, legal, C01ABC23DEF', 'legal')
+ await click(button('Create & Invite'))
+ expect(mocks.create.mock.calls[0][0]).toMatchObject({
+ credentialId: 'cred-source',
+ sourceConfig: { excludeChannels: 'legal' },
+ })
+ await click(button('Choose another source'))
+ await fill('Search sources...', 'gitlab')
+ const card = Array.from(document.querySelectorAll('button')).find(
+ (node) => node.getAttribute('aria-label') === 'GitLab'
+ )
+ await click(card!)
+ expect(document.body.textContent).not.toContain('Connected members')
+ expect(button('Workspace')).toHaveAttribute('aria-checked', 'true')
+ await fill('Enter your GitLab PAT', 'new-pat')
+ await fill('group/project or numeric ID', '1')
+ await click(button('Connect & Sync'))
+ expect(mocks.create.mock.calls[1][0]).toMatchObject({
+ connectorType: 'gitlab',
+ accessMode: 'workspace',
+ apiKey: 'new-pat',
+ })
+ expect(mocks.create.mock.calls[1][0].sourceConfig).not.toHaveProperty('excludeChannels')
+ expect(mocks.create.mock.calls[1][0]).not.toHaveProperty('credentialId')
+ })
+
+ it('changes indexing authority without warning that the existing member group will lose access', async () => {
+ await render(
+
+ )
+ await chooseCombo('Connected members', 'Indexing account')
+ expect(document.body.textContent).not.toContain('Members of the previous group lose access')
+ expect(button('Save')).toBeDisabled()
+ await click(button('Change indexing account'))
+ expect(mocks.applyAccess).toHaveBeenCalledWith(
+ expect.objectContaining({
+ knowledgeBaseId: 'kb-search',
+ connectorId: 'connector-1',
+ access: {
+ accessMode: 'members',
+ credentialId: 'cred-source',
+ },
+ }),
+ expect.any(Object)
+ )
+ })
+
+ it('uses the configured workspace provider without a group selector and preserves its content account', async () => {
+ mocks.credentialGroup = {
+ id: 'group-1',
+ name: 'Workspace accounts',
+ status: 'active',
+ options: [
+ {
+ id: 'option-1',
+ label: 'Slack',
+ provider: 'slack',
+ status: 'active',
+ configurationStatus: 'ready',
+ },
+ ],
+ }
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Indexing account')
+ expect(document.body.textContent).not.toContain('Choose member accounts')
+ expect(document.body.textContent).not.toContain('Change credential group')
+ expect(document.body.textContent).not.toContain('Set up Slack')
+ expect(button('Save')).toBeDisabled()
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+ })
+
+ it('distinguishes content scheduling from permission checks and makes manual expiry visible', async () => {
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Source permissions require a sync every hour')
+ await chooseCombo('Connected members', 'Indexing account')
+ expect(document.body.textContent).toContain(
+ 'Content follows this schedule. Member permissions are checked every hour.'
+ )
+ await click(button('Manual only'))
+ expect(document.body.textContent).toContain('Documents become unavailable after 24 hours')
+ await click(button('Every hour'))
+ expect(document.body.textContent).toContain('Permissions are checked on every sync.')
+ })
+
+ it('saves source settings without changing a dedicated indexing account', async () => {
+ await render(
+
+ )
+ await fill('e.g. hr, legal, C01ABC23DEF', 'legal')
+ await click(button('Save'))
+ expect(mocks.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectorId: 'connector-1',
+ updates: { sourceConfig: expect.objectContaining({ excludeChannels: 'legal' }) },
+ }),
+ expect.any(Object)
+ )
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+ })
+
+ it('sends explicit null when returning to connected members and locks access controls for readers', async () => {
+ const existing = connector({ credentialId: 'cred-source' })
+ await render(
+
+ )
+ await chooseCombo('Indexing account', 'Connected members')
+ await click(button('Change indexing account'))
+ expect(mocks.applyAccess.mock.calls[0][0].access.credentialId).toBeNull()
+ mocks.canAdmin = false
+ await render(
+
+ )
+ const combo = Array.from(document.querySelectorAll('[role="combobox"]')).find((node) =>
+ node.textContent?.includes('Indexing account')
+ )
+ expect(combo).toHaveAttribute('aria-disabled', 'true')
+ expect(document.body.textContent).toContain('Member accounts')
+ expect(
+ Array.from(document.querySelectorAll('[role="radio"]')).filter((node) =>
+ ['Workspace', 'Member accounts', 'Admin or service account'].includes(
+ node.textContent ?? ''
+ )
+ )
+ ).toHaveLength(0)
+ expect(document.body.textContent).not.toContain('Change indexing account')
+ })
+})
+
+describe('administrator source prerequisites in real connector dialogs', () => {
+ const adminEmailPlaceholder = 'admin@yourcompany.com'
+ const folderPlaceholder = 'e.g. 1aBcDeFg…, 2cDeFgHi… (comma-separated for multiple)'
+ const driveCredential = {
+ id: 'drive-credential',
+ name: 'Drive indexing account',
+ provider: 'google-drive',
+ type: 'service_account' as const,
+ }
+
+ beforeEach(() => {
+ mocks.credentials = [driveCredential]
+ })
+
+ it.each(['ready', 'limited', 'unavailable', 'misconfigured'] as const)(
+ 'uses canonical Confluence availability for inline service-account setup when %s',
+ async (state) => {
+ mocks.credentials = []
+ mocks.integrationAvailability.set('confluence_v2', { oauthAvailable: true, state })
+ await render(
+
+ )
+ const picker = Array.from(document.querySelectorAll('[role="combobox"]')).find(
+ (node) => node.textContent?.includes('Select Confluence account')
+ )
+ expect(picker).toBeDefined()
+ await click(picker!)
+ const serviceAccountOption = Array.from(
+ document.querySelectorAll('[role="option"]')
+ ).find((node) => node.textContent?.trim() === 'Add service account')
+
+ expect(Boolean(serviceAccountOption)).toBe(state === 'ready' || state === 'limited')
+ }
+ )
+
+ it.each(['admin', 'workspace'] as const)(
+ 'offers inline Drive service-account setup only when a general KB requires it in %s mode',
+ async (accessMode) => {
+ mocks.credentials = []
+ mocks.integrationAvailability.set('google_drive', { oauthAvailable: true, state: 'ready' })
+ await render(
+
+ )
+ const picker = Array.from(document.querySelectorAll('[role="combobox"]')).find(
+ (node) =>
+ node.textContent?.includes(
+ accessMode === 'admin' ? 'Select a service account' : 'Select Google Drive account'
+ )
+ )
+ expect(picker).toBeDefined()
+ await click(picker!)
+ const options = Array.from(document.querySelectorAll('[role="option"]')).map(
+ (node) => node.textContent?.trim()
+ )
+
+ expect(options.includes('Add service account')).toBe(accessMode === 'admin')
+ expect(options.includes('Connect Google Drive account')).toBe(accessMode === 'workspace')
+ }
+ )
+
+ it('marks Crawl as required in Drive administrator mode and refuses empty or blank subjects', async () => {
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Crawl as*')
+ expect(button('Connect & Sync')).toBeDisabled()
+ await click(button('Connect & Sync'))
+ expect(mocks.create).not.toHaveBeenCalled()
+ await fill(adminEmailPlaceholder, ' ')
+ expect(button('Connect & Sync')).toBeDisabled()
+
+ await fill(adminEmailPlaceholder, 'admin@example.com')
+ expect(button('Connect & Sync')).toBeEnabled()
+ await click(button('Connect & Sync'))
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectorType: 'google_drive',
+ accessMode: 'admin',
+ credentialId: driveCredential.id,
+ sourceConfig: expect.objectContaining({ adminEmail: 'admin@example.com' }),
+ }),
+ expect.any(Object)
+ )
+ })
+
+ it('excludes personal OAuth accounts and stale OAuth drafts from Drive administrator setup', async () => {
+ const oauthCredential = {
+ id: 'drive-personal',
+ name: 'Personal Drive account',
+ provider: 'google-drive',
+ type: 'oauth' as const,
+ }
+ mocks.credentials = [oauthCredential]
+ const setupDraftKey = 'user-1:workspace-1:kb-search:google_drive'
+ useConnectorSetupStore.getState().saveDraft(setupDraftKey, {
+ sourceConfig: { adminEmail: 'admin@example.com' },
+ canonicalModes: {},
+ accessMode: 'admin',
+ credentialId: oauthCredential.id,
+ contentCredentialId: null,
+ disabledTagIds: [],
+ savedAt: Date.now(),
+ })
+ const modal = (
+
+ )
+ await render(modal)
+ expect(document.body.textContent).toContain('Service account')
+ expect(document.body.textContent).not.toContain(oauthCredential.name)
+ expect(button('Connect & Sync')).toBeDisabled()
+ const picker = Array.from(document.querySelectorAll('[role="combobox"]')).find(
+ (node) => node.textContent?.includes('Select a service account')
+ )!
+ await click(picker)
+ expect(document.body.textContent).not.toContain('Connect Google Drive account')
+ expect(document.body.textContent).not.toContain(oauthCredential.name)
+ await click(picker)
+ mocks.credentials = [oauthCredential, driveCredential]
+ await render(cloneElement(modal))
+
+ expect(button('Connect & Sync')).toBeEnabled()
+ await click(button('Connect & Sync'))
+
+ expect(mocks.create).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ credentialId: driveCredential.id, accessMode: 'admin' }),
+ expect.any(Object)
+ )
+ })
+
+ it('replaces an existing Drive administrator account through the access operation', async () => {
+ const oauthCredential = {
+ id: 'drive-personal',
+ name: 'Personal Drive account',
+ provider: 'google-drive',
+ type: 'oauth' as const,
+ }
+ const replacement = { ...driveCredential, id: 'drive-new', name: 'Replacement service account' }
+ mocks.credentials = [oauthCredential, driveCredential, replacement]
+ await render(
+
+ )
+ const picker = Array.from(document.querySelectorAll('[role="combobox"]')).find(
+ (node) => node.textContent?.includes(driveCredential.name)
+ )!
+ await click(picker)
+ expect(document.body.textContent).not.toContain(oauthCredential.name)
+ const option = Array.from(document.querySelectorAll('[role="option"]')).find(
+ (node) => node.textContent?.trim() === replacement.name
+ )!
+ await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
+ expect(button('Save')).toBeDisabled()
+ expect(button('Change indexing account')).toBeEnabled()
+
+ await click(button('Change indexing account'))
+
+ expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith(
+ {
+ knowledgeBaseId: 'kb-search',
+ connectorId: 'connector-1',
+ access: { accessMode: 'admin', credentialId: replacement.id },
+ },
+ expect.any(Object)
+ )
+ expect(mocks.update).not.toHaveBeenCalled()
+ })
+
+ it.each(['members', 'workspace'] as const)(
+ 'keeps the Drive crawl subject optional in %s mode',
+ async (accessMode) => {
+ await render(
+
+ )
+ const subjectLabel = accessMode === 'members' ? 'Sync documents with' : 'Crawl as'
+ expect(document.body.textContent).toContain(subjectLabel)
+ expect(document.body.textContent).not.toContain(`${subjectLabel}*`)
+ const submit = button(accessMode === 'members' ? 'Create & Invite' : 'Connect & Sync')
+ expect(submit).toBeEnabled()
+ await click(submit)
+ expect(mocks.create.mock.calls[0][0]).toMatchObject({
+ knowledgeBaseId: 'general-kb',
+ connectorType: 'google_drive',
+ accessMode,
+ })
+ expect(mocks.create.mock.calls[0][0].sourceConfig.adminEmail).toBeFalsy()
+ }
+ )
+
+ it('does not let an administrator erase the crawl subject from an existing mirrored Drive source', async () => {
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Crawl as*')
+ await fill(adminEmailPlaceholder, '')
+ expect(button('Save')).toBeDisabled()
+ await click(button('Save'))
+ expect(mocks.update).not.toHaveBeenCalled()
+ await fill(adminEmailPlaceholder, 'replacement@example.com')
+ expect(button('Save')).toBeEnabled()
+ await click(button('Save'))
+ expect(mocks.update.mock.calls[0][0]).toMatchObject({
+ connectorId: 'connector-1',
+ updates: {
+ sourceConfig: {
+ adminEmail: 'replacement@example.com',
+ fileType: 'documents',
+ },
+ },
+ })
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+ })
+
+ it('guides a member source back to saving its crawl subject without losing drafts or combining mutations', async () => {
+ const existing = connector({
+ connectorType: 'google_drive',
+ sourceConfig: { folderId: 'original-folder', _canonicalModes: { folderId: 'advanced' } },
+ })
+ await render(
+
+ )
+ await fill(folderPlaceholder, 'draft-folder')
+ await click(button('Service account'))
+ expect(document.body.textContent).toContain(
+ 'Set Crawl as and save your settings before changing the connection method.'
+ )
+ expect(button('Apply connection method')).toBeDisabled()
+ expect(button('Save')).toBeDisabled()
+ await fill(adminEmailPlaceholder, 'admin@example.com')
+ expect(button('Apply connection method')).toBeDisabled()
+ await click(button('Edit settings'))
+
+ expect(button('Member accounts')).toHaveAttribute('aria-checked', 'true')
+ expect(document.querySelector(`input[placeholder="${folderPlaceholder}"]`)).toHaveValue(
+ 'draft-folder'
+ )
+ expect(document.querySelector(`input[placeholder="${adminEmailPlaceholder}"]`)).toHaveValue(
+ 'admin@example.com'
+ )
+ expect(button('Save')).toBeEnabled()
+ await click(button('Save'))
+ expect(mocks.update).toHaveBeenCalledOnce()
+ expect(mocks.update.mock.calls[0][0]).toMatchObject({
+ updates: {
+ sourceConfig: {
+ adminEmail: 'admin@example.com',
+ folderId: ['draft-folder'],
+ _canonicalModes: { folderId: 'advanced' },
+ },
+ },
+ })
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+
+ await render(
+
+ )
+ await click(button('Service account'))
+ await chooseCombo('Select the account to sync as', driveCredential.name)
+ expect(button('Apply connection method')).toBeEnabled()
+ await click(button('Apply connection method'))
+ expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith(
+ {
+ knowledgeBaseId: 'kb-search',
+ connectorId: existing.id,
+ access: { accessMode: 'admin', credentialId: driveCredential.id },
+ },
+ expect.any(Object)
+ )
+ expect(mocks.update).toHaveBeenCalledOnce()
+ })
+
+ it('does not offer Confluence administrator access while its member identity feature is unavailable', async () => {
+ mocks.features.knowledgeMemberAccess = false
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Member accounts')
+ expect(
+ Array.from(document.querySelectorAll('button')).some((node) =>
+ ['Admin or service account', 'Apply connection method'].includes(node.textContent ?? '')
+ )
+ ).toBe(false)
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+ })
+
+ it('blocks an already selected Confluence administrator transition when identity access becomes unavailable', async () => {
+ mocks.credentials = [
+ { id: 'confluence-account', name: 'Confluence indexing account', provider: 'confluence' },
+ ]
+ const existing = connector({
+ connectorType: 'confluence',
+ sourceConfig: { domain: 'team.atlassian.net', spaceKey: 'ENG' },
+ })
+ const modal = (
+
+ )
+ await render(modal)
+ await click(button('Admin or service account'))
+ await chooseCombo('Select the account to sync as', 'Confluence indexing account')
+ expect(button('Apply connection method')).toBeEnabled()
+ mocks.features.knowledgeMemberAccess = false
+ await render(cloneElement(modal))
+ expect(button('Apply connection method')).toBeDisabled()
+ await click(button('Apply connection method'))
+ expect(mocks.applyAccess).not.toHaveBeenCalled()
+ })
+
+ it.each(['creating', 'saving', 'switching access'] as const)(
+ 'disables generic source inputs, dropdowns, selectors, and mode toggles while %s',
+ async (phase) => {
+ mocks.createPending = phase === 'creating'
+ mocks.updatePending = phase === 'saving'
+ mocks.accessPending = phase === 'switching access'
+ await render(
+ phase === 'creating' ? (
+
+ ) : (
+
+ )
+ )
+ expect(document.querySelector(`input[placeholder="${adminEmailPlaceholder}"]`)).toBeDisabled()
+ expect(button('Switch Folders to manual input')).toBeDisabled()
+ const dropdown = Array.from(document.querySelectorAll('[role="combobox"]')).find((node) =>
+ node.textContent?.includes('Select file type')
+ )
+ expect(dropdown).toHaveAttribute('aria-disabled', 'true')
+ const folders = Array.from(document.querySelectorAll('[role="combobox"]')).find((node) =>
+ node.textContent?.includes('Select one or more folders (optional)')
+ )
+ expect(folders).toHaveAttribute('aria-disabled', 'true')
+ }
+ )
+})
+
+describe('canonical Search connector safety', () => {
+ it('offers only reviewed source types, including when a deep link names an unsupported provider', async () => {
+ await render(
+ {}}
+ knowledgeBaseId='kb-search'
+ isSearchIndex
+ initialConnectorType='airtable'
+ />
+ )
+ const sourceButtons = Array.from(document.querySelectorAll('button')).filter((node) =>
+ [
+ 'Confluence',
+ 'GitHub',
+ 'GitLab',
+ 'Gmail',
+ 'Google Calendar',
+ 'Google Drive',
+ 'Jira',
+ 'Slack',
+ 'Airtable',
+ 'Google Chat',
+ ].some((name) => node.getAttribute('aria-label') === name)
+ )
+ expect(sourceButtons.map((node) => node.getAttribute('aria-label'))).toHaveLength(8)
+ expect(sourceButtons.some((node) => node.getAttribute('aria-label') === 'Airtable')).toBe(false)
+ expect(sourceButtons.some((node) => node.getAttribute('aria-label') === 'Google Chat')).toBe(
+ false
+ )
+ })
+
+ it('defaults an OAuth source to member accounts and never offers workspace-wide access', async () => {
+ await render(
+ {}}
+ knowledgeBaseId='kb-search'
+ isSearchIndex
+ initialConnectorType='google_drive'
+ />
+ )
+ expect(button('Member accounts')).toHaveAttribute('aria-checked', 'true')
+ expect(
+ Array.from(document.querySelectorAll('button')).some(
+ (node) => node.textContent === 'Workspace'
+ )
+ ).toBe(false)
+ expect(document.body.textContent).not.toContain('Everyone in this workspace')
+ await click(button('Choose another source'))
+ const gitlab = Array.from(document.querySelectorAll('button')).find(
+ (node) => node.getAttribute('aria-label') === 'GitLab'
+ )
+ expect(gitlab).toBeDefined()
+ await click(gitlab!)
+ expect(document.body.textContent).toContain('Admin or service account')
+ expect(document.querySelector('[role="radio"][aria-checked="true"]')).toBeNull()
+ expect(
+ Array.from(document.querySelectorAll('button')).some(
+ (node) => node.textContent === 'Workspace'
+ )
+ ).toBe(false)
+ await fill('Enter your GitLab PAT', 'fixture-pat')
+ await fill('gitlab.com', 'gitlab.example.test')
+ await fill('group/project or numeric ID', 'engineering/search')
+ await click(button('Connect & Sync'))
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({ accessMode: 'admin', connectorType: 'gitlab' }),
+ expect.any(Object)
+ )
+ })
+
+ it('keeps an existing Search member source out of workspace-wide mode', async () => {
+ await render(
+ {}}
+ knowledgeBaseId='kb-search'
+ isSearchIndex
+ connector={connector()}
+ />
+ )
+ expect(document.body.textContent).toContain('Member accounts')
+ expect(
+ Array.from(document.querySelectorAll('[role="radio"]')).filter((node) =>
+ ['Workspace', 'Member accounts', 'Admin or service account'].includes(
+ node.textContent ?? ''
+ )
+ )
+ ).toHaveLength(0)
+ expect(
+ Array.from(document.querySelectorAll('button')).some(
+ (node) => node.textContent === 'Workspace'
+ )
+ ).toBe(false)
+ expect(document.body.textContent).not.toContain('Everyone in this workspace')
+ })
+})
+
+describe('resuming Search source setup', () => {
+ const key = 'user-1:workspace-1:kb-search:slack'
+
+ it('reopens the source from the URL even when the source filter hides its row', async () => {
+ await render(setup(), '?search=nothing-matches&addConnector=gitlab&credentialDraftId=draft-1')
+ expect(document.body.textContent).toContain('Admin or service account')
+ expect(document.querySelector('[role="radio"][aria-checked="true"]')).toBeNull()
+ expect(document.body.textContent).toContain('Configure GitLab')
+ expect(document.body.textContent).not.toContain('Sync Frequency')
+ expect(document.body.textContent).not.toContain('Sync automatically')
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ })
+
+ it('keeps the picker open when changing sources and updates the configuration selection', async () => {
+ await render(setup(), '?addConnector=google_drive')
+ await click(button('Choose another source'))
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull()
+ expect(document.body.textContent).toContain('Add source')
+ await fill('Find a source…', 'confluence')
+ await click(button('Set up'))
+ expect(document.body.textContent).toContain('Configure Confluence')
+ })
+
+ it('restores the source configuration and content account after an account-settings detour', async () => {
+ const onCreated = vi.fn()
+ const form = (
+
+ )
+ await render(form)
+ await chooseCombo('Connected members', 'Indexing account')
+ await fill('e.g. hr, legal, C01ABC23DEF', 'legal')
+ mocks.credentialGroup = null
+ await render(cloneElement(form))
+ const setup = Array.from(document.querySelectorAll('a')).find(
+ (link) => link.textContent === 'Set up Slack'
+ )
+ expect(setup?.getAttribute('href')).toContain('search-setup=slack')
+ setup?.addEventListener('click', (event) => event.preventDefault())
+ await click(setup!)
+ expect(useConnectorSetupStore.getState().getDraft(key)).toMatchObject({
+ sourceConfig: { excludeChannels: 'legal' },
+ contentCredentialId: 'cred-source',
+ accessMode: 'members',
+ })
+ await act(async () => root?.unmount())
+ root = null
+ container?.remove()
+ await useConnectorSetupStore.persist.rehydrate()
+ mocks.credentialGroup = {
+ id: 'group-1',
+ name: 'Workspace accounts',
+ status: 'active',
+ options: [
+ {
+ id: 'option-1',
+ label: 'Slack',
+ provider: 'slack',
+ status: 'active',
+ configurationStatus: 'ready',
+ },
+ ],
+ }
+ await render(form)
+ expect(
+ document.querySelector('input[placeholder="e.g. hr, legal, C01ABC23DEF"]')
+ ?.value
+ ).toBe('legal')
+ expect(document.body.textContent).not.toContain('Connected members')
+ await click(button('Create & Invite'))
+ expect(mocks.create.mock.calls[0][0]).toMatchObject({
+ syncIntervalMinutes: 60,
+ credentialId: 'cred-source',
+ sourceConfig: { excludeChannels: 'legal' },
+ })
+ await act(async () => mocks.create.mock.calls[0][1].onSuccess())
+ expect(onCreated).toHaveBeenCalledWith('slack')
+ expect(useConnectorSetupStore.getState().getDraft(key)).toBeUndefined()
+ })
+
+ it('selects the verified OAuth account instead of the previously selected one', async () => {
+ mocks.credentials = [
+ { id: 'cred-source', name: 'Old account', provider: 'google_drive' },
+ { id: 'cred-new', name: 'New account', provider: 'google_drive' },
+ ]
+ await render(
+
+ )
+ await act(async () => mocks.oauthReturn.mock.calls.at(-1)?.[1]('cred-new'))
+ const account = document.querySelector('[role="combobox"]')
+ expect(account?.textContent).toContain('New account')
+ })
+
+ it('keeps the general KB schedule and both document-detail sections collapsed by default', async () => {
+ await render(
+
+ )
+ expect(document.body.textContent).toContain('Sync Frequency')
+ expect(button('Live')).toBeDefined()
+ expect(button('Document details (optional)')).toHaveAttribute('aria-expanded', 'false')
+ await click(button('Document details (optional)'))
+ expect(document.body.textContent).toContain('Metadata tags')
+ await render(
+
+ )
+ expect(document.body.textContent).not.toContain('Sync Frequency')
+ expect(button('Document details (optional)')).toHaveAttribute('aria-expanded', 'false')
+ })
+})
+
+describe('Search setup guides', () => {
+ it('opens the source guide in a new tab without losing an administrator’s setup', async () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ await render(setup(), '?addConnector=github')
+ await fill('owner/repo', 'acme/docs')
+
+ await click(button('Setup guide'))
+
+ expect(open).toHaveBeenCalledWith(
+ 'https://docs.sim.ai/search/github',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe(
+ 'acme/docs'
+ )
+ expect(mocks.create).not.toHaveBeenCalled()
+ expect(mocks.urlUpdate).not.toHaveBeenCalled()
+ await click(button('Create & Invite'))
+ expect(mocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ connectorType: 'github',
+ sourceConfig: expect.objectContaining({ repository: 'acme/docs' }),
+ }),
+ expect.any(Object)
+ )
+ })
+
+ it('offers the Slack guide before its custom app is configured', async () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ mocks.credentialGroup = null
+ await render(setup(), '?addConnector=slack')
+
+ await click(button('Setup guide'))
+
+ expect(open).toHaveBeenCalledWith(
+ 'https://docs.sim.ai/search/slack',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ expect(document.body.textContent).not.toContain('Create & Invite')
+ expect(mocks.create).not.toHaveBeenCalled()
+ })
+
+ it('preserves a member’s required source fields while reading the guide', async () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ const onClose = vi.fn()
+ const onConnect = vi.fn()
+ const github = SEARCH_CONNECTORS.find((item) => item.type === 'github')!
+ await render( )
+ await fill('owner/repo', 'acme/docs')
+
+ await click(button('Setup guide'))
+
+ expect(open).toHaveBeenCalledWith(
+ 'https://docs.sim.ai/search/github',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ expect(onClose).not.toHaveBeenCalled()
+ expect(onConnect).not.toHaveBeenCalled()
+ await click(button('Connect'))
+ expect(onConnect).toHaveBeenCalledWith({ repository: 'acme/docs' })
+ expect(onClose).not.toHaveBeenCalled()
+ expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe(
+ 'acme/docs'
+ )
+ })
+
+ it('keeps unsaved source edits when opening the guide', async () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ const onOpenChange = vi.fn()
+ await render(
+
+ )
+ await fill('owner/repo', 'acme/handbook')
+
+ await click(button('Setup guide'))
+
+ expect(open).toHaveBeenCalledWith(
+ 'https://docs.sim.ai/search/github',
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ expect(onOpenChange).not.toHaveBeenCalled()
+ expect(mocks.update).not.toHaveBeenCalled()
+ await click(button('Save'))
+ expect(mocks.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ updates: expect.objectContaining({
+ sourceConfig: expect.objectContaining({ repository: 'acme/handbook' }),
+ }),
+ }),
+ expect.any(Object)
+ )
+ })
+
+ it.each(['add', 'edit'])('does not show Search guides in general KB %s dialogs', async (mode) => {
+ await render(
+ mode === 'add' ? (
+
+ ) : (
+
+ )
+ )
+
+ expect(document.body.textContent).not.toContain('Setup guide')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx
new file mode 100644
index 00000000000..d8f4d4926f3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx
@@ -0,0 +1,311 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Chip,
+ ChipInput,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalHeader,
+} from '@sim/emcn'
+import { Search } from '@sim/emcn/icons'
+import dynamic from 'next/dynamic'
+import { useQueryState } from 'nuqs'
+import { useSession } from '@/lib/auth/auth-client'
+import {
+ type ResourceScope,
+ resourceScopeFields,
+ resourceScopeFromOwner,
+ resourceScopeKey,
+} from '@/lib/core/resource-scope'
+import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import {
+ managedSourceParam,
+ searchSetupParam,
+} from '@/app/workspace/[workspaceId]/search/search-params'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import {
+ RESOURCE_LIST_STACK,
+ SettingsResourceRow,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
+import {
+ useConnectorList,
+ usePrepareSearchSource,
+ useSearchIndex,
+} from '@/hooks/queries/kb/connectors'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+const AddConnectorModal = dynamic(
+ () =>
+ import('@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal').then(
+ (module) => module.AddConnectorModal
+ ),
+ { ssr: false }
+)
+const SearchSourceStatus = dynamic(
+ () =>
+ import('@/app/workspace/[workspaceId]/search/components/search-source-status').then(
+ (module) => module.SearchSourceStatus
+ ),
+ { ssr: false }
+)
+
+interface SearchSourceSetupProps {
+ workspaceId?: string
+ scope?: ResourceScope
+ canAdmin: boolean
+ memberAccessAvailable: boolean
+ mirroredAccessAvailable: boolean
+ membersOnly?: boolean
+}
+
+/** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */
+export function SearchSourceSetup({
+ workspaceId,
+ scope: explicitScope,
+ canAdmin,
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ membersOnly = false,
+}: SearchSourceSetupProps) {
+ const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
+ const { data: session } = useSession()
+ const {
+ integrationAvailability,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ isIntegrationAvailabilityFetching,
+ isIntegrationAvailabilityLoading,
+ integrationAvailabilityError,
+ refetchIntegrationAvailability,
+ } = usePermissionConfig()
+ const [selectedType, setSelectedType] = useQueryState(
+ searchSetupParam.key,
+ searchSetupParam.parser.withOptions({ history: 'replace' })
+ )
+ const [managedSource, setManagedSource] = useQueryState(
+ managedSourceParam.key,
+ managedSourceParam.parser.withOptions({ history: 'replace' })
+ )
+ const [search, setSearch] = useState('')
+ const prepare = usePrepareSearchSource()
+ const open = selectedType !== null || managedSource !== null
+ const index = useSearchIndex(scope, { enabled: canAdmin && open })
+ const knowledgeBaseId = index.data?.knowledgeBaseId ?? undefined
+ const connectors = useConnectorList(canAdmin && managedSource ? knowledgeBaseId : undefined)
+
+ if (!canAdmin || !open) return null
+
+ const close = () => {
+ if (prepare.isPending) return
+ if (selectedType !== null) void setSelectedType(null)
+ if (managedSource !== null) void setManagedSource(null)
+ }
+ const failedQuery = index.isError
+ ? index
+ : managedSource && connectors.isError
+ ? connectors
+ : null
+ const selectedMeta = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : undefined
+ const managedConnectors =
+ connectors.data?.filter(
+ (connector) => connector.id === managedSource || connector.connectorType === managedSource
+ ) ?? []
+ const managedType =
+ managedConnectors[0]?.connectorType ??
+ (managedSource && CONNECTOR_META_REGISTRY[managedSource] ? managedSource : undefined)
+ const initialMode = (type: string) => {
+ if (membersOnly) return 'members' as const
+ const meta = CONNECTOR_META_REGISTRY[type]
+ if (
+ meta &&
+ getConnectorAccessAvailability(meta, integrationAvailability, {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady:
+ isIntegrationAvailabilityReady || integrationAvailability.size > 0,
+ }).admin
+ )
+ return 'admin' as const
+ return 'members' as const
+ }
+
+ if (
+ !failedQuery &&
+ knowledgeBaseId &&
+ (isIntegrationAvailabilityReady || integrationAvailability.size > 0)
+ ) {
+ if (selectedType && session?.user?.id) {
+ return (
+ {
+ if (!nextOpen) void setSelectedType(null)
+ }}
+ knowledgeBaseId={knowledgeBaseId}
+ scope={scope}
+ isSearchIndex
+ initialConnectorType={selectedType}
+ initialAccessMode={initialMode(selectedType)}
+ membersOnly={membersOnly}
+ setupDraftKey={`${session.user.id}:${resourceScopeKey(scope)}:${knowledgeBaseId}:${selectedType}`}
+ onConnectorTypeChange={(type) =>
+ void setSelectedType(type !== null ? searchSetupParam.parser.parse(type) : null)
+ }
+ />
+ )
+ }
+ if (managedSource && (connectors.isPending || managedType)) {
+ return (
+ void setManagedSource(null)}
+ />
+ )
+ }
+ }
+
+ const normalizedSearch = search.trim().toLowerCase()
+ const visibleTypes = SEARCH_SOURCE_TYPES.filter(([type, meta]) =>
+ selectedType
+ ? type === selectedType
+ : `${meta.name} ${meta.description}`.toLowerCase().includes(normalizedSearch)
+ )
+
+ return (
+ {
+ if (!nextOpen) close()
+ }}
+ srTitle='Add source'
+ >
+
+ {selectedMeta ? `Configure ${selectedMeta.name}` : 'Add source'}
+
+
+ {failedQuery ? (
+
+ void failedQuery.refetch()}
+ variant='inline'
+ />
+
+ ) : integrationAvailabilityError ? (
+
+ void refetchIntegrationAvailability()}
+ variant='inline'
+ />
+
+ ) : isIntegrationAvailabilityLoading ? (
+
+ Loading sources…
+
+ ) : managedSource ? (
+
+
+ {index.isPending ? 'Loading source…' : 'This source is no longer available.'}
+
+
+ ) : (
+ <>
+ {!selectedType && (
+
+ setSearch(event.target.value)}
+ />
+
+ )}
+
+
+ {visibleTypes.map(([type, meta]) => {
+ const { admin: central, members } = getConnectorAccessAvailability(
+ meta,
+ integrationAvailability,
+ {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ }
+ )
+ const available = membersOnly ? members : central || members
+ return (
+ }
+ title={meta.name}
+ description={
+ !available
+ ? `Not available in this ${scope.kind}`
+ : central
+ ? meta.adminSetupHint
+ : undefined
+ }
+ disabled={!available}
+ trailing={
+ available ? (
+ {
+ if (knowledgeBaseId)
+ void setSelectedType(searchSetupParam.parser.parse(type))
+ else
+ prepare.mutate(
+ {
+ ...resourceScopeFields(scope),
+ connectorType: type,
+ accessMode: membersOnly || !central ? 'members' : 'admin',
+ },
+ {
+ onSuccess: () =>
+ void setSelectedType(searchSetupParam.parser.parse(type)),
+ }
+ )
+ }}
+ >
+ {selectedType ? 'Continue setup' : 'Set up'}
+
+ ) : undefined
+ }
+ />
+ )
+ })}
+ {visibleTypes.length === 0 && (
+ No matching sources.
+ )}
+
+
+ {prepare.error?.message}
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx
new file mode 100644
index 00000000000..778f7227fd0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+} from '@sim/emcn'
+import { useRouter } from 'next/navigation'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { ConnectorsSection } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section'
+import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
+import type { ConnectorData } from '@/hooks/queries/kb/connectors'
+
+interface SearchSourceStatusProps {
+ scope: ResourceScope
+ knowledgeBaseId: string
+ connectorType: string
+ connectors: ConnectorData[]
+ isLoading: boolean
+ onClose: () => void
+}
+
+/** Search reuses the connector's sync status, history, and recovery controls. */
+export function SearchSourceStatus({
+ scope,
+ knowledgeBaseId,
+ connectorType,
+ connectors,
+ isLoading,
+ onClose,
+}: SearchSourceStatusProps) {
+ const router = useRouter()
+ const title = `${CONNECTOR_META_REGISTRY[connectorType]?.name ?? 'Source'} sources`
+ return (
+ {
+ if (!open) onClose()
+ }}
+ srTitle={title}
+ size='lg'
+ >
+ {title}
+
+
+
+
+
+
+ router.push(
+ scope.kind === 'organization'
+ ? organizationRoutes(scope.organizationId).search
+ : `/workspace/${scope.workspaceId}/home?mode=search`
+ ),
+ }}
+ />
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/search-params.ts b/apps/sim/app/workspace/[workspaceId]/search/search-params.ts
index c55f7709913..0312ee7c95c 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/search/search-params.ts
@@ -1,4 +1,35 @@
-import { parseAsString } from 'nuqs/server'
+import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
+
+const SEARCH_SETUP_SOURCES = [
+ 'confluence',
+ 'github',
+ 'gitlab',
+ 'gmail',
+ 'google_calendar',
+ 'google_drive',
+ 'jira',
+ 'slack',
+] as const
+
+/** Null closes setup; an empty value opens the picker, and a source type resumes its form. */
+export const searchSetupParam = {
+ key: 'addConnector',
+ parser: parseAsStringLiteral(['', ...SEARCH_SETUP_SOURCES]),
+} as const
+
+/** Null closes the source management panel. */
+export const managedSourceParam = {
+ key: 'manage-source',
+ parser: parseAsString,
+} as const
+
+/** A setup detour carries intent, never an arbitrary redirect URL. */
+export const searchSetupReturnParam = {
+ key: 'search-setup',
+ parser: parseAsStringLiteral([...SEARCH_SETUP_SOURCES, 'search']),
+} as const
+
+export type SearchSetupSource = NonNullable>
/**
* `search` filters the Sim Search connector list by name and description. The
@@ -15,3 +46,7 @@ export const connectorSearchUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
+
+export type SearchSetupReturnSource = NonNullable<
+ ReturnType
+>
diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx
index 57cc6d8abf7..6a4107e710a 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx
@@ -1,225 +1,395 @@
-/**
- * @vitest-environment jsdom
- */
+/** @vitest-environment jsdom */
import { act } from 'react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ SearchSourceSummary,
+ WorkspaceMemberConnector,
+} from '@/lib/api/contracts/knowledge/connectors'
-const { mockConnect, mockConnectSource, mockFeatures } = vi.hoisted(() => ({
- mockConnect: vi.fn(),
- mockConnectSource: vi.fn(),
- mockFeatures: vi.fn(),
+const mocks = vi.hoisted(() => ({
+ canAdmin: false,
+ features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true },
+ sources: [] as SearchSourceSummary[],
+ shared: [] as WorkspaceMemberConnector[],
+ sourcePending: false,
+ sourceError: null as Error | null,
+ sharedError: null as Error | null,
+ sourceRefetch: vi.fn(),
+ sharedRefetch: vi.fn(),
+ sourceQuery: vi.fn(),
+ sharedQuery: vi.fn(),
+ connect: vi.fn(),
+ setup: vi.fn(),
+ sharedRows: vi.fn(),
+ urlUpdate: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
- useOptionalWorkspaceHostContext: () => ({ features: mockFeatures() }),
+ useWorkspaceHostContext: () => ({ features: mocks.features }),
}))
-vi.mock('nuqs', () => ({
- useQueryState: () => ['', vi.fn()],
-}))
-vi.mock('@/hooks/use-debounced-search-setter', () => ({
- useDebouncedSearchSetter: (write: (value: string) => void) => write,
+vi.mock('@/hooks/use-member-access', () => ({
+ useMemberAccessAvailable: () => mocks.features.knowledgeMemberAccess,
}))
vi.mock('@/hooks/queries/workspace', () => ({
- useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: true } } }),
+ useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.canAdmin } } }),
}))
-vi.mock('@/hooks/use-permission-config', () => ({
- usePermissionConfig: () => ({
- integrationAvailability: new Map([
- ['slack', { state: 'limited', oauthAvailable: false }],
- ['jira', { state: 'available', oauthAvailable: true }],
- ]),
+vi.mock('@/hooks/queries/kb/connectors', () => ({
+ searchSourceKeys: { list: (id: string) => ['search-sources', id] },
+ useSearchSources: (id: string) => {
+ mocks.sourceQuery(id)
+ return {
+ data: mocks.sources,
+ isPending: mocks.sourcePending,
+ isError: Boolean(mocks.sourceError),
+ error: mocks.sourceError,
+ isFetching: false,
+ refetch: mocks.sourceRefetch,
+ }
+ },
+ useWorkspaceMemberConnectors: (id: string, options: { enabled: boolean }) => {
+ mocks.sharedQuery(id, options)
+ return {
+ data: mocks.shared,
+ isError: Boolean(mocks.sharedError),
+ error: mocks.sharedError,
+ isFetching: false,
+ refetch: mocks.sharedRefetch,
+ }
+ },
+}))
+vi.mock('@/hooks/use-member-enrollment', () => ({
+ CONNECTABLE_MEMBERSHIPS: new Set(['needs_reauth', 'invited', 'not_enrolled']),
+ useMemberEnrollment: () => ({
+ connect: mocks.connect,
+ isAwaiting: () => false,
+ isPending: false,
+ error: null,
}),
}))
+vi.mock('@/hooks/use-debounced-search-setter', () => ({
+ useDebouncedSearchSetter: (write: (value: string) => void) => write,
+}))
vi.mock('@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration', () => ({
useScrollRestoration: () => undefined,
}))
-vi.mock('@/app/workspace/[workspaceId]/components', () => ({
- IntegrationTabsHeader: () => null,
+vi.mock('@/app/workspace/[workspaceId]/components', () => ({ IntegrationTabsHeader: () => null }))
+vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({
+ IntegrationTile: () => null,
}))
-vi.mock('@/blocks', () => ({ getBlock: () => undefined }))
-vi.mock('@/lib/integrations', () => ({
- blockTypeToIconMap: {},
- resolveCredentialDisplay: () => ({ icon: () => null, blockType: 'confluence', subtitle: 'Sub' }),
+vi.mock('@/app/workspace/[workspaceId]/search/components/search-mcp-setup', () => ({
+ SearchMcpSetup: () => MCP setup
,
}))
-
-vi.mock('@/lib/sim-search/connectors', () => {
- const icon = () => null
- const connector = (type: string, name: string, description: string, personal: boolean) => ({
- type,
- meta: {
- id: type,
- name,
- description,
- icon,
- auth: { mode: 'oauth', provider: type },
- permissionScopedListing: personal ? { capFieldIds: [] } : undefined,
- configFields: personal ? [] : [{ id: 'domain', required: true }],
+vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({
+ SearchSourceSetup: (props: { canAdmin: boolean }) => {
+ mocks.setup(props)
+ return
+ },
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section',
+ () => ({
+ MemberConnectorsSection: (props: { connectors: WorkspaceMemberConnector[] }) => {
+ mocks.sharedRows(props.connectors)
+ return props.connectors.length ? Shared with you
: null
},
- providerId: type,
- providerIds: [type],
- requiredScopes: [],
- serviceName: name,
- serviceIcon: icon,
- blockType: type,
- setupFields: [],
})
- const isSearchConnectorAvailable = (
- candidate: { blockType: string },
- availability: ReadonlyMap
- ) => availability.get(candidate.blockType)?.oauthAvailable ?? true
- return {
- SIM_SEARCH_KNOWLEDGE_BASE_NAME: 'Sim Search',
- canConnectPersonally: (meta: { permissionScopedListing?: unknown }) =>
- Boolean(meta.permissionScopedListing),
- connectorDisplayName: (connectorType: string) => connectorType,
- isSearchConnectorAvailable,
- searchConnectorUnavailableReason: (
- candidate: { blockType: string; meta: { name: string } },
- availability: ReadonlyMap,
- context: { memberAccessAvailable: boolean; hasConnection: boolean; canCreate: boolean }
- ) =>
- !isSearchConnectorAvailable(candidate, availability)
- ? `${candidate.meta.name} is unavailable in this deployment`
- : !context.memberAccessAvailable
- ? 'Per-member access is not available in this workspace'
- : !context.hasConnection && !context.canCreate
- ? `Ask a workspace admin to connect ${candidate.meta.name} first`
- : null,
- SEARCH_CONNECTORS: [
- connector('google_drive', 'Google Drive', 'Sync Drive files', true),
- connector('confluence', 'Confluence', 'Sync Confluence pages', false),
- connector('slack', 'Slack', 'Sync Slack messages', true),
- ],
- }
-})
-
-vi.mock('@/hooks/queries/kb/connectors', () => ({
- memberConnectorKeys: { list: (workspaceId?: string) => ['member-connectors', workspaceId] },
- useWorkspaceMemberConnectors: () => ({
- isPending: false,
- data: [
- {
- knowledgeBaseId: 'kb-search',
- knowledgeBaseName: 'Sim Search',
- connectorId: 'conn-drive',
- connectorType: 'google_drive',
- memberSyncStatus: 'idle',
- viewerMembership: 'connected',
- viewerDocumentCount: 12,
- },
- {
- knowledgeBaseId: 'kb-sales',
- knowledgeBaseName: 'Sales',
- connectorId: 'conn-sales-drive',
- connectorType: 'google_drive',
- memberSyncStatus: 'idle',
- viewerMembership: 'invited',
- viewerDocumentCount: 0,
- },
- ],
- }),
-}))
-vi.mock('@/hooks/use-member-enrollment', async () => {
- const actual = await vi.importActual(
- '@/hooks/use-member-enrollment'
- )
- return {
- CONNECTABLE_MEMBERSHIPS: actual.CONNECTABLE_MEMBERSHIPS,
- describeMembership: actual.describeMembership,
- enrollmentActionLabel: actual.enrollmentActionLabel,
- useMemberEnrollment: () => ({
- connect: mockConnect,
- connectSource: mockConnectSource,
- connectSearchSource: (
- workspaceId: string,
- connector: { type: string },
- connection: { knowledgeBaseId: string; connectorId: string } | undefined
- ) =>
- connection
- ? mockConnect(connection.knowledgeBaseId, connection.connectorId)
- : mockConnectSource(workspaceId, connector.type),
- setupConnector: null,
- closeSetup: () => {},
- isAwaiting: () => false,
- isAwaitingSource: () => false,
- isPending: false,
- error: null,
- }),
- }
-})
-vi.mock('@/connectors/registry', () => ({
- CONNECTOR_META_REGISTRY: { google_drive: { name: 'Google Drive', icon: () => null } },
-}))
+)
import { Search } from '@/app/workspace/[workspaceId]/search/search'
-let root: Root | null = null
-let container: HTMLDivElement | null = null
-
-function mount(features: { knowledgeMemberAccess?: boolean } = { knowledgeMemberAccess: true }) {
- mockFeatures.mockReturnValue({ credentialGroups: true, ...features })
- ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
- container = document.createElement('div')
- document.body.appendChild(container)
- root = createRoot(container)
- act(() => root?.render( ))
+function source(overrides: Partial = {}): SearchSourceSummary {
+ return {
+ knowledgeBaseId: 'kb-search',
+ connectorId: 'drive-1',
+ connectorType: 'google_drive',
+ sourceDescription: 'Company files',
+ accessMode: 'admin',
+ availability: 'available',
+ enabled: true,
+ isSyncing: false,
+ lastSyncAt: '2026-09-05T12:00:00Z',
+ hasSyncError: false,
+ viewerDocumentCount: 12,
+ viewerEmailVerified: true,
+ connectionRequired: false,
+ viewerMembership: null,
+ ...overrides,
+ } as SearchSourceSummary
}
-function sectionLabels(): string[] {
- return Array.from(container?.querySelectorAll('section > div > span') ?? []).map(
- (node) => node.textContent ?? ''
+function button(label: string) {
+ return Array.from(document.querySelectorAll('button')).find(
+ (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label
)
}
-function buttons(): HTMLButtonElement[] {
- return Array.from(container?.querySelectorAll('button') ?? [])
+let root: Root
+let container: HTMLDivElement
+async function render(searchParams = '') {
+ await act(async () =>
+ root.render(
+
+
+
+ )
+ )
}
-afterEach(() => {
- if (root) act(() => root?.unmount())
- container?.remove()
- root = null
- container = null
- mockConnect.mockReset()
- mockConnectSource.mockReset()
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ mocks.canAdmin = false
+ mocks.features = { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true }
+ mocks.sources = [source(), source({ connectorId: 'gitlab-1', connectorType: 'gitlab' })]
+ mocks.shared = []
+ mocks.sourcePending = false
+ mocks.sourceError = null
+ mocks.sharedError = null
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
})
-describe('Search', () => {
- it('shows each source with the viewer’s own connection state', () => {
- mount()
+describe('unified Search sources', () => {
+ it('shows configured central Drive and GitLab sources to readers without setup controls', async () => {
+ await render()
+ expect(document.body.textContent).toContain('Google Drive')
+ expect(document.body.textContent).toContain('GitLab')
+ expect(document.body.textContent).not.toContain('Slack')
+ expect(document.body.textContent).not.toContain('Confluence')
+ expect(button('Add source')).toBeUndefined()
+ expect(button('Manage')).toBeUndefined()
+ expect(button('Connect account')).toBeUndefined()
+ expect(mocks.sourceQuery).toHaveBeenCalledWith('workspace-1')
+ expect(mocks.setup).toHaveBeenLastCalledWith(expect.objectContaining({ canAdmin: false }))
+ })
- expect(sectionLabels()).toEqual(['Sim Search Connectors', 'Shared with you'])
- const text = container?.textContent ?? ''
- expect(text).toContain('Connected · 12 documents')
- expect(text).toContain('Set up by a workspace admin from a knowledge base.')
- expect(text).toContain('Slack is unavailable in this deployment')
- expect(text).toContain('Sales')
+ it('connects each configured Confluence site using its exact connector ID', async () => {
+ mocks.sources = ['engineering', 'sales'].map((site) =>
+ source({
+ connectorId: `confluence-${site}`,
+ connectorType: 'confluence',
+ sourceDescription: `${site}.atlassian.net`,
+ accessMode: 'members',
+ connectionRequired: true,
+ viewerMembership: 'invited',
+ })
+ )
+ await render()
+ const buttons = Array.from(document.querySelectorAll('button')).filter(
+ (node) => node.textContent === 'Connect account'
+ )
+ expect(buttons).toHaveLength(2)
+ expect(document.body.textContent).toContain('engineering.atlassian.net')
+ expect(document.body.textContent).toContain('sales.atlassian.net')
+ await act(async () => buttons[1]!.click())
+ expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('kb-search', 'confluence-sales')
})
- it('connects a source nobody has connected yet through its per-member connector', () => {
- mount()
+ it('offers only the member’s required account actions across mixed source methods', async () => {
+ mocks.sources.push(
+ source({
+ connectorId: 'confluence-central',
+ connectorType: 'confluence',
+ connectionRequired: true,
+ viewerMembership: 'not_enrolled',
+ }),
+ source({
+ connectorId: 'drive-members',
+ accessMode: 'members',
+ connectionRequired: true,
+ viewerMembership: 'connected',
+ }),
+ source({
+ connectorId: 'slack-members',
+ connectorType: 'slack',
+ accessMode: 'members',
+ connectionRequired: true,
+ viewerMembership: 'needs_reauth',
+ })
+ )
+ await render()
+ const actions = Array.from(document.querySelectorAll('button')).map((node) =>
+ node.textContent?.trim()
+ )
+ expect(actions).toEqual(['Connect account', 'Reconnect'])
+ await act(async () => button('Connect account')!.click())
+ await act(async () => button('Reconnect')!.click())
+ expect(mocks.connect.mock.calls).toEqual([
+ ['kb-search', 'confluence-central'],
+ ['kb-search', 'slack-members'],
+ ])
+ expect(mocks.urlUpdate).not.toHaveBeenCalled()
+ })
- const connect = buttons().find((button) => button.textContent === 'Connect')
- expect(connect).toBeDefined()
- act(() => {
- connect?.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
- })
+ it('keeps central email-based sources usable when managed identities become unavailable', async () => {
+ mocks.features.knowledgeMemberAccess = false
+ mocks.sources.push(
+ source({
+ connectorId: 'confluence-central',
+ connectorType: 'confluence',
+ connectionRequired: true,
+ viewerMembership: 'invited',
+ }),
+ source({
+ connectorId: 'slack-members',
+ connectorType: 'slack',
+ accessMode: 'members',
+ connectionRequired: true,
+ viewerMembership: 'needs_reauth',
+ })
+ )
+ await render()
+ expect(document.body.textContent?.match(/12 searchable documents/g)).toHaveLength(2)
+ expect(document.body.textContent?.match(/Not available in this workspace/g)).toHaveLength(2)
+ expect(button('Connect account')).toBeUndefined()
+ expect(button('Reconnect')).toBeUndefined()
+ expect(mocks.sharedQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
+ })
- expect(mockConnect).toHaveBeenCalledWith('kb-sales', 'conn-sales-drive')
- expect(mockConnectSource).not.toHaveBeenCalled()
+ it('allows admins to add member sources when mirrored access is off', async () => {
+ mocks.canAdmin = true
+ mocks.features.knowledgeSourceMirroredAccess = false
+ await render('?search=slack')
+ expect(button('Add source')).toBeDefined()
+ await act(async () => button('Add source')!.click())
+ await vi.waitFor(() =>
+ expect(mocks.urlUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ queryString: '?addConnector=' })
+ )
+ )
+ expect(mocks.setup).toHaveBeenLastCalledWith(
+ expect.objectContaining({ memberAccessAvailable: true, mirroredAccessAvailable: false })
+ )
})
- it('offers no connection while per-member access is unavailable in the workspace', () => {
- mount({ knowledgeMemberAccess: false })
+ it('keeps general-KB enrollments under Shared with you and excludes index duplicates', async () => {
+ const shared: WorkspaceMemberConnector = {
+ knowledgeBaseId: 'kb-sales',
+ knowledgeBaseName: 'Sales',
+ knowledgeBaseIsSearchIndex: false,
+ connectorId: 'sales-drive',
+ connectorType: 'google_drive',
+ sourceDescription: 'Sales folder',
+ memberSyncStatus: 'idle',
+ viewerMembership: 'invited',
+ viewerDocumentCount: 0,
+ }
+ mocks.shared = [shared, { ...shared, connectorId: 'drive-1', knowledgeBaseIsSearchIndex: true }]
+ await render()
+ expect(mocks.sharedRows).toHaveBeenLastCalledWith([shared])
+ expect(document.body.textContent).toContain('Shared with you')
+ })
+
+ it('blocks cached member and identity actions after features turn off', async () => {
+ mocks.canAdmin = true
+ mocks.features = { knowledgeMemberAccess: false, knowledgeSourceMirroredAccess: false }
+ mocks.sources = [
+ source({ accessMode: 'members', connectionRequired: true, viewerMembership: 'invited' }),
+ source({
+ connectorId: 'confluence-1',
+ connectorType: 'confluence',
+ connectionRequired: true,
+ viewerMembership: 'needs_reauth',
+ }),
+ ]
+ await render()
+ expect(document.body.textContent).toContain('Not available in this workspace')
+ expect(button('Connect account')).toBeUndefined()
+ expect(button('Reconnect')).toBeUndefined()
+ expect(button('Add source')).toBeUndefined()
+ expect(mocks.sharedQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
+ expect(mocks.sharedRows).not.toHaveBeenCalled()
+ })
+
+ it.each([false, true])('provides a useful empty state for canAdmin=%s', async (canAdmin) => {
+ mocks.canAdmin = canAdmin
+ mocks.sources = []
+ await render()
+ expect(document.body.textContent).toContain(
+ canAdmin ? 'Add a source to start indexing' : 'Ask a workspace admin to get started'
+ )
+ })
+
+ it('shows loading and then a retryable source error without stale rows', async () => {
+ mocks.sourcePending = true
+ await render()
+ expect(document.body.textContent).toContain('Loading sources…')
+ expect(document.body.textContent).not.toContain('Google Drive')
+ mocks.sourcePending = false
+ mocks.sourceError = new Error('Could not fetch sources')
+ await render()
+ expect(document.body.textContent).toContain('Could not fetch sources')
+ await act(async () => button('Try again')!.click())
+ expect(mocks.sourceRefetch).toHaveBeenCalledOnce()
+ expect(document.body.textContent).not.toContain('Google Drive')
+ })
+
+ it('retries shared-source failures without hiding the configured sources', async () => {
+ mocks.sharedError = new Error('Shared sources failed')
+ await render()
+ expect(document.body.textContent).toContain('Google Drive')
+ expect(document.body.textContent).toContain('Shared sources failed')
+ await act(async () => button('Try again')!.click())
+ expect(mocks.sharedRefetch).toHaveBeenCalledOnce()
+ })
+
+ it('filters by provider and source address while retaining the setup owner for a return URL', async () => {
+ mocks.canAdmin = true
+ await render('?search=missing&addConnector=gitlab&credentialDraftId=draft-1')
+ expect(document.body.textContent).toContain('No matching sources.')
+ expect(document.body.textContent).not.toContain('Google Drive')
+ expect(document.querySelector('[data-testid="source-setup"]')).not.toBeNull()
+ expect(document.querySelector('[data-testid="mcp-setup"]')).toBeNull()
+ expect(mocks.urlUpdate).not.toHaveBeenCalled()
+ })
+
+ it('pushes source management without replacing the filtered list URL used by Back', async () => {
+ mocks.canAdmin = true
+ await render('?search=gitlab')
+ expect(document.body.textContent).toContain('GitLab')
+ expect(document.body.textContent).not.toContain('Google Drive')
+ await act(async () => button('Manage')!.click())
+ await vi.waitFor(() =>
+ expect(mocks.urlUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryString: '?search=gitlab&manage-source=gitlab-1',
+ options: expect.objectContaining({ history: 'push' }),
+ })
+ )
+ )
+ await render('?search=gitlab&manage-source=gitlab-1')
+ await render('?search=gitlab')
+ expect(document.querySelector('input')?.value).toBe('gitlab')
+ expect(document.body.textContent).toContain('GitLab')
+ expect(document.body.textContent).not.toContain('Google Drive')
+ })
- expect(sectionLabels()).toEqual(['Sim Search Connectors'])
- const text = container?.textContent ?? ''
- expect(text).toContain('Per-member access is not available in this workspace')
- expect(text).not.toContain('Connected · 12 documents')
- expect(buttons().find((button) => button.textContent === 'Connect')).toBeUndefined()
+ it('keeps search above MCP setup and opens admin management by connector ID', async () => {
+ mocks.canAdmin = true
+ await render()
+ const input = document.querySelector('input')!
+ const mcp = document.querySelector('[data-testid="mcp-setup"]')!
+ expect(input.compareDocumentPosition(mcp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ await act(async () => button('Manage')!.click())
+ await vi.waitFor(() =>
+ expect(mocks.urlUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryString: '?manage-source=drive-1',
+ options: expect.objectContaining({ history: 'push' }),
+ })
+ )
+ )
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.tsx
index ab9ed1419a0..b602a08921c 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/search.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/search/search.tsx
@@ -1,220 +1,95 @@
'use client'
import { useMemo, useRef } from 'react'
-import { Button, ChipInput } from '@sim/emcn'
-import { Search as SearchIcon } from '@sim/emcn/icons'
+import { Chip, ChipInput } from '@sim/emcn'
+import { Plus, Search as SearchIcon } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
-import {
- canConnectPersonally,
- connectorDisplayName,
- SEARCH_CONNECTORS,
- type SearchConnector,
- SIM_SEARCH_KNOWLEDGE_BASE_NAME,
- searchConnectorUnavailableReason,
-} from '@/lib/sim-search/connectors'
+import { connectorDisplayName } from '@/lib/sim-search/connectors'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components'
-import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { MemberConnectorsSection } from '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section'
+import { SearchMcpSetup } from '@/app/workspace/[workspaceId]/search/components/search-mcp-setup'
+import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
+import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
import {
connectorSearchParam,
connectorSearchUrlKeys,
+ managedSourceParam,
+ searchSetupParam,
} from '@/app/workspace/[workspaceId]/search/search-params'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import {
- memberConnectorKeys,
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import {
+ searchSourceKeys,
+ useSearchSources,
useWorkspaceMemberConnectors,
- type WorkspaceMemberConnector,
} from '@/hooks/queries/kb/connectors'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useMemberAccessAvailable } from '@/hooks/use-member-access'
-import {
- CONNECTABLE_MEMBERSHIPS,
- describeMembership,
- enrollmentActionLabel,
- useMemberEnrollment,
-} from '@/hooks/use-member-enrollment'
-import { usePermissionConfig } from '@/hooks/use-permission-config'
+import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
-const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
-const CONNECTORS_LABEL = 'Sim Search Connectors'
-const NEEDS_KNOWLEDGE_BASE_SETUP = 'Set up by a workspace admin from a knowledge base.'
-
-/** What a source row says once the viewer's own indexing has settled. */
-function connectedDescription(connector: WorkspaceMemberConnector): string {
- const count = connector.viewerDocumentCount
- return count === 1 ? 'Connected · 1 document' : `Connected · ${count} documents`
-}
-
-interface SourceRowProps {
- connector: SearchConnector
- /** The Sim Search per-member connector for this source, once anyone has connected it. */
- connection: WorkspaceMemberConnector | undefined
- /** Why the source cannot be connected here, shown in place of its state; null when it can. */
- unavailableReason: string | null
- waiting: boolean
- isPending: boolean
- onConnect: () => void
-}
-
-/**
- * One Sim Search source: what the viewer's connection is doing (indexing,
- * how many documents they can read, what to do next) and the one action open
- * to them. A source nobody has connected yet offers Connect, which creates its
- * connector and enrolls the viewer in one step.
- */
-function SourceRow({
- connector,
- connection,
- unavailableReason,
- waiting,
- isPending,
- onConnect,
-}: SourceRowProps) {
- const unavailable = unavailableReason !== null
- const personal = canConnectPersonally(connector.meta)
- const membership = connection?.viewerMembership
- const state = connection
- ? (describeMembership({
- membership: connection.viewerMembership,
- memberSyncStatus: connection.memberSyncStatus,
- waiting,
- name: connector.meta.name,
- }) ?? connectedDescription(connection))
- : waiting
- ? `Finish connecting your ${connector.meta.name} account in the other tab.`
- : connector.meta.description
- const description = unavailableReason ?? (personal ? state : NEEDS_KNOWLEDGE_BASE_SETUP)
- const connectable =
- !unavailable && !waiting && personal && (!membership || CONNECTABLE_MEMBERSHIPS.has(membership))
- return (
- }
- title={connector.meta.name}
- description={description}
- disabled={unavailable || !personal}
- trailing={
- connectable ? (
-
- {enrollmentActionLabel(membership ?? 'not_enrolled', waiting)}
-
- ) : undefined
- }
- />
- )
-}
-
-/**
- * The Sim Search catalog: every source a person can connect with one click,
- * each row showing where the viewer's own connection stands. Connecting opens
- * the enrollment for the workspace's Sim Search knowledge base, and indexing
- * starts on its own once the account is linked; documents count up here as
- * they land. Per-member connectors in other knowledge bases are listed below
- * under Shared with you, with the same actions.
- */
+/** One source list for everyone; setup and management remain admin actions. */
export function Search() {
const scrollContainerRef = useRef(null)
- const params = useParams()
- const workspaceId = (params?.workspaceId as string) || ''
- const { integrationAvailability } = usePermissionConfig()
- /**
- * With per-member access off, every connect is refused, so the rows say so
- * and the memberships are not fetched.
- */
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const { features } = useWorkspaceHostContext()
const memberAccessAvailable = useMemberAccessAvailable()
- const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId)
- /** The first connect of a source turns it on for the workspace, which takes an admin. */
- const canCreate = workspacePermissions?.viewer?.isAdmin ?? false
-
+ const mirroredAccessAvailable = features?.knowledgeSourceMirroredAccess === true
+ const { data: permissions } = useWorkspacePermissionsQuery(workspaceId)
+ const canAdmin = permissions?.viewer?.isAdmin ?? false
+ const sources = useSearchSources(workspaceId)
+ const shared = useWorkspaceMemberConnectors(workspaceId, { enabled: memberAccessAvailable })
const [searchTerm, setSearchTermParam] = useQueryState(connectorSearchParam.key, {
...connectorSearchParam.parser,
...connectorSearchUrlKeys,
})
- /**
- * The input binds to the instant nuqs value; only the URL write is debounced.
- * Filtering reads the same instant value: it is a cheap in-memory pass over a
- * small static list, which is exactly the case the url-state rule permits.
- */
- const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
-
- const { data: memberConnectorRows, isPending: connectionsPending } = useWorkspaceMemberConnectors(
- workspaceId,
- { enabled: memberAccessAvailable }
+ const [, setSelectedType] = useQueryState(
+ searchSetupParam.key,
+ searchSetupParam.parser.withOptions({ history: 'replace' })
)
- /** Rows cached before the feature went off are not this surface's to show. */
- const memberConnectors = memberAccessAvailable
- ? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS)
- : EMPTY_MEMBER_CONNECTORS
- useScrollRestoration(scrollContainerRef, {
- ready: !memberAccessAvailable || !connectionsPending,
- })
-
- /** The Sim Search connection per source; other knowledge bases' connectors keep their own section. */
- const { connectionByType, sharedConnectors } = useMemo(() => {
- const connectionByType = new Map()
- const sharedConnectors: WorkspaceMemberConnector[] = []
- for (const connector of memberConnectors) {
- if (
- connector.knowledgeBaseName === SIM_SEARCH_KNOWLEDGE_BASE_NAME &&
- !connectionByType.has(connector.connectorType)
- ) {
- connectionByType.set(connector.connectorType, connector)
- } else {
- sharedConnectors.push(connector)
- }
- }
- return { connectionByType, sharedConnectors }
- }, [memberConnectors])
+ const [, setManagedSource] = useQueryState(
+ managedSourceParam.key,
+ managedSourceParam.parser.withOptions({ history: 'replace' })
+ )
+ const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
+ const membershipQueryKeys = useMemo(() => [searchSourceKeys.list(workspaceId)], [workspaceId])
const connectedConnectorIds = useMemo(
() =>
new Set(
- memberConnectors
- .filter((connector) => connector.viewerMembership === 'connected')
- .map((connector) => connector.connectorId)
+ sources.data
+ ?.filter((source) => source.viewerMembership === 'connected')
+ .map((source) => source.connectorId)
),
- [memberConnectors]
+ [sources.data]
)
- const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
- const {
- connectSource,
- connectSearchSource,
- setupConnector,
- closeSetup,
- isAwaiting,
- isAwaitingSource,
- isPending,
- error,
- } = useMemberEnrollment({
+ const { connect, isAwaiting, isPending, error } = useMemberEnrollment({
membershipQueryKeys,
connectedConnectorIds,
})
- const normalizedSearch = searchTerm.trim().toLowerCase()
- const visibleConnectors = normalizedSearch
- ? SEARCH_CONNECTORS.filter(
- (connector) =>
- connector.meta.name.toLowerCase().includes(normalizedSearch) ||
- connector.meta.description.toLowerCase().includes(normalizedSearch)
- )
- : SEARCH_CONNECTORS
- const visibleSharedConnectors = normalizedSearch
- ? sharedConnectors.filter((connector) =>
- [connectorDisplayName(connector.connectorType), connector.knowledgeBaseName].some((text) =>
- text.toLowerCase().includes(normalizedSearch)
- )
- )
- : sharedConnectors
+ useScrollRestoration(scrollContainerRef, { ready: !sources.isPending })
- const showNoResults =
- Boolean(normalizedSearch) &&
- visibleConnectors.length === 0 &&
- visibleSharedConnectors.length === 0
+ const normalizedSearch = searchTerm.trim().toLowerCase()
+ const matches = (type: string, description: string) =>
+ `${connectorDisplayName(type)} ${description}`.toLowerCase().includes(normalizedSearch)
+ const visibleSources =
+ sources.data?.filter((source) => matches(source.connectorType, source.sourceDescription)) ?? []
+ const sharedConnectors = memberAccessAvailable
+ ? (shared.data?.filter(
+ (source) =>
+ !source.knowledgeBaseIsSearchIndex &&
+ matches(
+ source.connectorType,
+ `${source.knowledgeBaseName} ${source.sourceDescription ?? ''}`
+ )
+ ) ?? [])
+ : []
return (
@@ -224,69 +99,88 @@ export function Search() {
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
+
+
Search sources
+ {canAdmin && (memberAccessAvailable || mirroredAccessAvailable) && (
+ {
+ void setSearchTermParam('')
+ void setSelectedType('')
+ }}
+ >
+ Add source
+
+ )}
+
setSearchTerm(e.target.value)}
+ onChange={(event) => setSearchTerm(event.target.value)}
/>
-
-
- {visibleConnectors.length > 0 && (
-
- {visibleConnectors.map((connector) => {
- const connection = connectionByType.get(connector.type)
- return (
- connectSearchSource(workspaceId, connector, connection)}
- />
- )
- })}
-
- )}
-
- {memberAccessAvailable && (
-
}
+
+ {sources.isError ? (
+ void sources.refetch()}
+ variant='inline'
/>
- )}
-
- {error && {error}
}
- {setupConnector && (
-
- connectSource(workspaceId, setupConnector.type, sourceConfig)
- }
- />
- )}
-
- {showNoResults && (
+ ) : sources.isPending ? (
+ Loading sources…
+ ) : visibleSources.length > 0 ? (
+ visibleSources.map((source) => (
+ connect(source.knowledgeBaseId, source.connectorId)}
+ onManage={() => void setManagedSource(source.connectorId, { history: 'push' })}
+ />
+ ))
+ ) : (
- No connectors found matching “{searchTerm}”
+ {normalizedSearch
+ ? 'No matching sources.'
+ : canAdmin
+ ? 'Add a source to start indexing documents for Search.'
+ : 'Your workspace hasn’t added any sources yet. Ask a workspace admin to get started.'}
)}
-
+
+ {memberAccessAvailable &&
+ (shared.isError ? (
+ void shared.refetch()}
+ variant='inline'
+ />
+ ) : (
+
+ ))}
+ {error && {error}
}
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx
index 9814b45a8ae..16b97e2c87d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx
@@ -11,6 +11,7 @@ const {
mockNotFound,
mockRedirect,
mockSectionPrefetch,
+ mockGetHostContext,
} = vi.hoisted(() => ({
mockAuthorizeSection: vi.fn(),
mockGetQueryClient: vi.fn(),
@@ -22,6 +23,7 @@ const {
throw new Error(`NEXT_REDIRECT:${href}`)
}),
mockSectionPrefetch: vi.fn(),
+ mockGetHostContext: vi.fn(),
}))
vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect }))
@@ -29,6 +31,9 @@ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/settings/application/workspace-section-access', () => ({
authorizeWorkspaceSettingsSection: mockAuthorizeSection,
}))
+vi.mock('@/lib/workspaces/host-context', () => ({
+ getWorkspaceHostContextForViewer: mockGetHostContext,
+}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: mockGetQueryClient,
}))
@@ -59,6 +64,7 @@ describe('WorkspaceSettingsSectionPage', () => {
mockAuthorizeSection.mockResolvedValue({ allowed: true })
mockGetQueryClient.mockReturnValue(new QueryClient())
mockSectionPrefetch.mockResolvedValue(undefined)
+ mockGetHostContext.mockResolvedValue(null)
})
it('authenticates before authorizing the resolved section', async () => {
@@ -72,6 +78,19 @@ describe('WorkspaceSettingsSectionPage', () => {
expect(mockSectionPrefetch).toHaveBeenCalledTimes(1)
})
+ it('preserves legacy organization settings query state on the canonical org destination', async () => {
+ mockGetHostContext.mockResolvedValue({ hostOrganizationId: 'org-target' })
+ await expect(
+ WorkspaceSettingsSectionPage({
+ ...pageProps('subscription'),
+ searchParams: Promise.resolve({ window: 'month', source: ['search', 'chat'] }),
+ })
+ ).rejects.toThrow(
+ 'NEXT_REDIRECT:/o/org-target/settings/billing?window=month&source=search&source=chat'
+ )
+ expect(mockSectionPrefetch).not.toHaveBeenCalled()
+ })
+
it('conceals inaccessible workspaces and platform-only sections', async () => {
mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'not-found' })
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
index 742a35d37b6..34ce91bc3e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
@@ -2,8 +2,13 @@ import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
+import {
+ getOrganizationSettingsHref,
+ UNIFIED_TO_ORGANIZATION_SECTION,
+} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/workspace-section-access'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { SECTION_PREFETCHERS } from './prefetch'
@@ -11,6 +16,7 @@ import { SettingsPage } from './settings'
interface WorkspaceSettingsSectionPageProps {
params: Promise<{ workspaceId: string; section: string }>
+ searchParams?: Promise
>
}
/**
@@ -30,6 +36,7 @@ export async function generateMetadata({
export default async function WorkspaceSettingsSectionPage({
params,
+ searchParams,
}: WorkspaceSettingsSectionPageProps) {
const session = await getSession()
if (!session?.user) redirect('/login')
@@ -50,6 +57,22 @@ export default async function WorkspaceSettingsSectionPage({
redirectToGeneralSettings(workspaceId)
}
+ const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed]
+ if (organizationSection) {
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
+ if (hostContext?.hostOrganizationId) {
+ const query = new URLSearchParams()
+ for (const [key, value] of Object.entries((await searchParams) ?? {})) {
+ for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
+ query.append(key, entry)
+ }
+ }
+ redirect(
+ getOrganizationSettingsHref(hostContext.hostOrganizationId, organizationSection, query)
+ )
+ }
+ }
+
const queryClient = getQueryClient()
/**
* Protected section data starts only after the current server-side section gate succeeds.
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts
index daf315dc569..397ffde3dde 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts
@@ -4,9 +4,8 @@
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockGetCurrentUserSettings, mockExecute, mockAuthenticate } = vi.hoisted(() => ({
+const { mockGetCurrentUserSettings, mockAuthenticate } = vi.hoisted(() => ({
mockGetCurrentUserSettings: vi.fn(),
- mockExecute: vi.fn(),
mockAuthenticate: vi.fn(),
}))
@@ -14,10 +13,6 @@ vi.mock('@/lib/users/application/read-current-user', () => ({
getCurrentUserSettingsUseCase: { execute: mockGetCurrentUserSettings },
}))
-vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
- listCredentialGroupSettings: { execute: mockExecute },
-}))
-
vi.mock('@/lib/api/server/routes/internal-json-route', () => ({
internalSessionAuth: { authenticate: mockAuthenticate },
}))
@@ -27,7 +22,6 @@ vi.mock('@/lib/api/server/routes', () => ({
import { SECTION_PREFETCHERS } from '@/app/workspace/[workspaceId]/settings/[section]/prefetch'
import { generalSettingsKeys } from '@/hooks/queries/current-user-data'
-import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
describe('general settings prefetch', () => {
beforeEach(() => {
@@ -64,70 +58,3 @@ describe('general settings prefetch', () => {
})
})
})
-
-describe('credential-groups prefetch', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockAuthenticate.mockResolvedValue({ kind: 'session', userId: 'u1', sessionId: 's1' })
- })
-
- it('hydrates the key the panel subscribes to, through the authorized use case', async () => {
- const credentialGroup = {
- id: 'g1',
- workspaceId: 'w1',
- name: 'Engineering',
- description: null,
- options: [],
- mcpServers: [],
- status: 'active',
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-01T00:00:00.000Z',
- }
- mockExecute.mockResolvedValue({
- credentialGroups: [{ ...credentialGroup, internal: true }],
- availableProviders: ['gmail'],
- })
- const queryClient = new QueryClient()
-
- await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
- workspaceId: 'w1',
- })
-
- expect(mockExecute).toHaveBeenCalledWith({
- principal: { kind: 'session', userId: 'u1', sessionId: 's1' },
- input: { workspaceId: 'w1' },
- })
- /**
- * The whole response envelope, not just the groups array: this key is shared with
- * `fetchCredentialGroupSettings`, and seeding it with a narrower shape would leave every
- * consumer reading an empty list for as long as the hydrated value stayed fresh.
- */
- expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual({
- credentialGroups: [credentialGroup],
- availableProviders: ['gmail'],
- })
- })
-
- it('leaves the cache empty when the use case denies the viewer', async () => {
- mockExecute.mockRejectedValue(Object.assign(new Error('forbidden'), { code: 'forbidden' }))
- const queryClient = new QueryClient()
-
- await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
- workspaceId: 'w1',
- })
-
- expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
- })
-
- it('leaves the cache empty when session authentication fails', async () => {
- mockAuthenticate.mockRejectedValue(new Error('unauthenticated'))
- const queryClient = new QueryClient()
-
- await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
- workspaceId: 'w1',
- })
-
- expect(mockExecute).not.toHaveBeenCalled()
- expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
- })
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
index 5f4bc94e742..89889c87cb3 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
@@ -1,38 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
-import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'
-import { internalSessionAuth } from '@/lib/api/server/routes/internal-json-route'
-import { listCredentialGroupSettings } from '@/lib/credential-groups/application/manage-groups'
import { prefetchCurrentUserSettings } from '@/lib/settings/prefetch-current-user-settings'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
-import {
- CREDENTIAL_GROUP_LIST_STALE_TIME,
- credentialGroupKeys,
-} from '@/hooks/queries/utils/credential-group-queries'
-
-/** Prefetches credential groups through the route's authorization and response boundaries. */
-async function prefetchCredentialGroups(
- queryClient: QueryClient,
- { workspaceId }: SettingsSectionPrefetchContext
-) {
- return queryClient.prefetchQuery({
- queryKey: credentialGroupKeys.list(workspaceId),
- queryFn: async () => {
- const principal = await internalSessionAuth.authenticate()
- const result = await listCredentialGroupSettings.execute({
- principal,
- input: { workspaceId },
- })
- /**
- * Hydrates the whole response envelope, matching what `fetchCredentialGroupSettings` caches
- * under this key. Narrowing to the groups array here would seed the shared entry with a
- * shape its consumers do not read, so every one of them would see an empty list until the
- * first refetch replaced it.
- */
- return listCredentialGroupsContract.response.schema.parse(result)
- },
- staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
- })
-}
export interface SettingsSectionPrefetchContext {
workspaceId: string
@@ -52,5 +20,4 @@ export const SECTION_PREFETCHERS: Partial<
general: (queryClient) => prefetchCurrentUserSettings(queryClient),
billing: (queryClient) => prefetchCurrentUserSettings(queryClient),
admin: (queryClient) => prefetchCurrentUserSettings(queryClient),
- 'credential-groups': prefetchCredentialGroups,
}
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 86878115939..a877fd6668b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
@@ -92,18 +92,6 @@ export const groupIdUrlKeys = {
clearOnDefault: true,
} as const
-/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */
-export const credentialGroupIdParam = {
- key: 'credential-group-id',
- parser: parseAsString,
-} as const
-
-/** Opening a credential group is a destination; closing replaces the detail URL. */
-export const credentialGroupIdUrlKeys = {
- history: 'push',
- clearOnDefault: true,
-} as const
-
/** Active view inside a credential-group detail page. */
export const credentialGroupTabParam = {
key: 'credential-group-tab',
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index 7407f7af1fe..00db817fd3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -84,9 +84,6 @@ const AccessControl = dynamic(() =>
const CustomBlocks = dynamic(() =>
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
)
-const CredentialGroups = dynamic(() =>
- import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings)
-)
const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
@@ -163,9 +160,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'browser' && }
{effectiveSection === 'terminal' && }
{effectiveSection === 'secrets' && }
- {effectiveSection === 'credential-groups' && (
-
- )}
{effectiveSection === 'access-control' && organizationId && (
{
recordImpersonation(email)
await clearUserData({ preserveRecentImpersonations: true })
- window.location.assign('/workspace')
+ window.location.assign(APP_ENTRY_PATH)
},
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index 05adc77f7e9..b8dc9cd6d30 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
-import { ChipConfirmModal, Label, Switch, Tooltip, toast } from '@sim/emcn'
+import { Chip, ChipConfirmModal, Label, Switch, Tooltip, toast } from '@sim/emcn'
import { CircleInfo, Plus } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -371,13 +371,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
Allow personal API keys
-
-
-
+
Allow collaborators to authenticate with their own keys. Hosted usage is
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
index 6f0127f4b9f..087e20adc11 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
@@ -21,7 +21,7 @@ const logger = createLogger('CreateApiKeyModal')
interface CreateApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
- workspaceId: string
+ workspaceId?: string
existingKeyNames?: string[]
allowPersonalApiKeys?: boolean
canManageWorkspaceKeys?: boolean
@@ -77,9 +77,12 @@ export function CreateApiKeyModal({
}
}
+ const canCreateKeyType =
+ keyType === 'personal' ? allowPersonalApiKeys : canManageWorkspaceKeys && Boolean(workspaceId)
+
const handleCreateKey = async () => {
const trimmedName = keyName.trim()
- if (!trimmedName) return
+ if (!trimmedName || !canCreateKeyType || createApiKeyMutation.isPending) return
const isDuplicate = existingKeyNames.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase()
@@ -95,12 +98,17 @@ export function CreateApiKeyModal({
setCreateError(null)
try {
- const data = await createApiKeyMutation.mutateAsync({
- workspaceId,
- name: trimmedName,
- keyType,
- source,
- })
+ if (keyType === 'workspace' && !workspaceId) return
+ const data = await createApiKeyMutation.mutateAsync(
+ keyType === 'workspace' && workspaceId
+ ? {
+ workspaceId,
+ name: trimmedName,
+ keyType,
+ source,
+ }
+ : { keyType: 'personal', name: trimmedName, source }
+ )
setNewKey(data.key)
setShowNewKeyDialog(true)
@@ -118,17 +126,23 @@ export function CreateApiKeyModal({
}
const handleClose = () => {
- onOpenChange(false)
+ if (!createApiKeyMutation.isPending) onOpenChange(false)
}
return (
<>
- {/* Create API Key Dialog */}
-
+ {
+ if (!createApiKeyMutation.isPending) onOpenChange(nextOpen)
+ }}
+ dismissDisabled={createApiKeyMutation.isPending}
+ srTitle='Create new API key'
+ >
Create new API key
{canManageWorkspaceKeys && (
-
+
{
@@ -145,7 +159,7 @@ export function CreateApiKeyModal({
)}
{
setKeyName(value)
@@ -161,12 +175,7 @@ export function CreateApiKeyModal({
name='fakeusernameremembered'
autoComplete='username'
aria-hidden='true'
- style={{
- position: 'absolute',
- left: '-9999px',
- opacity: 0,
- pointerEvents: 'none',
- }}
+ className='-left-[9999px] pointer-events-none absolute opacity-0'
tabIndex={-1}
readOnly
/>
@@ -177,15 +186,11 @@ export function CreateApiKeyModal({
primaryAction={{
label: createApiKeyMutation.isPending ? 'Creating...' : 'Create',
onClick: handleCreateKey,
- disabled:
- !keyName.trim() ||
- createApiKeyMutation.isPending ||
- (keyType === 'workspace' && !canManageWorkspaceKeys),
+ disabled: !keyName.trim() || createApiKeyMutation.isPending || !canCreateKeyType,
}}
/>
- {/* New API Key Dialog - shows the created key */}
{
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index dd96fd4efc5..488a0ef206c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -36,7 +36,6 @@ import {
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
-import { useCredentialGroups } from '@/hooks/queries/credential-groups'
import {
type McpServer,
type McpTool,
@@ -77,7 +76,6 @@ interface ServerListItemProps {
isLoadingTools?: boolean
isRefreshing?: boolean
discoveryError?: string | null
- ownerName?: string
onViewDetails: () => void
onAuthorize: () => void
}
@@ -90,7 +88,6 @@ function ServerListItem({
isLoadingTools = false,
isRefreshing = false,
discoveryError = null,
- ownerName,
onViewDetails,
onAuthorize,
}: ServerListItemProps) {
@@ -121,7 +118,7 @@ function ServerListItem({
// Transport rides on the description rather than beside the name — inside the
// row's truncating title a long name would clip it away entirely.
const statusText = server.managedConnectorId
- ? `Managed by ${ownerName ?? 'a Credential Group'}`
+ ? 'Managed by Connected accounts'
: isConnecting
? 'Waiting for authorization...'
: isRefreshing
@@ -206,9 +203,6 @@ export function MCP() {
isLoading: serversLoading,
error: serversError,
} = useMcpServers(workspaceId)
- const credentialGroups = useCredentialGroups(
- workspacePermissions.canAdmin ? workspaceId : undefined
- )
const { data: mcpToolsData = [], toolsStateByServer } = useMcpToolsQuery(workspaceId)
const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId, {
enabled: selectedServerId !== null,
@@ -291,9 +285,6 @@ export function MCP() {
const filteredServers = (servers || []).filter((server) =>
server.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
- const credentialGroupNameById = new Map(
- credentialGroups.data?.credentialGroups.map((group) => [group.id, group.name] as const) ?? []
- )
const handleViewDetails = (serverId: string) => {
setSelectedServerId(serverId)
@@ -493,11 +484,7 @@ export function MCP() {
)}
{server.managedConnectorId && (
-
- {server.credentialGroupId
- ? (credentialGroupNameById.get(server.credentialGroupId) ?? 'Credential Group')
- : 'Credential Group'}
-
+ Connected accounts
)}
{server.connectionStatus !== 'connected' && (
@@ -731,11 +718,6 @@ export function MCP() {
key={server.id}
canManage={canEdit}
server={server}
- ownerName={
- server.credentialGroupId
- ? credentialGroupNameById.get(server.credentialGroupId)
- : undefined
- }
tools={tools}
isConnecting={connectingOauthServers.has(server.id)}
isLoadingTools={isLoadingTools}
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 84fb6d65b94..82c1009e63d 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
@@ -45,10 +45,10 @@ export function NoOrganizationView({
-
Create Your Team Workspace
+
Create your organization
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
- workspace to start collaborating with your team.
+ organization to start collaborating with your team.
@@ -107,7 +107,7 @@ export function NoOrganizationView({
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
>
- {isCreatingOrg ? 'Creating...' : 'Create Team Workspace'}
+ {isCreatingOrg ? 'Creating...' : 'Create organization'}
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 3952ac18e48..42ca096a093 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, toast } from '@sim/emcn'
+import { ChipDropdown, ChipTag, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
@@ -154,12 +154,7 @@ export function OrganizationMemberLists({
disabled={updateMemberRole.isPending}
/>
) : (
-
+ {capitalize(member.role)}
)
}
menu={buildActionsMenu([
@@ -167,7 +162,7 @@ export function OrganizationMemberLists({
...(canManage && !isOwner
? [
{
- label: 'Manage Credits',
+ label: 'Manage credits',
onSelect: () =>
setCreditsTarget({
userId: member.userId,
@@ -262,30 +257,28 @@ export function OrganizationMemberLists({
const renderOrgInviteRow = (invitation: RosterPendingInvitation) => {
const isExternal = invitation.membershipIntent === 'external'
- const roleControl = isExternal ? (
-
- ) : (
-
- updateInvitation
- .mutateAsync({
- orgId: organizationId,
- invitationId: invitation.id,
- role: role as OrgRole,
- })
- .catch((error) => logger.error('Failed to update invitation role', { error }))
- }
- options={ORG_ROLE_OPTIONS}
- matchTriggerWidth={false}
- disabled={!canManage || updateInvitation.isPending}
- />
- )
+ const roleControl =
+ isExternal || !canManage ? (
+
+ {isExternal ? 'External' : invitation.role === 'admin' ? 'Admin' : 'Member'}
+
+ ) : (
+
+ updateInvitation
+ .mutateAsync({
+ orgId: organizationId,
+ invitationId: invitation.id,
+ role: role as OrgRole,
+ })
+ .catch((error) => logger.error('Failed to update invitation role', { error }))
+ }
+ options={ORG_ROLE_OPTIONS}
+ matchTriggerWidth={false}
+ disabled={updateInvitation.isPending}
+ />
+ )
return renderInviteRow(invitation, 'org-invite', roleControl)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
index 5f2b20d33c4..d57734dbf9e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
@@ -164,6 +164,7 @@ export function TransferOwnershipDialog({
setSelectedUserId(m.userId)}
className={cn(
'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx
index 0ff76850276..0db89380c53 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx
@@ -61,7 +61,27 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
}))
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
- SettingsPanel: ({ children }: { children?: ReactNode }) => ,
+ SettingsPanel: ({
+ children,
+ actions = [],
+ }: {
+ children?: ReactNode
+ actions?: { text: string; disabled?: boolean; onSelect: () => void }[]
+ }) => (
+
+ {actions.map((action) => (
+
+ {action.text}
+
+ ))}
+ {children}
+
+ ),
}))
vi.mock('@/app/workspace/[workspaceId]/settings/components/team-management/components', () => ({
@@ -125,6 +145,31 @@ afterEach(() => {
})
describe('TeamManagement organization errors', () => {
+ it.each([
+ { admin: true, canInvite: false, shown: true, disabled: true },
+ { admin: true, canInvite: true, shown: true, disabled: false },
+ { admin: false, canInvite: false, shown: false, disabled: false },
+ ])(
+ 'respects the org invitation capability for admin=$admin, allowed=$canInvite',
+ ({ admin, canInvite, shown, disabled }) => {
+ mockIsAdminOrOwner.mockReturnValue(admin)
+ mockUseOrganization.mockReturnValue({ data: { id: 'org-1' }, error: null, isLoading: false })
+ act(() =>
+ root.render(
+
+ )
+ )
+ const invite = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Invite'
+ )
+ expect(Boolean(invite)).toBe(shown)
+ if (invite) expect(invite.disabled).toBe(disabled)
+ }
+ )
it('shows the organization error instead of the missing-organization recovery view', () => {
mockUseOrganization.mockReturnValue({
data: undefined,
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 2d21ca6bed7..6bab7fe2a95 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
@@ -1,12 +1,13 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Plus } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import {
@@ -38,16 +39,23 @@ const logger = createLogger('TeamManagement')
interface TeamManagementProps {
organizationId: string
+ canInviteMembers?: boolean
/**
- * Required: organization billing is reached only through a workspace, so the
- * caller — which knows the workspace — is the only thing that can build it.
+ * The caller owns navigation so the same panel works in organization and
+ * legacy workspace settings.
*/
billingHref: string
}
-export function TeamManagement({ organizationId, billingHref }: TeamManagementProps) {
+export function TeamManagement({
+ organizationId,
+ billingHref,
+ canInviteMembers,
+}: TeamManagementProps) {
const { data: session } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
+ const invitationsDisabled =
+ canInviteMembers === undefined ? isInvitationsDisabled : !canInviteMembers
const [memberQuery, setMemberQuery] = useSettingsSearch()
const {
@@ -158,13 +166,13 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
}
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
- const handleOrgNameChange = useCallback((e: React.ChangeEvent) => {
+ const handleOrgNameChange = (e: React.ChangeEvent) => {
const newName = e.target.value
setOrgName(newName)
setOrgSlug(generateSlug(newName))
- }, [])
+ }
- const handleCreateOrganization = useCallback(async () => {
+ const handleCreateOrganization = async () => {
if (!session?.user || !orgName.trim()) return
try {
@@ -179,34 +187,31 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
} catch (error) {
logger.error('Failed to create organization', error)
}
- }, [orgName, orgSlug, createOrgMutation, session?.user])
-
- const handleRemoveMember = useCallback(
- async (member: Member) => {
- if (!session?.user) return
+ }
- if (!member.user?.id) {
- logger.error('Member object missing user ID', { member })
- return
- }
+ const handleRemoveMember = async (member: Member) => {
+ if (!session?.user) return
- const isLeavingSelf = member.user?.email === session.user.email
- const displayName = isLeavingSelf
- ? 'yourself'
- : member.user?.name || member.user?.email || 'this member'
+ if (!member.user?.id) {
+ logger.error('Member object missing user ID', { member })
+ return
+ }
- setRemoveMemberDialog({
- open: true,
- memberId: member.user.id,
- memberName: displayName,
- isSelfRemoval: isLeavingSelf,
- isExternalRemoval: member.role === 'external',
- })
- },
- [session?.user]
- )
+ const isLeavingSelf = member.user?.email === session.user.email
+ const displayName = isLeavingSelf
+ ? 'yourself'
+ : member.user?.name || member.user?.email || 'this member'
+
+ setRemoveMemberDialog({
+ open: true,
+ memberId: member.user.id,
+ memberName: displayName,
+ isSelfRemoval: isLeavingSelf,
+ isExternalRemoval: member.role === 'external',
+ })
+ }
- const confirmRemoveMember = useCallback(async () => {
+ const confirmRemoveMember = async () => {
const { memberId, isSelfRemoval } = removeMemberDialog
if (!session?.user || !memberId) return
@@ -224,65 +229,53 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
})
if (isSelfRemoval) {
- window.location.href = '/workspace'
+ window.location.href = APP_ENTRY_PATH
}
} catch (error) {
logger.error('Failed to remove member', error)
}
- }, [
- removeMemberDialog.memberId,
- removeMemberDialog.isSelfRemoval,
- session?.user?.id,
- organizationId,
- removeMemberMutation,
- ])
-
- const handleTransferDialogOpenChange = useCallback(
- (next: boolean) => {
- setTransferDialogOpen(next)
- if (!next) {
- transferOwnershipMutation.reset()
- setTransferPortalError(null)
- }
- },
- [transferOwnershipMutation]
- )
+ }
- const handleOpenTransferDialog = useCallback(() => {
+ const handleTransferDialogOpenChange = (next: boolean) => {
+ setTransferDialogOpen(next)
+ if (!next) {
+ transferOwnershipMutation.reset()
+ setTransferPortalError(null)
+ }
+ }
+
+ const handleOpenTransferDialog = () => {
transferOwnershipMutation.reset()
setTransferPortalError(null)
setTransferDialogOpen(true)
- }, [transferOwnershipMutation])
+ }
- const handleConfirmTransfer = useCallback(
- async (newOwnerUserId: string) => {
- try {
- const result = await transferOwnershipMutation.mutateAsync({
- orgId: organizationId,
- newOwnerUserId,
- alsoLeave: true,
- })
+ const handleConfirmTransfer = async (newOwnerUserId: string) => {
+ try {
+ const result = await transferOwnershipMutation.mutateAsync({
+ orgId: organizationId,
+ newOwnerUserId,
+ alsoLeave: true,
+ })
- setTransferDialogOpen(false)
+ setTransferDialogOpen(false)
- if (result.left) {
- window.location.href = '/workspace'
- }
- } catch (error) {
- logger.error('Failed to transfer ownership', error)
+ if (result.left) {
+ window.location.href = APP_ENTRY_PATH
}
- },
- [organizationId, transferOwnershipMutation]
- )
+ } catch (error) {
+ logger.error('Failed to transfer ownership', error)
+ }
+ }
- const handleOpenTransferBillingPortal = useCallback(() => {
+ const handleOpenTransferBillingPortal = () => {
setTransferPortalError(null)
const portalWindow = window.open('', '_blank')
openBillingPortal.mutate(
{
context: 'organization',
organizationId,
- returnUrl: `${getBaseUrl()}/workspace`,
+ returnUrl: `${getBaseUrl()}${APP_ENTRY_PATH}`,
},
{
onSuccess: (data) => {
@@ -301,7 +294,7 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
},
}
)
- }, [organizationId, openBillingPortal])
+ }
const displayOrganization = organization
@@ -367,8 +360,8 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
icon: Plus,
variant: 'primary',
onSelect: () => setInviteModalOpen(true),
- disabled: isInvitationsDisabled,
- tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
+ disabled: invitationsDisabled,
+ tooltip: invitationsDisabled ? 'Invitations are disabled' : undefined,
},
]
: []
@@ -426,7 +419,8 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
open={inviteModalOpen}
onOpenChange={setInviteModalOpen}
organizationId={displayOrganization.id}
- canInvite={adminOrOwner}
+ isOrganizationAdmin={adminOrOwner}
+ canInvite={adminOrOwner && !invitationsDisabled}
/>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
index f304c1e6574..742ed73e6a4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
@@ -27,13 +27,12 @@ describe('unified settings navigation', () => {
{ id: 'terminal', label: 'Terminal', section: 'account' },
{ id: 'access-control', label: 'Permission groups', section: 'organization' },
{ id: 'audit-logs', label: 'Audit logs', section: 'organization' },
- { id: 'forks', label: 'Workspace forks', section: 'organization' },
+ { id: 'forks', label: 'Workspace forks', section: 'workspace' },
{ id: 'billing', label: 'Subscription', section: 'account' },
{ id: 'teammates', label: 'Teammates', section: 'workspace' },
{ id: 'organization', label: 'Members', section: 'organization' },
{ id: 'usage', label: 'Usage tracking', section: 'organization' },
{ id: 'secrets', label: 'Secrets', section: 'workspace' },
- { id: 'credential-groups', label: 'Credential groups', section: 'workspace' },
{ id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
{ id: 'mcp', label: 'MCP tools', section: 'workspace' },
{ id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
@@ -48,7 +47,7 @@ describe('unified settings navigation', () => {
{ id: 'data-retention', label: 'Data retention', section: 'organization' },
{ id: 'data-drains', label: 'Data drains', section: 'organization' },
{ id: 'whitelabeling', label: 'White-labeling', section: 'organization' },
- { id: 'custom-blocks', label: 'Custom blocks', section: 'organization' },
+ { id: 'custom-blocks', label: 'Custom blocks', section: 'workspace' },
{ id: 'admin', label: 'Admin', section: 'platform' },
{ id: 'mothership', label: 'Mothership', section: 'platform' },
])
@@ -72,20 +71,19 @@ describe('unified settings navigation', () => {
'teammates',
'secrets',
'mcp',
+ 'custom-blocks',
+ 'forks',
'custom-tools',
'byok',
'inbox',
'workflow-mcp-servers',
'apikeys',
'sandboxes',
- 'credential-groups',
'recently-deleted',
])
expect(idsForSection('organization')).toEqual([
'organization',
'usage',
- 'custom-blocks',
- 'forks',
'access-control',
'audit-logs',
'whitelabeling',
@@ -146,6 +144,11 @@ describe('resolveSettingsSection', () => {
expect(resolveSettingsSection('')).toBeNull()
})
+ it('does not expose credential group management through workspace settings', () => {
+ expect(resolveSettingsSection('credential-groups')).toBeNull()
+ expect(resolveSettingsSection('connected-accounts')).toBeNull()
+ })
+
it('carries the catalog label through as the header title', () => {
// `billing` is the case where id and label visibly differ, and the title feeds both the
// shell heading and the document title via generateMetadata.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
index 32af4cc4d53..b12086f7a27 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
@@ -467,7 +467,6 @@ export function ConnectionBlockSelector({ id, data }: NodeProps
- selectedCredential?.type === 'service_account' ||
- selectedAllCredential?.type === 'service_account',
- [selectedCredential, selectedAllCredential]
- )
+ const isServiceAccount =
+ selectedCredential?.type === 'service_account' ||
+ selectedAllCredential?.type === 'service_account'
const { data: inaccessibleCredential } = useWorkspaceCredential(
selectedId || undefined,
@@ -170,12 +167,11 @@ export function CredentialSelector({
)
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
- const resolvedLabel = useMemo(() => {
- if (selectedAllCredential) return selectedAllCredential.displayName
- if (selectedCredential) return selectedCredential.name
- if (inaccessibleCredentialName) return inaccessibleCredentialName
- return ''
- }, [selectedAllCredential, selectedCredential, inaccessibleCredentialName])
+ const resolvedLabel = selectedAllCredential
+ ? selectedAllCredential.displayName
+ : selectedCredential
+ ? selectedCredential.name
+ : inaccessibleCredentialName || ''
const displayValue = isEditing ? editingValue : resolvedLabel
@@ -449,7 +445,7 @@ export function CredentialSelector({
return (
-
-
+
{dataversePolicy.message}
{!dataversePolicy.hasInvalidEnvironment && (
@@ -563,6 +559,9 @@ export function CredentialSelector({
onOpenChange={setShowSetupModal}
workspaceId={workspaceId}
serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId}
+ atlassianProduct={
+ serviceAccountService?.providerId === 'confluence' ? 'confluence' : 'jira'
+ }
serviceName={serviceAccountTarget.serviceName}
serviceIcon={serviceAccountTarget.serviceIcon}
onCreated={(newCredentialId) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx
index 0b335cdc0a6..0a430a7e6e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx
@@ -76,6 +76,9 @@ vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight',
() => ({ getWorkflowSearchLabelHighlight: () => undefined })
)
+vi.mock('@/hooks/queries/organization-accounts', () => ({
+ useWorkspaceOrganizationAccounts: () => ({ data: { allowed: false } }),
+}))
vi.mock('@/hooks/use-operation-access', () => ({
useOperationAccess: () => ({
getDeniedOperations: () => new Set(),
@@ -89,7 +92,7 @@ vi.mock('@/stores/workflows/workflow/store', () => ({
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: (selector: (state: unknown) => unknown) =>
- selector({ activeWorkflowId: 'wf-1' }),
+ selector({ activeWorkflowId: 'wf-1', hydration: { workspaceId: 'workspace-1' } }),
}))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: (selector: (state: unknown) => unknown) => selector({ workflowValues: {} }),
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
index 081d329bc3c..3af408c28f3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
@@ -18,6 +18,7 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
+import { useWorkspaceOrganizationAccounts } from '@/hooks/queries/organization-accounts'
import { useDebounce } from '@/hooks/use-debounce'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -123,6 +124,15 @@ export const Dropdown = memo(function Dropdown({
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type)
+ const workspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId)
+ const organizationAccounts = useWorkspaceOrganizationAccounts(
+ workspaceId ?? undefined,
+ blockType === 'credential' && subBlockId === OPERATION_SUBBLOCK_ID
+ )
+ const hideOrganizationOperations =
+ blockType === 'credential' &&
+ subBlockId === OPERATION_SUBBLOCK_ID &&
+ organizationAccounts.data?.allowed !== true
const blockConfig = blockType ? getBlock(blockType) : null
const previousModeRef = useRef(null)
@@ -274,10 +284,19 @@ export const Dropdown = memo(function Dropdown({
label: toLabel(opt.label),
value: opt.id,
icon: 'icon' in opt ? opt.icon : undefined,
- hidden: opt.hidden || deniedOperationIds.has(opt.id),
+ hidden:
+ opt.hidden ||
+ deniedOperationIds.has(opt.id) ||
+ (hideOrganizationOperations &&
+ [
+ 'find_organization_account',
+ 'list_organization_accounts',
+ 'find_organization_mcp_connection',
+ 'list_organization_mcp_connections',
+ ].includes(opt.id)),
}
})
- }, [allOptions, deniedOperationIds, preserveLabelCase])
+ }, [allOptions, deniedOperationIds, preserveLabelCase, hideOrganizationOperations])
const optionMap = useMemo(() => {
return new Map(comboboxOptions.map((opt) => [opt.value, opt.label]))
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx
index deb7b7494a8..a9a25f64059 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo } from 'react'
-import { Combobox, type ComboboxOption } from '@sim/emcn'
+import { ChipCombobox, ChipTag, type ComboboxOption } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { useQueries } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
@@ -116,10 +116,7 @@ export function KnowledgeBaseSelector({
return { options, labelById }
}, [combinedKnowledgeBases, knowledgeBaseFolders])
- const labelOf = useCallback(
- (kb: KnowledgeBaseData) => labelById.get(kb.id) ?? kb.name,
- [labelById]
- )
+ const labelOf = (kb: KnowledgeBaseData) => labelById.get(kb.id) ?? kb.name
/**
* Compute selected knowledge bases for tag display
@@ -167,24 +164,22 @@ export function KnowledgeBaseSelector({
/**
* Remove selected knowledge base from multi-select tags
*/
- const handleRemoveKnowledgeBase = useCallback(
- (knowledgeBaseId: string) => {
- if (isPreview) return
+ const handleRemoveKnowledgeBase = (knowledgeBaseId: string) => {
+ if (isPreview) return
- const newSelectedIds = selectedIds.filter((id) => id !== knowledgeBaseId)
- const valueToStore =
- newSelectedIds.length === 1 ? newSelectedIds[0] : newSelectedIds.join(',')
+ const newSelectedIds = selectedIds.filter((id) => id !== knowledgeBaseId)
+ const valueToStore = newSelectedIds.length === 1 ? newSelectedIds[0] : newSelectedIds.join(',')
- setStoreValue(valueToStore)
- onKnowledgeBaseSelect?.(newSelectedIds)
- },
- [isPreview, selectedIds, setStoreValue, onKnowledgeBaseSelect]
- )
+ setStoreValue(valueToStore)
+ onKnowledgeBaseSelect?.(newSelectedIds)
+ }
const label =
subBlock.placeholder || (isMultiSelect ? 'Select knowledge bases' : 'Select knowledge base')
- const hasMemberScopedSelection = selectedKnowledgeBases.some((kb) => kb.hasMemberScopedConnector)
+ const hasMemberScopedSelection = selectedKnowledgeBases.some(
+ (kb) => kb.hasPermissionScopedConnector
+ )
return (
@@ -200,31 +195,23 @@ export function KnowledgeBaseSelector({
label: labelOf(kb),
})
return (
-
handleRemoveKnowledgeBase(kb.id) : undefined
+ }
>
-
-
- {formatDisplayText(labelOf(kb), { workflowSearchHighlight })}
-
- {!disabled && !isPreview && (
-
handleRemoveKnowledgeBase(kb.id)}
- className='ml-1 text-[color-mix(in_srgb,var(--brand-knowledge)_60%,transparent)] hover-hover:text-[var(--brand-knowledge)]'
- aria-label={`Remove ${labelOf(kb)}`}
- >
-
-
- )}
-
+ {formatDisplayText(labelOf(kb), { workflowSearchHighlight })}
+
)
})}
)}
- {
href: '/workspace/w1/tables/t2',
},
]}
- icon={Table}
emptyLabel='No tables yet'
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
index 8d6431cd43f..021f2a21e46 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
@@ -1,4 +1,4 @@
-import { type ComponentType, type MouseEvent as ReactMouseEvent, useState } from 'react'
+import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import {
Chip,
chipVariants,
@@ -15,7 +15,7 @@ import {
Loader,
OverflowText,
} from '@sim/emcn'
-import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
+import { MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders'
@@ -32,8 +32,6 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface CollapsedResourceFlyoutProps {
entries: FlyoutEntry[]
- /** Icon for the resource rows. Folders always carry the folder glyph. */
- icon: ComponentType<{ className?: string }>
/** Resource open on the current route, so its row reads as selected. */
currentItemId?: string
/**
@@ -49,11 +47,12 @@ interface CollapsedResourceFlyoutProps {
/**
* Rail flyout body for a foldered workspace resource (Tables, Files). Every row
* is a link — the flyout is a jump list, so folders open as submenus rather than
- * navigating, and an empty one has nowhere to go and is inert.
+ * navigating, and an empty one has nowhere to go and is inert. Rows carry no
+ * glyph: the rail chip the flyout hangs off already names the resource, so a
+ * repeated icon on every row is noise in a list that exists only to be scanned.
*/
export function CollapsedResourceFlyout({
entries,
- icon,
currentItemId,
isLoading = false,
emptyLabel,
@@ -69,7 +68,7 @@ export function CollapsedResourceFlyout({
if (entries.length === 0) {
return {emptyLabel}
}
- return
+ return
}
/**
@@ -85,9 +84,8 @@ function PinnedGlyph() {
function CollapsedFlyoutRows({
entries,
- icon: Icon,
currentItemId,
-}: Pick) {
+}: Pick) {
return (
<>
{entries.map((entry) => {
@@ -95,7 +93,6 @@ function CollapsedFlyoutRows({
return (
-
{entry.pinned && }
@@ -106,7 +103,6 @@ function CollapsedFlyoutRows({
if (entry.children.length === 0) {
return (
-
{entry.pinned && }
@@ -116,16 +112,11 @@ function CollapsedFlyoutRows({
return (
-
{entry.pinned && }
-
+
)
@@ -520,7 +511,6 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
if (!hasChildren) {
return (
-
)
@@ -529,7 +519,6 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
return (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
index 6ad98b4755c..e735e674e89 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
@@ -13,8 +13,9 @@ export { SearchModal } from './search-modal'
export { SettingsSidebar } from './settings-sidebar'
export { SidebarFooter } from './sidebar-footer'
export type { SidebarNavItemData } from './sidebar-nav-chip'
-export { SidebarNavChip } from './sidebar-nav-chip'
+export { isNavItemActive, SidebarNavChip } from './sidebar-nav-chip'
export { SidebarSection } from './sidebar-section'
+export { SidebarTooltip } from './sidebar-tooltip'
export { StatusNotice } from './status-notice'
export { WorkflowList } from './workflow-list'
export { WorkspaceHeader } from './workspace-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
index 7cddf15ef18..19daa684447 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
@@ -65,7 +65,6 @@ export function TablesRailFlyout({ workspaceId }: { workspaceId: string }) {
return (
{
vi.unstubAllGlobals()
})
- it('fades the palette with the short pixel-anchored mask and the shared search surface', () => {
+ it('keeps the list unfogged at rest, insets the fade under the search field, and shares the search surface', () => {
act(() => {
root.render(
-
+
)
@@ -59,7 +59,9 @@ describe('CommandFadedList', () => {
const list = container.querySelector('[cmdk-list]')
const input = container.querySelector('[cmdk-input]')
const search = container.querySelector('[cmdk-input]')?.parentElement
- expect(list?.className).toContain('transparent_36px,black_58px,black_calc(100%_-_13px)')
+ expect(list?.className).toContain('[--scroll-fade-inset:3rem]')
+ expect(list?.hasAttribute('data-scroll-fade-top')).toBe(false)
+ expect(list?.hasAttribute('data-scroll-fade-bottom')).toBe(false)
expect(list?.className).not.toContain('scrollbar-track')
expect(input?.className).toContain('-ml-1')
expect(input?.className).toContain('indent-1')
@@ -71,7 +73,7 @@ describe('CommandFadedList', () => {
root.render(
-
+
First
Second
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
index e65664f1b83..f13bb242a94 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
@@ -5,8 +5,10 @@ import {
forwardRef,
type KeyboardEvent,
type ReactNode,
+ useCallback,
+ useRef,
} from 'react'
-import { cn } from '@sim/emcn'
+import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import { Search } from '@sim/emcn/icons'
import { Command } from 'cmdk'
@@ -20,10 +22,6 @@ interface CommandSearchProps extends Omit {
endAdornment?: ReactNode
}
-interface CommandFadedListProps extends CommandListProps {
- fade: 'canvas' | 'palette'
-}
-
/**
* The fog must repaint its host's exact background or it reads as a tinted
* band under the input: the canvas selector card fills with `--surface-2`,
@@ -37,24 +35,6 @@ const SEARCH_SURFACE_CLASSNAME = {
'bg-[linear-gradient(to_bottom,var(--bg)_0%,color-mix(in_srgb,var(--bg)_88%,transparent)_68%,transparent_100%)]',
} as const
-/**
- * The palette hides its scrollbar (`scrollbar-none` at the call site), so it
- * fades with one plain mask; its band is kept short — fully masked only under
- * the floating input (0–36px), legible by 58px, and a brief 13px exit — so
- * rows spend less time in the fog than on the canvas surface. The palette's
- * stops are anchored in pixels (the 448px max-height look frozen) because the
- * list shrinks to its content: percentage stops would move the fog on every
- * result-count change, a shimmer the dark selected first row makes obvious.
- * The canvas list fills a fixed-height card, so its percentage stops never
- * move.
- */
-const LIST_FADE_CLASSNAME = {
- canvas:
- '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]',
- palette:
- '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)]',
-} as const
-
/**
* Borderless search field layered over a fading command-result list.
*
@@ -102,17 +82,36 @@ export const CommandSearch = forwardRef(
CommandSearch.displayName = 'CommandSearch'
-/** Scrollable command list with soft edge fades tuned for each command surface. */
-export const CommandFadedList = forwardRef(
- function CommandFadedList({ className, fade, ...props }, ref) {
+/**
+ * Scrollable command list with the shared edge fade. The search field floats over
+ * the list's top 48px (`pt-12` keeps the first row clear of it), so the top band
+ * is inset by that height: while scrolled, rows are fully hidden under the field
+ * and fade in just beneath it. At rest neither edge fades, so the first group's
+ * heading and the last row are never fogged on a list that has not moved.
+ */
+export const CommandFadedList = forwardRef(
+ function CommandFadedList({ className, ...props }, ref) {
+ const listRef = useRef(null)
+ const edges = useScrollEdges(listRef)
+
+ const setRefs = useCallback(
+ (node: HTMLDivElement | null) => {
+ listRef.current = node
+ if (typeof ref === 'function') ref(node)
+ else if (ref) ref.current = node
+ },
+ [ref]
+ )
+
return (
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
index 4bf47d8dc4c..c0ce35be12d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
@@ -5,7 +5,8 @@ import { memo } from 'react'
import { OverflowText } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
-import { HEX_COLOR_REGEX } from '@/lib/branding'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { BlockTile } from '@/blocks/block-tile'
@@ -247,7 +248,6 @@ export const MemoizedWorkspaceItem = memo(
name,
isCurrent,
logoUrl,
- color,
meta,
}: {
value: string
@@ -255,31 +255,10 @@ export const MemoizedWorkspaceItem = memo(
name: string
isCurrent?: boolean
logoUrl?: string | null
- color?: string
} & ResultMetaProps) {
- const backgroundColor = color && HEX_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)'
-
return (
- {logoUrl ? (
-
- ) : (
-
-
-
-
- {name.charAt(0).toUpperCase() || 'W'}
-
- )}
+
{isCurrent && (current) }
@@ -293,7 +272,6 @@ export const MemoizedWorkspaceItem = memo(
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.logoUrl === next.logoUrl &&
- prev.color === next.color &&
prev.meta === next.meta
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
index 75a8e76b7ba..ef133e937a4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
@@ -53,7 +53,6 @@ const workspaceItems: WorkspaceItem[] = [
id: 'workspace-beta',
name: 'Beta Workspace',
href: '/workspace/workspace-beta/w',
- color: '#123456',
},
]
@@ -127,11 +126,10 @@ describe('SearchEntryGroup', () => {
})
const logo = container.querySelector('img[data-slot="workspace-icon"]')
- const fallback = container.querySelector('span[data-slot="workspace-icon"]')
+ const fallback = container.querySelector('div[data-slot="workspace-icon"]')
expect(logo?.src).toBe('https://cdn.example.com/acme.png')
expect(logo?.alt).toBe('')
expect(fallback?.textContent).toBe('B')
- expect(fallback?.querySelector('rect')?.getAttribute('fill')).toBe('#123456')
})
it('renders workspace icons in the default workspace section', () => {
@@ -151,6 +149,6 @@ describe('SearchEntryGroup', () => {
})
expect(container.querySelector('img[data-slot="workspace-icon"]')).not.toBeNull()
- expect(container.querySelector('span[data-slot="workspace-icon"]')?.textContent).toBe('B')
+ expect(container.querySelector('div[data-slot="workspace-icon"]')?.textContent).toBe('B')
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
index 5277dd70a84..89b130ca569 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
@@ -243,7 +243,6 @@ function renderSearchEntry(
name={entry.item.name}
isCurrent={entry.item.isCurrent}
logoUrl={entry.item.logoUrl}
- color={entry.item.color}
/>
)
case 'pages':
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index 6dfc70e6bbc..5597803ef7b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -1351,7 +1351,6 @@ function SearchModalContent({
rows against an edge the user cannot see. */}
s.pendingLeave)
const showDiscardDialog = pendingLeave !== null
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
const [desktopSurfaces, setDesktopSurfaces] = useState>({
settings: false,
browser: false,
@@ -130,14 +138,16 @@ export function SettingsSidebar({
const isSuperUser = session?.user?.role === 'admin'
- const isSSOProviderOwner = useMemo(() => {
- if (hosted) return null
- if (!userId || isLoadingSSO) return null
- return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
- }, [hosted, userId, ssoProvidersData?.providers, isLoadingSSO])
+ const isSSOProviderOwner =
+ hosted || !userId || isLoadingSSO
+ ? null
+ : (ssoProvidersData?.providers?.some((provider) => provider.userId === userId) ?? false)
const navigationItems = useMemo(() => {
return allNavigationItems.filter((item) => {
+ if (hostContext.hostOrganizationId && ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id)) {
+ return false
+ }
if (item.requiresSelfHosted && hosted) {
return false
}
@@ -179,12 +189,6 @@ export function SettingsSidebar({
if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) {
return false
}
- if (
- item.id === 'credential-groups' &&
- (!hostContext.features?.credentialGroups || !canAdminWorkspace)
- ) {
- return false
- }
if (item.id === 'custom-blocks' && !hostContext.hostOrganizationId) {
return false
}
@@ -261,14 +265,12 @@ export function SettingsSidebar({
desktopSurfaces,
])
- const activeSection = useMemo(() => {
- const segments = pathname?.split('/') ?? []
- const settingsIdx = segments.indexOf('settings')
- if (settingsIdx !== -1 && segments[settingsIdx + 1]) {
- return segments[settingsIdx + 1] as SettingsSection
- }
- return 'general'
- }, [pathname])
+ const segments = pathname?.split('/') ?? []
+ const settingsIndex = segments.indexOf('settings')
+ const activeSection: SettingsSection =
+ settingsIndex !== -1 && segments[settingsIndex + 1]
+ ? (segments[settingsIndex + 1] as SettingsSection)
+ : 'general'
const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()
@@ -303,62 +305,41 @@ export function SettingsSidebar({
})
}, [])
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
-
- const updateScrollState = () => {
- setHasOverflowTop(container.scrollTop > 1)
- }
-
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) {
- observer.observe(scrollContentRef.current)
- }
-
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [isCollapsed])
-
return (
<>
- {/* Back button */}
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
-
- {/* The 16px slot every settings row gives its icon, so Back's label starts on their baseline. */}
-
-
-
- Back
-
+ Back
+
- {/* Settings sections */}
{sectionConfig
@@ -369,7 +350,13 @@ export function SettingsSidebar({
.filter((item) => item.section === key)
.sort((left, right) => left.order - right.order),
}))
- .filter(({ items }) => items.length > 0)
+ .filter(
+ ({ key, items }) =>
+ items.length > 0 ||
+ (key === 'organization' &&
+ hostContext.hostOrganizationId &&
+ hostContext.viewer.isHostOrganizationMember)
+ )
.map(({ key, title, items: sectionItems }, index) => (
0 && SIDEBAR_SECTION_GAP_CLASS, 'shrink-0')}
>
+ {key === 'organization' &&
+ hostContext.hostOrganizationId &&
+ hostContext.viewer.isHostOrganizationMember && (
+
+ {
+ if (!useSettingsDirtyStore.getState().isDirty) return
+ event.preventDefault()
+ const organizationId = hostContext.hostOrganizationId
+ if (organizationId)
+ requestLeave(() =>
+ router.push(getOrganizationSettingsHref(organizationId, 'members'))
+ )
+ }}
+ >
+
+
+
+
+
+ )}
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
@@ -406,9 +424,12 @@ export function SettingsSidebar({
tooltipEnabled={!showCollapsedTooltips}
/>
{isLocked && (
-
+
Max
-
+
)}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
index 0684acb4c24..03dda46288f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
@@ -42,9 +42,12 @@ vi.mock('@/hooks/use-workspace-invite-policy', () => ({
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
useWorkspaceHostContext: () => null,
}))
-vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
- SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
-}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip',
+ () => ({
+ SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
+ })
+)
vi.mock('@/components/icons', () => ({
SlackIcon: ({ className }: { className?: string }) =>
,
}))
@@ -63,6 +66,7 @@ async function renderFooter(
root.render(
`/workspace/workspace-1/settings/${section}`}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
index 612a927149b..44b135b4c92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
@@ -27,11 +27,11 @@ import { getDesktopUpdates } from '@/lib/desktop'
import { getUserColor } from '@/lib/workspaces/colors'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
import {
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useUserProfile } from '@/hooks/queries/user-profile'
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
import { useWorkspaceInvitePolicy } from '@/hooks/use-workspace-invite-policy'
@@ -88,6 +88,12 @@ function DesktopUpdateIcon({ className }: { className?: string }) {
interface SidebarFooterProps {
workspaceId: string
+ /**
+ * True while the scroll region above still hides rows beyond its bottom edge —
+ * the same test the divider under the pinned nav applies at the top. The bar's
+ * top rule is drawn only then, so a list that fits meets the footer with no line.
+ */
+ showDivider: boolean
isCollapsed: boolean
showCollapsedTooltips: boolean
getSettingsHref: (section: SettingsSection) => string
@@ -122,6 +128,7 @@ interface SidebarFooterProps {
*/
export function SidebarFooter({
workspaceId,
+ showDivider,
isCollapsed,
showCollapsedTooltips,
getSettingsHref,
@@ -346,7 +353,8 @@ export function SidebarFooter({
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
index 93f9e4dc7d0..83e1718ed5d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
@@ -1,2 +1,2 @@
export type { SidebarNavItemData } from './sidebar-nav-chip'
-export { SidebarNavChip } from './sidebar-nav-chip'
+export { isNavItemActive, SidebarNavChip } from './sidebar-nav-chip'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
index f159cd0b6fe..d52b32106af 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
@@ -14,6 +14,17 @@ export interface SidebarNavItemData {
additionalActivePaths?: string[]
}
+/**
+ * Whether `pathname` matches `item.href` or any of its `additionalActivePaths` at a
+ * segment boundary, so `/foo` never lights up for `/foo-bar`.
+ */
+export function isNavItemActive(item: SidebarNavItemData, pathname: string | null): boolean {
+ if (!pathname) return false
+ const matches = (p: string) => pathname === p || pathname.startsWith(`${p}/`)
+ if (item.href && matches(item.href)) return true
+ return item.additionalActivePaths?.some(matches) ?? false
+}
+
interface SidebarNavChipProps extends React.HTMLAttributes
{
item: SidebarNavItemData
active: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
index 0516ff98ce7..bfa2112bb31 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
@@ -48,14 +48,8 @@ export function SidebarSection({
children,
}: SidebarSectionProps) {
const [expanded, setExpanded] = useState(true)
- /**
- * Collapse animations are enabled only after the first user toggle, so sections
- * render at full height on mount instead of replaying the open animation.
- */
- const [animationsEnabled, setAnimationsEnabled] = useState(false)
const handleToggle = () => {
- setAnimationsEnabled(true)
setExpanded((prev) => !prev)
}
@@ -97,8 +91,10 @@ export function SidebarSection({
{/* Carries the gutter the row gave up so the toggle can reach the rail's edge. */}
{action ? {action}
: null}
+ {/* `animate-none!`: the disclosure opens and closes in one frame, like every
+ other change of the rail's shape. */}
-
+
{/* The header gap pads an inner wrapper rather than the animated element:
`collapsible-up`/`-down` interpolate height alone, so a margin here would
hold its full 6px for the whole close and then vanish on unmount, snapping
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts
new file mode 100644
index 00000000000..368cc3ad539
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts
@@ -0,0 +1 @@
+export { SidebarTooltip } from './sidebar-tooltip'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx
new file mode 100644
index 00000000000..2a9774ac101
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { Tooltip } from '@sim/emcn'
+
+interface SidebarTooltipProps {
+ children: React.ReactElement
+ label: string
+ /** Renders the bare child when false, so a row can opt out without swapping element trees. */
+ enabled: boolean
+ side?: 'right' | 'bottom'
+ shortcut?: string
+}
+
+/**
+ * Tooltip for a sidebar control, shown while the rail is collapsed (the label is
+ * hidden) or on the header's icon-only chips. Returns `children` untouched when
+ * disabled so the wrapped element keeps its identity across the toggle.
+ */
+export function SidebarTooltip({
+ children,
+ label,
+ enabled,
+ side = 'right',
+ shortcut,
+}: SidebarTooltipProps) {
+ if (!enabled) return children
+ return (
+
+ {children}
+
+ {shortcut ? {label} : {label}
}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
index 2f81336b20d..8c6c9e7476a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
@@ -47,5 +47,11 @@ export function StatusNotice({ preview = false }: StatusNoticeProps) {
return null
}
- return
+ /* The gutter lives here rather than on the sidebar's slot: a slot padded for a
+ notice that renders nothing would hold an empty band above the footer. */
+ return (
+
+
+
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx
index 4282d30611a..4accdb23706 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx
@@ -557,7 +557,7 @@ export const WorkflowList = memo(function WorkflowList({
{
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx
index 801d13ca3e9..bcef7009e6a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx
@@ -5,7 +5,10 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockNavigateToSettings } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn() }))
+const { mockNavigateToSettings, mockWorkspacePermissions } = vi.hoisted(() => ({
+ mockNavigateToSettings: vi.fn(),
+ mockWorkspacePermissions: { canAdmin: true, canEdit: true, canRead: true },
+}))
const onWorkspaceSwitch = vi.fn()
@@ -21,7 +24,7 @@ vi.mock('@/hooks/use-permission-config', () => ({
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
useWorkspacePermissionsContext: () => ({
- userPermissions: { canAdmin: true, canEdit: true, canRead: true },
+ userPermissions: mockWorkspacePermissions,
}),
}))
vi.mock('@/hooks/queries/invitations', () => ({ invitationKeys: { all: ['invitations'] } }))
@@ -147,6 +150,7 @@ function typeInto(input: HTMLInputElement, value: string) {
beforeEach(() => {
vi.clearAllMocks()
+ Object.assign(mockWorkspacePermissions, { canAdmin: true, canEdit: true, canRead: true })
// jsdom implements neither; the component scrolls the active row into view.
Element.prototype.scrollIntoView = vi.fn()
})
@@ -157,6 +161,23 @@ afterEach(() => {
})
describe('WorkspaceHeader workspace switcher highlight', () => {
+ it.each([
+ { role: 'viewer', canAdmin: false, canEdit: false },
+ { role: 'editor', canAdmin: false, canEdit: true },
+ { role: 'admin', canAdmin: true, canEdit: true },
+ ])(
+ 'only offers workspace invitations to admins, including for $role',
+ ({ canAdmin, canEdit }) => {
+ Object.assign(mockWorkspacePermissions, { canAdmin, canEdit })
+ render()
+ const invite = [...document.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === 'Invite teammates'
+ )
+ expect(Boolean(invite)).toBe(canAdmin)
+ expect(container.querySelector('button[aria-label="Switch workspace"]')).not.toBeDisabled()
+ }
+ )
+
it('shows the route workspace identity while the switcher list is unavailable', () => {
render({
activeWorkspace: { name: 'Brightwave' },
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index e69b89f8653..f6d35471dfb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -18,14 +18,19 @@ import {
Plus,
Send,
Skeleton,
+ scrollFadeAttributes,
+ scrollFadeClass,
Tooltip,
toast,
+ useScrollEdges,
} from '@sim/emcn'
import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -42,7 +47,6 @@ import {
} from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
-import { SIDEBAR_WIDTH } from '@/stores/constants'
const logger = createLogger('WorkspaceHeader')
@@ -51,22 +55,15 @@ const logger = createLogger('WorkspaceHeader')
* list viewport to exactly this many rows — so the sixth workspace is the one that
* both fills the viewport and brings in search.
*
- * The viewport's `max-h-[190px]` is derived from it: 6 rows at `chipGeometryClass`'s
- * 30px plus the 2px `gap-0.5` between them (6 * 30 + 5 * 2). Tailwind arbitrary
- * values must be statically analyzable, so the arithmetic cannot live in the class —
- * change the two together.
+ * The viewport's `max-h-[200px]` is derived from it: 6 rows at `chipGeometryClass`'s
+ * 30px plus the 2px `gap-0.5` between them (6 * 30 + 5 * 2), plus the list's own
+ * `pt-1.5 pb-1` (6 + 4) — the gaps to the search field and the rule, carried as
+ * the scroll box's padding so rows scroll through them under the edge fade.
+ * Tailwind arbitrary values must be statically analyzable, so the arithmetic
+ * cannot live in the class — change them together.
*/
const WORKSPACE_SEARCH_THRESHOLD = 6
-/**
- * Derives the single-letter avatar initial for a workspace, ignoring the word
- * "workspace" in the name (e.g. "Acme Workspace" → "A").
- */
-function getWorkspaceInitial(name: string | undefined): string {
- const stripped = (name ?? '').replace(/workspace/gi, '').trim()
- return (stripped[0] || name?.[0] || 'W').toUpperCase()
-}
-
interface DisabledReasonTooltipProps {
reason: string | null
children: ReactElement
@@ -193,6 +190,13 @@ function WorkspaceHeaderImpl({
const renameInputRef = useRef
(null)
const searchInputRef = useRef(null)
const workspaceListRef = useRef(null)
+ /**
+ * Held in state as well as the ref: the list lives in the menu's portal, which
+ * Radix mounts a commit after the menu opens, so the edge hook has to be handed
+ * the element itself to pick it up.
+ */
+ const [workspaceListElement, setWorkspaceListElement] = useState(null)
+ const listEdges = useScrollEdges(workspaceListElement)
const [workspaceSearch, setWorkspaceSearch] = useState('')
const [highlightedId, setHighlightedId] = useState(null)
@@ -452,45 +456,32 @@ function WorkspaceHeaderImpl({
return (
{isMounted && isCollapsed ? (
-
-
- {activeWorkspaceFull?.logoUrl ? (
- <>
-
-
- >
- ) : activeWorkspace ? (
- <>
-
- {workspaceInitial}
-
-
- >
- ) : (
-
- )}
-
-
+ fullWidth
+ className={SIDEBAR_RAIL_CHIP_CLASS}
+ leftAdornment={
+
+ {activeWorkspace ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ }
+ />
) : isMounted && isWorkspaceReady ? (
- {
if (activeWorkspaceFull) {
handleContextMenu(e, activeWorkspaceFull)
}
}}
+ leftAdornment={
+
+ }
+ rightAdornment={activeWorkspace?.name ? : undefined}
>
- {activeWorkspaceFull ? (
- activeWorkspaceFull.logoUrl ? (
-
- ) : (
-
- {workspaceInitial}
-
- )
- ) : (
-
- )}
- {!isCollapsed && activeWorkspace?.name && (
- <>
-
-
- >
- )}
-
+ {activeWorkspace?.name}
+
e.preventDefault()}
+ className='flex max-h-[var(--radix-dropdown-menu-content-available-height,400px)] w-64 max-w-[calc(100vw-24px)] flex-col overflow-y-auto'
>
{isWorkspacesLoading ? (
@@ -617,12 +581,20 @@ function WorkspaceHeaderImpl({
if (target) onWorkspaceSwitch(target)
}
}}
- className='mb-1.5'
/>
)}
+ {/** The list owns the gap below search, when shown, and above the separator. */}
{
+ workspaceListRef.current = node
+ setWorkspaceListElement(node)
+ }}
+ className={cn(
+ scrollFadeClass,
+ '-mx-1.5 flex max-h-[200px] flex-col gap-0.5 overflow-y-auto px-1.5 pb-1',
+ showSearch && 'pt-1.5'
+ )}
+ {...scrollFadeAttributes(listEdges)}
>
{filteredWorkspaces.length === 0 && workspaceSearch && (
@@ -657,22 +629,11 @@ function WorkspaceHeaderImpl({
>
{editingWorkspaceId === workspace.id ? (
- {workspace.logoUrl ? (
-
- ) : (
-
- {initial}
-
- )}
+
{
renameInputRef.current = el
@@ -749,22 +710,11 @@ function WorkspaceHeaderImpl({
}}
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
- {workspace.logoUrl ? (
-
- ) : (
-
- {initial}
-
- )}
+
-
+
@@ -835,23 +785,25 @@ function WorkspaceHeaderImpl({
New workspace
-
- {
- setIsWorkspaceMenuOpen(false)
- if (isInvitationsDisabled) {
- if (billingEnabled) navigateToSettings({ section: 'billing' })
- return
- }
- setIsInviteModalOpen(true)
- }}
- fullWidth
- className='select-none'
- >
- Invite teammates
-
-
+ {userPermissions.canAdmin && (
+
+ {
+ setIsWorkspaceMenuOpen(false)
+ if (isInvitationsDisabled) {
+ if (billingEnabled) navigateToSettings({ section: 'billing' })
+ return
+ }
+ setIsInviteModalOpen(true)
+ }}
+ fullWidth
+ className='select-none'
+ >
+ Invite teammates
+
+
+ )}
{
setIsWorkspaceMenuOpen(false)
@@ -873,19 +825,12 @@ function WorkspaceHeaderImpl({
className={cn(chipGeometryClass, isCollapsed ? 'flex' : 'inline-flex min-w-0 max-w-full')}
disabled
>
- {activeWorkspaceFull?.logoUrl ? (
-
- ) : activeWorkspace ? (
-
- {workspaceInitial}
-
) : (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
index dc682c71374..3226942be37 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
@@ -20,12 +20,14 @@ export const SIDEBAR_SECTION_GAP_CLASS = 'mt-4'
export const SIDEBAR_ITEM_GAP_CLASS = 'gap-[1px]'
/**
- * Halves of {@link SIDEBAR_SECTION_GAP_CLASS} straddling the scroll region's
- * divider: the pinned block above carries the top half, the scroll region below
- * carries the bottom half. Split this way the divider sits centered in a gap that
- * reads as one section gap, so the first section header is spaced from the block
- * above it exactly like every other section boundary. Keep both in step with the
- * section gap.
+ * Halves of {@link SIDEBAR_SECTION_GAP_CLASS} straddling a divider: the block
+ * above carries the top half, the block below carries the bottom half. Split this
+ * way the divider sits centered in a gap that reads as one section gap, so the
+ * first section header is spaced from the pinned nav exactly like every other
+ * section boundary. The scroll region carries BOTH — the bottom half under the
+ * nav's divider and the top half above the footer's — as its own padding, so rows
+ * scroll through the gap beneath the edge fade rather than stopping short of the
+ * rule. Keep both in step with the section gap.
*/
export const SIDEBAR_DIVIDER_PAD_ABOVE_CLASS = 'pb-2'
export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
@@ -43,20 +45,9 @@ export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
* (rail midline 25.5 vs glyph column 24), which produced either a
* left-biased rail or a drift on toggle; keep the rail width and this chip
* width commensurate (rail = chip + 2 × gutter) if either ever changes.
- * Collapsing, the width tweens down to 32px on the 175ms curve the rail
- * closes on; expanding targets `auto` (not interpolable), so the chip snaps
- * to the still-narrow rail's width and stretch-tracks it open. The duration
- * is `!important` because the aside zeroes chip transition durations
- * (`[&_.group.cursor-pointer]:duration-0`) for instant hover fills — colors
- * are excluded from the property list here, so hover fills keep snapping.
+ * The width applies in one frame, in step with the rail itself.
*/
-export const SIDEBAR_RAIL_CHIP_CLASS = [
- 'transition-[width]',
- '![transition-duration:175ms]',
- '[transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)]',
- 'motion-reduce:transition-none!',
- 'group-data-[collapsed]/rail:w-[32px]',
-].join(' ')
+export const SIDEBAR_RAIL_CHIP_CLASS = 'group-data-[collapsed]/rail:w-[32px]'
/**
* Nested-selector variants for cmdk-based surfaces (e.g. the search modal).
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
index c7f7cdec75b..a444efe6a8b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { SIDEBAR_WIDTH } from '@/stores/constants'
-import { useSidebarStore } from '@/stores/sidebar/store'
+import { getMaxSidebarWidth, useSidebarStore } from '@/stores/sidebar/store'
/**
* Handles sidebar drag-resize with zero React renders during the drag.
@@ -8,10 +8,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* Architecture (confirmed industry best-practice for resize handles):
*
* pointerdown → capture the pointer on the handle (so move/up keep arriving
- * even when the cursor leaves the window or crosses an iframe),
- * add `is-resizing` class directly to the DOM (no React
- * round-trip, so the CSS width transition is suppressed from the
- * very first frame)
+ * even when the cursor leaves the window or crosses an iframe)
* pointermove → write --sidebar-width to `.sidebar-shell-outer` (the element
* that sizes the rail) inside a requestAnimationFrame callback.
* Scoping the variable to that subtree keeps the style recalc
@@ -23,9 +20,8 @@ import { useSidebarStore } from '@/stores/sidebar/store'
*
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
* interrupted gesture (release outside the window, alt-tab, context menu, the OS
- * stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
- * stuck — which would otherwise freeze the sidebar at a tiny width with the
- * collapse transition permanently disabled. A single-flight guard prevents
+ * stealing focus) can never leave the body cursor and selection lock stuck. A
+ * single-flight guard prevents
* stacking listeners across rapid presses, and unmounting mid-drag finalizes it
* the same way a release does — persisting the last width and dropping the
* scoped override — which matters because `.sidebar-shell-outer` lives in the
@@ -42,11 +38,8 @@ export function useSidebarResize() {
const handle = e.currentTarget
const pointerId = e.pointerId
- const sidebar = document.querySelector('.sidebar-container')
const shell = document.querySelector('.sidebar-shell-outer')
const target = shell ?? document.documentElement
- sidebar?.classList.add('is-resizing')
- document.documentElement.classList.add('sidebar-resizing')
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
handle.setPointerCapture?.(pointerId)
@@ -55,7 +48,7 @@ export function useSidebarResize() {
let lastWidth: number | null = null
const onPointerMove = (ev: PointerEvent) => {
- const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
+ const max = getMaxSidebarWidth(window.innerWidth)
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
lastWidth = clamped
if (rafId !== null) cancelAnimationFrame(rafId)
@@ -70,8 +63,6 @@ export function useSidebarResize() {
cancelAnimationFrame(rafId)
rafId = null
}
- sidebar?.classList.remove('is-resizing')
- document.documentElement.classList.remove('sidebar-resizing')
document.body.style.cursor = ''
document.body.style.userSelect = ''
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
index 0deca94ef97..8a821c0243a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
@@ -164,7 +164,7 @@ export function useWorkspaceManagement({
const updateWorkspace = useCallback(
async (
workspaceId: string,
- updates: { name?: string; logoUrl?: string | null; color?: string }
+ updates: { name?: string; logoUrl?: string | null }
): Promise => {
try {
await updateWorkspaceMutation.mutateAsync({ workspaceId, ...updates })
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index 530e5a3dc6d..95ed1993cd6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -16,8 +16,11 @@ import {
Loader,
OverflowText,
Skeleton,
+ scrollFadeAttributes,
+ scrollFadeClass,
Tooltip,
Upload,
+ useScrollEdges,
} from '@sim/emcn'
import {
Database,
@@ -42,7 +45,9 @@ import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
+import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
import { captureEvent } from '@/lib/posthog/client'
+import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -57,6 +62,7 @@ import {
CollapsedWorkflowFlyoutItem,
FilesRailFlyout,
HelpModal,
+ isNavItemActive,
NavItemContextMenu,
SearchModal,
SettingsSidebar,
@@ -64,6 +70,7 @@ import {
SidebarNavChip,
type SidebarNavItemData,
SidebarSection,
+ SidebarTooltip,
StatusNotice,
TablesRailFlyout,
WorkflowList,
@@ -164,33 +171,6 @@ const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
minute: '2-digit',
})
-const SLACK_COMMUNITY_URL =
- 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA'
-
-export function SidebarTooltip({
- children,
- label,
- enabled,
- side = 'right',
- shortcut,
-}: {
- children: React.ReactElement
- label: string
- enabled: boolean
- side?: 'right' | 'bottom'
- shortcut?: string
-}) {
- if (!enabled) return children
- return (
-
- {children}
-
- {shortcut ? {label} : {label}
}
-
-
- )
-}
-
/** Stands in for a chip row while a list loads, so it carries no margin either. */
function SidebarItemSkeleton() {
return (
@@ -326,17 +306,6 @@ const SidebarChatItem = memo(function SidebarChatItem({
)
})
-/**
- * Returns true when the current pathname matches `item.href` or any
- * `additionalActivePaths` at a segment boundary (avoids `/foo` matching `/foo-bar`).
- */
-function isNavItemActive(item: SidebarNavItemData, pathname: string | null): boolean {
- if (!pathname) return false
- const matches = (p: string) => pathname === p || pathname.startsWith(`${p}/`)
- if (item.href && matches(item.href)) return true
- return item.additionalActivePaths?.some(matches) ?? false
-}
-
const SidebarNavItem = memo(function SidebarNavItem({
item,
active,
@@ -385,30 +354,14 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
*
* This ensures server and client render identical HTML, preventing hydration errors.
*
+ * Collapse and peek state come from the hosting chrome through
+ * {@link useSidebarChrome}; the peek card always renders the expanded layout,
+ * whatever the rail's state.
+ *
* @returns Sidebar with workflows panel
*/
-interface SidebarProps {
- /**
- * Authoritative collapse state, derived once in {@link WorkspaceChrome} from the
- * `sidebar_collapsed` cookie (server prop → store after hydration) and passed in
- * so the rail's structure, labels, and width all read a single source.
- */
- isCollapsed: boolean
- /**
- * True while the sidebar is rendered as the desktop hover-peek card. The card shows
- * the expanded layout even though the rail is collapsed, so this overrides
- * {@link SidebarProps.isCollapsed} below — and separately suppresses the chrome the
- * card already provides: it sits below the traffic-light lane, and drag-resize would
- * fight the card's width.
- */
- isPeeking?: boolean
-}
-
-export const Sidebar = memo(function Sidebar({
- isCollapsed: isCollapsedProp,
- isPeeking = false,
-}: SidebarProps) {
- /** The peek card always renders the expanded layout, whatever the rail's state. */
+export const Sidebar = memo(function Sidebar() {
+ const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome()
const isCollapsed = isCollapsedProp && !isPeeking
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -772,7 +725,6 @@ export const Sidebar = memo(function Sidebar({
href: `/workspace/${workspace.id}/w`,
isCurrent: workspace.id === workspaceId,
logoUrl: workspace.logoUrl,
- color: workspace.color,
})),
[workspaces, workspaceId]
)
@@ -1028,29 +980,10 @@ export const Sidebar = memo(function Sidebar({
[workflowFlyoutRename, workflowsHover]
)
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
-
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
-
- const updateScrollState = () => {
- setHasOverflowTop(container.scrollTop > 1)
- }
-
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) {
- observer.observe(scrollContentRef.current)
- }
-
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [])
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false
@@ -1251,7 +1184,7 @@ export const Sidebar = memo(function Sidebar({
const handleOpenHelpFromMenu = () => setIsHelpModalOpen(true)
const handleOpenDocs = () => {
- window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
+ window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
}
@@ -1371,7 +1304,7 @@ export const Sidebar = memo(function Sidebar({
)}
) : (
<>
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
{topNavItems.map((item) => (
@@ -1495,9 +1427,11 @@ export const Sidebar = memo(function Sidebar({
ref={isCollapsed ? undefined : scrollContainerRef}
className={cn(
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
- 'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden border-t transition-colors duration-150',
- !hasOverflowTop && 'border-transparent'
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ scrollFadeClass,
+ 'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden'
)}
+ {...scrollFadeAttributes(scrollEdges)}
>
{chatEnabled && (
@@ -1824,13 +1758,12 @@ export const Sidebar = memo(function Sidebar({
{(hosted || isStatusNoticePreviewEnabled) && !isCollapsed ? (
-
-
-
+
) : null}
getSettingsHref({ section })}
diff --git a/apps/sim/app/workspace/page.test.tsx b/apps/sim/app/workspace/page.test.tsx
new file mode 100644
index 00000000000..4ef877bcd5f
--- /dev/null
+++ b/apps/sim/app/workspace/page.test.tsx
@@ -0,0 +1,95 @@
+/** @vitest-environment jsdom */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ replace: vi.fn(),
+ workspaces: vi.fn(),
+ recentWorkspace: vi.fn(),
+ request: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mocks.replace }) }))
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { id: 'viewer' } }, isPending: false }),
+}))
+vi.mock('@/lib/auth/stale-session-recovery', () => ({ recoverFromStaleSession: vi.fn() }))
+vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request }))
+vi.mock('@/lib/core/utils/browser-storage', () => ({
+ WorkspaceRecencyStorage: { getMostRecent: mocks.recentWorkspace },
+}))
+vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null }))
+vi.mock('@/hooks/queries/workspace', () => ({ useWorkspacesWithMetadata: mocks.workspaces }))
+
+import WorkspacePage from '@/app/workspace/page'
+
+describe('workspace settings destination', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ window.history.replaceState(null, '', '/workspace?redirect=settings')
+ mocks.recentWorkspace.mockReturnValue('workspace-2')
+ mocks.workspaces.mockReturnValue({
+ data: {
+ workspaces: [{ id: 'workspace-1' }, { id: 'workspace-2' }],
+ lastActiveWorkspaceId: 'workspace-1',
+ },
+ isLoading: false,
+ })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ })
+
+ it('opens full settings in the most recent accessible workspace', async () => {
+ await act(async () => root.render( ))
+ expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-2/settings/general')
+ expect(mocks.request).not.toHaveBeenCalled()
+ })
+
+ it('uses an accessible workspace when the locally remembered workspace is unavailable', async () => {
+ mocks.recentWorkspace.mockReturnValue('removed-workspace')
+ await act(async () => root.render( ))
+ expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-1/settings/general')
+ })
+
+ it('preserves the settings destination after creating a permitted first workspace', async () => {
+ mocks.workspaces.mockReturnValue({
+ data: { workspaces: [], creationPolicy: { canCreate: true } },
+ isLoading: false,
+ })
+ mocks.request.mockResolvedValue({ workspace: { id: 'new-workspace' } })
+ await act(async () => root.render( ))
+ expect(mocks.replace).toHaveBeenCalledWith('/workspace/new-workspace/settings/general')
+ })
+
+ it('keeps the access-denied state when workspace creation is blocked', async () => {
+ mocks.workspaces.mockReturnValue({
+ data: {
+ workspaces: [],
+ creationPolicy: { canCreate: false, workspaceMode: 'organization' },
+ },
+ isLoading: false,
+ })
+ await act(async () => root.render( ))
+ expect(container.textContent).toContain('No workspace access yet')
+ expect(mocks.replace).not.toHaveBeenCalled()
+ expect(mocks.request).not.toHaveBeenCalled()
+ })
+
+ it('preserves normal workspace navigation without a settings destination', async () => {
+ window.history.replaceState(null, '', '/workspace')
+ await act(async () => root.render( ))
+ expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-2')
+ })
+})
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx
index 72ed6873e6b..e53c894085a 100644
--- a/apps/sim/app/workspace/page.tsx
+++ b/apps/sim/app/workspace/page.tsx
@@ -125,14 +125,13 @@ export default function WorkspacePage() {
const redirectTarget = urlParams.get('redirect')
const rawReason = urlParams.get(UPGRADE_REASON_PARAM)
- // `?redirect=upgrade` is how a caller that cannot know a workspace id — a
- // self-hosted deployment, an email — reaches the plan picker. It has to
- // survive workspace creation too: a first-time visitor has no workspace to
- // resolve, and dropping the intent lands them on home with no explanation.
+ /** Preserve settings and upgrade destinations when selecting or creating a workspace. */
const destinationFor = (id: string) =>
redirectTarget === 'upgrade'
? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined)
- : `/workspace/${id}`
+ : redirectTarget === 'settings'
+ ? `/workspace/${id}/settings/general`
+ : `/workspace/${id}`
const { workspaces, lastActiveWorkspaceId, creationPolicy } = data
diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts
index 98d17859594..2d6816d525a 100644
--- a/apps/sim/background/cleanup-soft-deletes.test.ts
+++ b/apps/sim/background/cleanup-soft-deletes.test.ts
@@ -5,6 +5,7 @@
import {
dbChainMock,
dbChainMockFns,
+ hasMockCondition,
queueTableRows,
resetDbChainMock,
schemaMock,
@@ -14,6 +15,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockBatchDeleteByWorkspaceAndTimestamp,
mockChunkedBatchDelete,
+ mockScopedChunkedBatchDelete,
mockDecrementStorageUsageForBillingContextInTx,
mockDeleteFileMetadata,
mockDeleteFiles,
@@ -33,6 +35,7 @@ const {
mockAllocateUniqueWorkspaceFileName: vi.fn(async (_ws: string, name: string) => name),
mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })),
mockChunkedBatchDelete: vi.fn(async () => ({ deleted: 0, failed: 0 })),
+ mockScopedChunkedBatchDelete: vi.fn(async () => ({ deleted: 0, failed: 0 })),
mockDecrementStorageUsageForBillingContextInTx: vi.fn(async () => undefined),
mockDeleteFileMetadata: vi.fn(async () => true),
mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })),
@@ -48,6 +51,7 @@ const {
vi.mock('@/lib/cleanup/batch-delete', () => ({
batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp,
chunkedBatchDelete: mockChunkedBatchDelete,
+ chunkedBatchDeleteByScope: mockScopedChunkedBatchDelete,
DEFAULT_DELETE_CHUNK_SIZE: 1000,
deleteRowsById: mockDeleteRowsById,
selectRowsByIdChunks: mockSelectRowsByIdChunks,
@@ -541,3 +545,126 @@ describe('folder cleanup target', () => {
})
})
})
+
+describe('organization-owned Search retention cleanup', () => {
+ const organizationPayload = {
+ plan: 'enterprise' as const,
+ label: 'enterprise/organization/org-1',
+ workspaceIds: [],
+ organizationIds: ['org-1'],
+ retentionHours: 72,
+ }
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockIsUsingCloudStorage.mockReturnValue(true)
+ mockDeleteFiles.mockResolvedValue({ deleted: 0, failed: [] })
+ mockSelectRowsByIdChunks.mockImplementation(async (ids, query) =>
+ ids.length ? query(ids, 500) : []
+ )
+ mockScopedChunkedBatchDelete.mockResolvedValue({ deleted: 0, failed: 0 })
+ })
+
+ it('selects only organization-owned cache, private chats, and knowledge bases', async () => {
+ queueTableRows(schemaMock.workspaceFiles, [
+ {
+ id: 'org-file',
+ key: 'knowledge-base/org-key',
+ workspaceId: null,
+ context: 'knowledge-base',
+ sizeBytes: 10,
+ },
+ ])
+ queueTableRows(schemaMock.workspaceFiles, [])
+ queueTableRows(schemaMock.copilotChats, [{ id: 'org-chat' }])
+ await runCleanupSoftDeletes(organizationPayload)
+ expect(mockDeleteFiles).toHaveBeenCalledWith(['knowledge-base/org-key'], 'knowledge-base')
+ expect(mockPrepareChatCleanup).toHaveBeenCalledWith(['org-chat'], organizationPayload.label)
+ expect(mockResolveStorageBillingContext).not.toHaveBeenCalled()
+ expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
+ expect(mockBatchDeleteByWorkspaceAndTimestamp).not.toHaveBeenCalled()
+ expect(mockChunkedBatchDelete).not.toHaveBeenCalled()
+ expect(mockScopedChunkedBatchDelete).toHaveBeenCalledWith(
+ expect.objectContaining({ scopeIds: ['org-1'] })
+ )
+ for (const table of [schemaMock.workspaceFiles, schemaMock.copilotChats]) {
+ expect(
+ dbChainMockFns.where.mock.calls.some(
+ ([predicate]) =>
+ hasMockCondition(
+ predicate,
+ (item) =>
+ item.type === 'inArray' &&
+ item.column === table.organizationId &&
+ item.values.includes('org-1')
+ ) &&
+ hasMockCondition(
+ predicate,
+ (item) => item.type === 'isNull' && item.column === table.workspaceId
+ )
+ )
+ ).toBe(true)
+ }
+ const options = mockScopedChunkedBatchDelete.mock.calls[0][0] as {
+ selectChunk: (ids: string[], limit: number) => Promise
+ deleteFilter: unknown
+ }
+ await options.selectChunk(['org-1'], 100)
+ for (const predicate of [dbChainMockFns.where.mock.calls.at(-1)?.[0], options.deleteFilter]) {
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) =>
+ item.type === 'inArray' &&
+ item.column === schemaMock.knowledgeBase.organizationId &&
+ item.values.includes('org-1')
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) => item.type === 'isNull' && item.column === schemaMock.knowledgeBase.workspaceId
+ )
+ ).toBe(true)
+ }
+ })
+
+ it('keeps failed organization cache objects bound for the next cleanup attempt', async () => {
+ queueTableRows(schemaMock.workspaceFiles, [
+ {
+ id: 'org-file',
+ key: 'knowledge-base/org-key',
+ workspaceId: null,
+ context: 'knowledge-base',
+ sizeBytes: 10,
+ },
+ ])
+ queueTableRows(schemaMock.workspaceFiles, [])
+ mockDeleteFiles.mockResolvedValue({
+ deleted: 0,
+ failed: [{ key: 'knowledge-base/org-key', error: 'storage unavailable' }],
+ })
+ await runCleanupSoftDeletes(organizationPayload)
+ expect(
+ dbChainMockFns.delete.mock.calls.some(([table]) => table === schemaMock.workspaceFiles)
+ ).toBe(false)
+ })
+
+ it('sweeps orphaned organization cache bindings without a workspace payer', async () => {
+ queueTableRows(schemaMock.workspaceFiles, [])
+ queueTableRows(schemaMock.workspaceFiles, [{ key: 'knowledge-base/orphan' }])
+ queueTableRows(schemaMock.workspaceFiles, [])
+ await runCleanupSoftDeletes(organizationPayload)
+ expect(mockDeleteFiles).toHaveBeenCalledWith(['knowledge-base/orphan'], 'knowledge-base')
+ expect(mockDeleteFileMetadata).toHaveBeenCalledWith('knowledge-base/orphan')
+ expect(mockResolveStorageBillingContext).not.toHaveBeenCalled()
+ })
+
+ it('rejects an ambiguous batch before reading or deleting resources', async () => {
+ await expect(
+ runCleanupSoftDeletes({ ...organizationPayload, workspaceIds: ['ws-1'] })
+ ).rejects.toThrow('not both')
+ expect(dbChainMockFns.select).not.toHaveBeenCalled()
+ expect(mockDeleteFiles).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts
index 7fda5bcdaf8..2f48f1d51d7 100644
--- a/apps/sim/background/cleanup-soft-deletes.ts
+++ b/apps/sim/background/cleanup-soft-deletes.ts
@@ -25,10 +25,16 @@ import {
import {
batchDeleteByWorkspaceAndTimestamp,
chunkedBatchDelete,
+ chunkedBatchDeleteByScope,
DEFAULT_DELETE_CHUNK_SIZE,
selectRowsByIdChunks,
} from '@/lib/cleanup/batch-delete'
import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup'
+import {
+ type CleanupOwnerScope,
+ cleanupOwnerCondition,
+ resolveCleanupOwnerScope,
+} from '@/lib/cleanup/resource-scope'
import { deduplicateFolderName } from '@/lib/folders/naming'
import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
import type { StorageContext } from '@/lib/uploads'
@@ -55,7 +61,7 @@ const KB_ORPHAN_BINDING_TOTAL_LIMIT = 5_000
* never mistaken for an abandoned one.
*/
const KB_ORPHAN_BINDING_GRACE_HOURS = 7 * 24
-const KB_ORPHAN_BINDING_WORKSPACE_CHUNK = 50
+const KB_ORPHAN_BINDING_OWNER_CHUNK_SIZE = 50
const KB_RETENTION_BATCH_SIZE = 100
const KB_DOCUMENT_DELETE_BATCH_SIZE = 500
const KB_DOCUMENT_DELETE_MAX_BATCHES = 50
@@ -86,11 +92,11 @@ interface WorkspaceFileStorageCleanupResult {
* cleanup cannot drift from the row-level cleanup.
*/
async function selectExpiredWorkspaceFiles(
- workspaceIds: string[],
+ scope: CleanupOwnerScope,
retentionDate: Date
): Promise {
const [legacyRows, multiContextRows] = await Promise.all([
- selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
+ selectRowsByIdChunks(scope.kind === 'workspace' ? scope.ids : [], (chunkIds, chunkLimit) =>
cleanupDb
.select({
id: workspaceFile.id,
@@ -107,7 +113,7 @@ async function selectExpiredWorkspaceFiles(
)
.limit(chunkLimit)
),
- selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
+ selectRowsByIdChunks(scope.ids, (chunkIds, chunkLimit) =>
cleanupDb
.select({
id: workspaceFiles.id,
@@ -119,7 +125,10 @@ async function selectExpiredWorkspaceFiles(
.from(workspaceFiles)
.where(
and(
- inArray(workspaceFiles.workspaceId, chunkIds),
+ cleanupOwnerCondition(workspaceFiles, scope, chunkIds),
+ scope.kind === 'organization'
+ ? eq(workspaceFiles.context, 'knowledge-base')
+ : undefined,
isNotNull(workspaceFiles.deletedAt),
lt(workspaceFiles.deletedAt, retentionDate)
)
@@ -382,23 +391,22 @@ async function hardDeleteKnowledgeBaseDocuments(
}
async function cleanupExpiredKnowledgeBases(
- workspaceIds: string[],
+ scope: CleanupOwnerScope,
retentionDate: Date,
label: string
) {
- return chunkedBatchDelete({
+ const options = {
tableDef: knowledgeBase,
- workspaceIds,
tableName: `${label}/knowledgeBase`,
batchSize: KB_RETENTION_BATCH_SIZE,
dbClient: cleanupDb,
- selectChunk: (chunkIds, limit) =>
+ selectChunk: (chunkIds: string[], limit: number) =>
cleanupDb
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(
and(
- inArray(knowledgeBase.workspaceId, chunkIds),
+ cleanupOwnerCondition(knowledgeBase, scope, chunkIds),
isNotNull(knowledgeBase.deletedAt),
lt(knowledgeBase.deletedAt, retentionDate)
)
@@ -414,15 +422,19 @@ async function cleanupExpiredKnowledgeBases(
* select → onBatch → delete.
*/
deleteFilter: and(
+ cleanupOwnerCondition(knowledgeBase, scope),
isNotNull(knowledgeBase.deletedAt),
lt(knowledgeBase.deletedAt, retentionDate)
),
- onBatch: (rows) =>
+ onBatch: (rows: { id: string }[]) =>
hardDeleteKnowledgeBaseDocuments(
rows.map(({ id }) => id),
label
),
- })
+ }
+ return scope.kind === 'workspace'
+ ? chunkedBatchDelete({ ...options, workspaceIds: scope.ids })
+ : chunkedBatchDeleteByScope({ ...options, scopeIds: scope.ids })
}
/**
@@ -708,15 +720,15 @@ const CLEANUP_TARGETS = [
* grace window.
*/
async function cleanupOrphanedKnowledgeBaseBindings(
- workspaceIds: string[],
+ scope: CleanupOwnerScope,
label: string
): Promise<{ total: number; deleted: number; failed: number }> {
const stats = { total: 0, deleted: 0, failed: 0 }
- if (workspaceIds.length === 0) return stats
+ if (scope.ids.length === 0) return stats
const orphanCutoff = new Date(Date.now() - KB_ORPHAN_BINDING_GRACE_HOURS * 60 * 60 * 1000)
- for (const chunkIds of chunkArray(workspaceIds, KB_ORPHAN_BINDING_WORKSPACE_CHUNK)) {
+ for (const chunkIds of chunkArray(scope.ids, KB_ORPHAN_BINDING_OWNER_CHUNK_SIZE)) {
let attempted = 0
while (attempted < KB_ORPHAN_BINDING_TOTAL_LIMIT) {
const limit = Math.min(
@@ -728,7 +740,7 @@ async function cleanupOrphanedKnowledgeBaseBindings(
.from(workspaceFiles)
.where(
and(
- inArray(workspaceFiles.workspaceId, chunkIds),
+ cleanupOwnerCondition(workspaceFiles, scope, chunkIds),
eq(workspaceFiles.context, 'knowledge-base'),
isNull(workspaceFiles.deletedAt),
lt(workspaceFiles.uploadedAt, orphanCutoff),
@@ -784,15 +796,16 @@ async function cleanupOrphanedKnowledgeBaseBindings(
export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise {
const startTime = Date.now()
const { workspaceIds, retentionHours, label } = payload
+ const scope = resolveCleanupOwnerScope(payload)
- if (workspaceIds.length === 0) {
- logger.info(`[${label}] No workspaces to process`)
+ if (scope.ids.length === 0) {
+ logger.info(`[${label}] No resource owners to process`)
return
}
const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000)
logger.info(
- `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}`
+ `[${label}] Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}`
)
// Select workflows + files + soft-deleted chats once. These sets drive BOTH
@@ -813,14 +826,14 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
)
.limit(chunkLimit)
),
- selectExpiredWorkspaceFiles(workspaceIds, retentionDate),
- selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
+ selectExpiredWorkspaceFiles(scope, retentionDate),
+ selectRowsByIdChunks(scope.ids, (chunkIds, chunkLimit) =>
cleanupDb
.select({ id: copilotChats.id })
.from(copilotChats)
.where(
and(
- inArray(copilotChats.workspaceId, chunkIds),
+ cleanupOwnerCondition(copilotChats, scope, chunkIds),
isNotNull(copilotChats.deletedAt),
lt(copilotChats.deletedAt, retentionDate)
)
@@ -889,6 +902,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
.where(
and(
inArray(copilotChats.id, batch),
+ cleanupOwnerCondition(copilotChats, scope),
isNotNull(copilotChats.deletedAt),
lt(copilotChats.deletedAt, retentionDate)
)
@@ -921,10 +935,10 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
)
totalDeleted += unbilledFileResult.deleted
- const knowledgeBaseResult = await cleanupExpiredKnowledgeBases(workspaceIds, retentionDate, label)
+ const knowledgeBaseResult = await cleanupExpiredKnowledgeBases(scope, retentionDate, label)
totalDeleted += knowledgeBaseResult.deleted
- for (const target of CLEANUP_TARGETS) {
+ for (const target of scope.kind === 'workspace' ? CLEANUP_TARGETS : []) {
const result = await batchDeleteByWorkspaceAndTimestamp({
tableDef: target.table,
workspaceIdCol: target.wsCol,
@@ -943,7 +957,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
totalDeleted += result.deleted
}
- const orphanBindingStats = await cleanupOrphanedKnowledgeBaseBindings(workspaceIds, label)
+ const orphanBindingStats = await cleanupOrphanedKnowledgeBaseBindings(scope, label)
logger.info(
`[${label}] Complete: ${totalDeleted} rows deleted, ${fileCleanup.filesDeleted} files cleaned, ${orphanBindingStats.deleted} orphan KB bindings cleaned`
diff --git a/apps/sim/background/cleanup-tasks.test.ts b/apps/sim/background/cleanup-tasks.test.ts
new file mode 100644
index 00000000000..05350a10f9d
--- /dev/null
+++ b/apps/sim/background/cleanup-tasks.test.ts
@@ -0,0 +1,153 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ dbChainMockFns,
+ hasMockCondition,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockBatchDelete, mockDeleteRowsById, mockPrepareChatCleanup, mockExecuteChatCleanup } =
+ vi.hoisted(() => ({
+ mockBatchDelete: vi.fn(async (_options: unknown) => ({ deleted: 0, failed: 0 })),
+ mockDeleteRowsById: vi.fn(async (..._args: unknown[]) => ({ deleted: 0, failed: 0 })),
+ mockPrepareChatCleanup: vi.fn(),
+ mockExecuteChatCleanup: vi.fn(async () => undefined),
+ }))
+
+vi.mock('@/lib/cleanup/batch-delete', () => ({
+ batchDeleteByWorkspaceAndTimestamp: mockBatchDelete,
+ deleteRowsById: mockDeleteRowsById,
+ DEFAULT_DELETE_CHUNK_SIZE: 1000,
+ selectRowsByIdChunks: async (
+ ids: string[],
+ query: (ids: string[], limit: number) => Promise
+ ) => (ids.length ? query(ids, 500) : []),
+}))
+vi.mock('@/lib/cleanup/chat-cleanup', () => ({ prepareChatCleanup: mockPrepareChatCleanup }))
+
+import { runCleanupTasks } from '@/background/cleanup-tasks'
+
+const organizationPayload = {
+ plan: 'enterprise' as const,
+ label: 'enterprise/organization/org-1',
+ workspaceIds: [],
+ organizationIds: ['org-1'],
+ retentionHours: 48,
+}
+
+describe('chat retention ownership', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-09-07T12:00:00Z'))
+ mockPrepareChatCleanup.mockResolvedValue({ execute: mockExecuteChatCleanup })
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ resetDbChainMock()
+ })
+
+ it('purges organization chats with exact owner and cutoff checks, without workspace tasks', async () => {
+ queueTableRows(schemaMock.copilotChats, [{ id: 'org-chat' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'org-chat' }])
+ await runCleanupTasks(organizationPayload)
+
+ expect(mockPrepareChatCleanup).toHaveBeenCalledWith(['org-chat'], organizationPayload.label)
+ expect(mockBatchDelete).not.toHaveBeenCalled()
+ expect(mockDeleteRowsById).not.toHaveBeenCalled()
+ expect(dbChainMockFns.from).toHaveBeenCalledTimes(1)
+ expect(dbChainMockFns.from).toHaveBeenCalledWith(schemaMock.copilotChats)
+ expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.copilotChats)
+ expect(dbChainMockFns.where).toHaveBeenCalledTimes(2)
+ for (const [predicate] of dbChainMockFns.where.mock.calls) {
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) =>
+ item.type === 'inArray' &&
+ item.column === schemaMock.copilotChats.organizationId &&
+ item.values.length === 1 &&
+ item.values[0] === 'org-1'
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) => item.type === 'isNull' && item.column === schemaMock.copilotChats.workspaceId
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) =>
+ item.type === 'lt' &&
+ item.left === schemaMock.copilotChats.updatedAt &&
+ item.right instanceof Date &&
+ item.right.toISOString() === '2026-09-05T12:00:00.000Z'
+ )
+ ).toBe(true)
+ }
+ expect(mockExecuteChatCleanup).toHaveBeenCalledOnce()
+ expect(mockPrepareChatCleanup.mock.invocationCallOrder[0]).toBeLessThan(
+ dbChainMockFns.delete.mock.invocationCallOrder[0]
+ )
+ expect(mockExecuteChatCleanup.mock.invocationCallOrder[0]).toBeGreaterThan(
+ dbChainMockFns.delete.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('keeps workspace run and inbox retention on the workspace path', async () => {
+ queueTableRows(schemaMock.copilotChats, [{ id: 'workspace-chat' }])
+ queueTableRows(schemaMock.copilotRuns, [])
+ await runCleanupTasks({ ...organizationPayload, organizationIds: [], workspaceIds: ['ws-1'] })
+ expect(mockBatchDelete).toHaveBeenCalledTimes(2)
+ expect(mockBatchDelete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableDef: schemaMock.copilotRuns,
+ workspaceIds: ['ws-1'],
+ })
+ )
+ expect(mockBatchDelete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableDef: schemaMock.mothershipInboxTask,
+ workspaceIds: ['ws-1'],
+ })
+ )
+ const [predicate] = dbChainMockFns.where.mock.calls[0]
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) =>
+ item.type === 'inArray' &&
+ item.column === schemaMock.copilotChats.workspaceId &&
+ item.values[0] === 'ws-1'
+ )
+ ).toBe(true)
+ expect(
+ hasMockCondition(
+ predicate,
+ (item) => item.type === 'isNull' && item.column === schemaMock.copilotChats.organizationId
+ )
+ ).toBe(true)
+ })
+
+ it('rejects mixed ownership before selecting or deleting any resource', async () => {
+ await expect(
+ runCleanupTasks({ ...organizationPayload, workspaceIds: ['ws-1'] })
+ ).rejects.toThrow('Cleanup batches must name workspace or organization owners, not both')
+ expect(dbChainMockFns.select).not.toHaveBeenCalled()
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(mockPrepareChatCleanup).not.toHaveBeenCalled()
+ })
+
+ it('does no work for an empty owner batch', async () => {
+ await runCleanupTasks({ ...organizationPayload, organizationIds: [] })
+ expect(dbChainMockFns.select).not.toHaveBeenCalled()
+ expect(mockPrepareChatCleanup).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/background/cleanup-tasks.ts b/apps/sim/background/cleanup-tasks.ts
index 1d35d143498..b77ca2344ba 100644
--- a/apps/sim/background/cleanup-tasks.ts
+++ b/apps/sim/background/cleanup-tasks.ts
@@ -19,6 +19,7 @@ import {
type TableCleanupResult,
} from '@/lib/cleanup/batch-delete'
import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup'
+import { cleanupOwnerCondition, resolveCleanupOwnerScope } from '@/lib/cleanup/resource-scope'
const logger = createLogger('CleanupTasks')
@@ -75,62 +76,72 @@ async function cleanupRunChildren(
export async function runCleanupTasks(payload: CleanupJobPayload): Promise {
const startTime = Date.now()
const { workspaceIds, retentionHours, label } = payload
+ const scope = resolveCleanupOwnerScope(payload)
- if (workspaceIds.length === 0) {
- logger.info(`[${label}] No workspaces to process`)
+ if (scope.ids.length === 0) {
+ logger.info(`[${label}] No resource owners to process`)
return
}
const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000)
logger.info(
- `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}`
+ `[${label}] Processing ${scope.ids.length} ${scope.kind} owners, cutoff: ${retentionDate.toISOString()}`
)
- const doomedChats = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
+ const doomedChats = await selectRowsByIdChunks(scope.ids, (chunkIds, chunkLimit) =>
cleanupDb
.select({ id: copilotChats.id })
.from(copilotChats)
.where(
- and(inArray(copilotChats.workspaceId, chunkIds), lt(copilotChats.updatedAt, retentionDate))
+ and(
+ cleanupOwnerCondition(copilotChats, scope, chunkIds),
+ lt(copilotChats.updatedAt, retentionDate)
+ )
)
.limit(chunkLimit)
)
const doomedChatIds = doomedChats.map((c) => c.id)
- // Prepare chat cleanup (collect file keys + copilot backend call) BEFORE DB deletion
+ /** Collect external chat data before deleting the owning rows. */
const chatCleanup = await prepareChatCleanup(doomedChatIds, label)
- // Delete run children first (checkpoints, tool calls) since they reference runs
+ /** Delete run children before their parent runs. Organization chats have no workspace runs. */
const runChildResults = await cleanupRunChildren(workspaceIds, retentionDate, label)
for (const r of runChildResults) {
if (r.deleted > 0) logger.info(`[${r.table}] ${r.deleted} deleted`)
}
- // Delete copilot runs (has workspaceId directly, cascades checkpoints)
- const runsResult = await batchDeleteByWorkspaceAndTimestamp({
- tableDef: copilotRuns,
- workspaceIdCol: copilotRuns.workspaceId,
- timestampCol: copilotRuns.updatedAt,
- workspaceIds,
- retentionDate,
- tableName: `${label}/copilotRuns`,
- dbClient: cleanupDb,
- })
-
- // Delete copilot chats using the exact IDs collected above so the chat
- // cleanup (S3 + copilot backend) and the DB delete can never disagree.
- // Re-check the retention cutoff in the DELETE: a chat restored from Recently
- // Deleted mid-run gets a fresh `updatedAt`, so it survives here (and
- // chatCleanup.execute() re-checks row existence before purging its data).
- // Chat-scoped children (copilot_messages, copilot_feedback) go with the row
- // via FK cascade, so they are removed only for chats actually deleted.
+ const runsResult =
+ scope.kind === 'workspace'
+ ? await batchDeleteByWorkspaceAndTimestamp({
+ tableDef: copilotRuns,
+ workspaceIdCol: copilotRuns.workspaceId,
+ timestampCol: copilotRuns.updatedAt,
+ workspaceIds,
+ retentionDate,
+ tableName: `${label}/copilotRuns`,
+ dbClient: cleanupDb,
+ })
+ : { deleted: 0, failed: 0 }
+
+ /**
+ * Delete the selected chats only if their owner and cutoff still match.
+ * Restored chats survive; external cleanup rechecks row existence as well.
+ * Messages and feedback cascade only for chats actually deleted.
+ */
const chatsResult = { deleted: 0, failed: 0 }
for (const batch of chunkArray(doomedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) {
try {
const deleted = await cleanupDb
.delete(copilotChats)
- .where(and(inArray(copilotChats.id, batch), lt(copilotChats.updatedAt, retentionDate)))
+ .where(
+ and(
+ inArray(copilotChats.id, batch),
+ cleanupOwnerCondition(copilotChats, scope),
+ lt(copilotChats.updatedAt, retentionDate)
+ )
+ )
.returning({ id: copilotChats.id })
chatsResult.deleted += deleted.length
} catch (error) {
@@ -139,16 +150,18 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise
}
}
- // Delete mothership inbox tasks (has workspaceId directly)
- const inboxResult = await batchDeleteByWorkspaceAndTimestamp({
- tableDef: mothershipInboxTask,
- workspaceIdCol: mothershipInboxTask.workspaceId,
- timestampCol: mothershipInboxTask.createdAt,
- workspaceIds,
- retentionDate,
- tableName: `${label}/mothershipInboxTask`,
- dbClient: cleanupDb,
- })
+ const inboxResult =
+ scope.kind === 'workspace'
+ ? await batchDeleteByWorkspaceAndTimestamp({
+ tableDef: mothershipInboxTask,
+ workspaceIdCol: mothershipInboxTask.workspaceId,
+ timestampCol: mothershipInboxTask.createdAt,
+ workspaceIds,
+ retentionDate,
+ tableName: `${label}/mothershipInboxTask`,
+ dbClient: cleanupDb,
+ })
+ : { deleted: 0, failed: 0 }
const totalDeleted =
runChildResults.reduce((s, r) => s + r.deleted, 0) +
@@ -158,7 +171,6 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise
logger.info(`[${label}] Complete: ${totalDeleted} total rows deleted`)
- // Clean up copilot backend + storage files after DB rows are gone
await chatCleanup.execute()
const timeElapsed = (Date.now() - startTime) / 1000
diff --git a/apps/sim/background/knowledge-connector-directory-sync.ts b/apps/sim/background/knowledge-connector-directory-sync.ts
new file mode 100644
index 00000000000..f36bb618f44
--- /dev/null
+++ b/apps/sim/background/knowledge-connector-directory-sync.ts
@@ -0,0 +1,41 @@
+import { createLogger } from '@sim/logger'
+import { task } from '@trigger.dev/sdk'
+import {
+ assertDirectorySyncPayload,
+ DIRECTORY_SYNC_CONCURRENCY,
+ DIRECTORY_SYNC_MAX_DURATION_SECONDS,
+ DIRECTORY_SYNC_TASK_ID,
+ type DirectorySyncPayload,
+} from '@/lib/knowledge/connectors/directory-queue'
+import { refreshConnectorDirectory } from '@/lib/knowledge/connectors/external-group-sync'
+
+const logger = createLogger('TriggerKnowledgeConnectorDirectorySync')
+
+export async function executeDirectorySyncJob(payload: unknown) {
+ const { connectorId, requestId } = assertDirectorySyncPayload(payload)
+ logger.info(`[${requestId}] Starting directory refresh: ${connectorId}`)
+ const outcome = await refreshConnectorDirectory(connectorId, requestId)
+ logger.info(`[${requestId}] Directory refresh finished`, { connectorId, outcome })
+ return { outcome }
+}
+
+export const knowledgeConnectorDirectorySync = task({
+ id: DIRECTORY_SYNC_TASK_ID,
+ maxDuration: DIRECTORY_SYNC_MAX_DURATION_SECONDS,
+ retry: {
+ maxAttempts: 2,
+ factor: 2,
+ minTimeoutInMs: 5000,
+ maxTimeoutInMs: 30000,
+ },
+ /**
+ * Two at a time: a walk is bounded by the provider's rate limit, not by
+ * CPU, and one tenant's directory is refreshed by whichever run reaches it
+ * first — the rest see it fresh and skip.
+ */
+ queue: {
+ concurrencyLimit: DIRECTORY_SYNC_CONCURRENCY,
+ name: 'connector-directory-sync-queue',
+ },
+ run: async (payload: DirectorySyncPayload) => executeDirectorySyncJob(payload),
+})
diff --git a/apps/sim/background/knowledge-connector-member-sync.test.ts b/apps/sim/background/knowledge-connector-member-sync.test.ts
index dbacf478487..45191321340 100644
--- a/apps/sim/background/knowledge-connector-member-sync.test.ts
+++ b/apps/sim/background/knowledge-connector-member-sync.test.ts
@@ -67,6 +67,8 @@ describe('knowledge connector member sync worker', () => {
it('classifies outcomes from the run counters', () => {
expect(classifyMemberSyncResult(RESULT)).toBe('completed')
+ expect(classifyMemberSyncResult({ ...RESULT, membersIncomplete: 1 })).toBe('partial')
+ expect(classifyMemberSyncResult({ ...RESULT, membersRemaining: true })).toBe('partial')
expect(classifyMemberSyncResult({ ...RESULT, membersFailed: 1 })).toBe('partial')
expect(classifyMemberSyncResult({ ...RESULT, docsFailed: 1 })).toBe('partial')
expect(classifyMemberSyncResult({ ...RESULT, error: 'boom' })).toBe('failed')
diff --git a/apps/sim/background/knowledge-connector-member-sync.ts b/apps/sim/background/knowledge-connector-member-sync.ts
index 04cd5e1ded4..6f01ca3ff48 100644
--- a/apps/sim/background/knowledge-connector-member-sync.ts
+++ b/apps/sim/background/knowledge-connector-member-sync.ts
@@ -19,20 +19,31 @@ export type MemberSyncTaskOutcome = 'completed' | 'partial' | 'skipped' | 'faile
export function classifyMemberSyncResult(result: MemberSyncResult): MemberSyncTaskOutcome {
if (result.skipReason) return 'skipped'
if (result.error) return 'failed'
- if (result.membersFailed > 0 || result.docsFailed > 0 || result.processingDispatch.failed > 0) {
+ if (
+ result.listingIncomplete ||
+ result.membersIncomplete > 0 ||
+ result.membersRemaining ||
+ result.membersFailed > 0 ||
+ result.docsFailed > 0 ||
+ result.processingDispatch.failed > 0
+ ) {
return 'partial'
}
return 'completed'
}
export async function executeMemberSyncJob(payload: unknown) {
- const { connectorId, requestId, billingAttribution, dispatchToken } =
+ const { connectorId, requestId, billingAttribution, dispatchToken, forceContentRefresh } =
assertMemberSyncPayload(payload)
logger.info(`[${requestId}] Starting member sync: ${connectorId}`)
try {
- const result = await executeMemberSync(connectorId, { billingAttribution, dispatchToken })
+ const result = await executeMemberSync(connectorId, {
+ billingAttribution,
+ dispatchToken,
+ forceContentRefresh,
+ })
const outcome = classifyMemberSyncResult(result)
logger.info(`[${requestId}] Member sync completed`, {
diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts
index a1023c80091..0a2c113ada0 100644
--- a/apps/sim/background/knowledge-connector-sync.test.ts
+++ b/apps/sim/background/knowledge-connector-sync.test.ts
@@ -202,6 +202,30 @@ describe('knowledge connector sync worker', () => {
).toBe('failed')
})
+ it('reports a durable continuation without aborting or retrying its completed pages', async () => {
+ const result = {
+ docsAdded: 1,
+ docsUpdated: 0,
+ docsDeleted: 0,
+ docsUnchanged: 0,
+ docsSkipped: 0,
+ docsFailed: 0,
+ processingDispatch: { requested: 1, accepted: 1, failed: 0 },
+ listingIncomplete: true,
+ }
+ mockAssertConnectorSyncPayload.mockReturnValue({
+ connectorId: 'connector-1',
+ requestId: 'request-1',
+ billingAttribution: BILLING_ATTRIBUTION,
+ })
+ mockExecuteSync.mockResolvedValue(result)
+ expect(classifyConnectorSyncResult(result)).toBe('partial')
+ await expect(executeConnectorSyncJob({})).resolves.toMatchObject({
+ outcome: 'partial',
+ listingIncomplete: true,
+ })
+ })
+
it('classifies a persisted connector error as a failed task', () => {
expect(
classifyConnectorSyncResult({
diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts
index ca34b89c62c..c261ddd2fcd 100644
--- a/apps/sim/background/knowledge-connector-sync.ts
+++ b/apps/sim/background/knowledge-connector-sync.ts
@@ -20,7 +20,8 @@ export type ConnectorSyncTaskOutcome = 'completed' | 'partial' | 'skipped' | 'fa
export function classifyConnectorSyncResult(result: SyncResult): ConnectorSyncTaskOutcome {
if (result.skipReason) return 'skipped'
if (result.error) return 'failed'
- if (result.docsFailed > 0 || result.processingDispatch.failed > 0) return 'partial'
+ if (result.listingIncomplete || result.docsFailed > 0 || result.processingDispatch.failed > 0)
+ return 'partial'
return 'completed'
}
@@ -72,7 +73,7 @@ export async function executeConnectorSyncJob(payload: unknown) {
})
const outcome = classifyConnectorSyncResult(result)
- if (outcome === 'failed' || outcome === 'partial') {
+ if (outcome === 'failed' || result.docsFailed > 0 || result.processingDispatch.failed > 0) {
/**
* `executeSync` has already persisted its terminal state. Source failures
* preserve the previous incremental watermark so the next connector pass
@@ -80,7 +81,9 @@ export async function executeConnectorSyncJob(payload: unknown) {
* sweep. Retrying this whole task immediately would duplicate a large
* fan-out, so fail visibly without retrying the completed transaction.
*/
- throw new AbortTaskRunError(formatConnectorSyncFailure(connectorId, result, outcome))
+ throw new AbortTaskRunError(
+ formatConnectorSyncFailure(connectorId, result, outcome === 'failed' ? 'failed' : 'partial')
+ )
}
return {
diff --git a/apps/sim/blocks/blocks/credential-group.test.ts b/apps/sim/blocks/blocks/credential-group.test.ts
new file mode 100644
index 00000000000..703ce2b342c
--- /dev/null
+++ b/apps/sim/blocks/blocks/credential-group.test.ts
@@ -0,0 +1,31 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/triggers', () => ({ getTrigger: () => ({ subBlocks: [] }) }))
+
+import { CredentialGroupBlock } from '@/blocks/blocks/credential-group'
+
+describe('Connected Accounts block', () => {
+ it('uses the workspace container without selectable or manual group inputs', () => {
+ expect(CredentialGroupBlock.name).toBe('Connected Accounts (Legacy)')
+ expect(CredentialGroupBlock.hideFromToolbar).toBe(true)
+ const operation = CredentialGroupBlock.subBlocks.find((field) => field.id === 'operation')
+ expect(operation?.options).toEqual([
+ { label: 'List Credentials', id: 'list_credentials' },
+ { label: 'List MCP Connections', id: 'list_mcp_connections' },
+ { label: 'Send Invite', id: 'send_invite' },
+ { label: 'Get Invite Link', id: 'get_invite_link' },
+ { label: 'List People', id: 'list_people' },
+ ])
+ expect(CredentialGroupBlock.inputs).not.toHaveProperty('credentialGroupId')
+ expect(CredentialGroupBlock.outputs).not.toHaveProperty('credentialGroups')
+ expect(
+ CredentialGroupBlock.subBlocks.some((field) => field.canonicalParamId === 'credentialGroupId')
+ ).toBe(false)
+ expect(
+ CredentialGroupBlock.subBlocks.find((field) => field.id === 'providerFilter')?.dependsOn
+ ).toBeUndefined()
+ })
+})
diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts
index 18cc264fb12..ad9f45df962 100644
--- a/apps/sim/blocks/blocks/credential-group.ts
+++ b/apps/sim/blocks/blocks/credential-group.ts
@@ -1,58 +1,8 @@
import { GridOffset } from '@sim/emcn/icons'
import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID } from '@/lib/credential-groups/trigger-constants'
-import {
- type CanonicalGroup,
- resolveActiveCanonicalValue,
-} from '@/lib/workflows/subblocks/visibility'
-import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { BlockConfig } from '@/blocks/types'
-import {
- CREDENTIAL_GROUP_LIST_STALE_TIME,
- credentialGroupKeys,
- fetchCredentialGroupSettings,
-} from '@/hooks/queries/utils/credential-group-queries'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { useSubBlockStore } from '@/stores/workflows/subblock/store'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getTrigger } from '@/triggers'
-const CREDENTIAL_GROUP_CANONICAL_GROUP = {
- canonicalId: 'credentialGroupId',
- basicId: 'credentialGroup',
- advancedIds: ['manualCredentialGroup'],
-} as const satisfies CanonicalGroup
-
-/**
- * Reads the workspace credential-group list through the shared cache entry every
- * consumer observes. The fetch stays bound to React Query's own signal: a caller's
- * signal belongs to that caller alone, and forwarding it here would abort a request
- * other observers of this workspace-wide key are awaiting.
- */
-async function fetchCachedCredentialGroups() {
- const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
- if (!workspaceId) return []
-
- const settings = await getQueryClient().fetchQuery({
- queryKey: credentialGroupKeys.list(workspaceId),
- queryFn: ({ signal }) => fetchCredentialGroupSettings(workspaceId, signal),
- staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
- })
- return settings.credentialGroups
-}
-
-function resolveCredentialGroupIdForBlock(blockId: string): string | null {
- const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
- if (!activeWorkflowId) return null
- const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}
- const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes
- const value = resolveActiveCanonicalValue(
- CREDENTIAL_GROUP_CANONICAL_GROUP,
- values,
- canonicalModes
- )
- return typeof value === 'string' && value.trim() ? value.trim() : null
-}
-
interface CredentialGroupBlockOutput {
success: boolean
output: {
@@ -72,15 +22,6 @@ interface CredentialGroupBlockOutput {
mcpServerName: string
toolNames: string[]
}>
- credentialGroups: Array<{
- id: string
- name: string
- description: string | null
- status: 'active' | 'disabled'
- providerIds: string[]
- createdAt: string
- updatedAt: string
- }>
people: Array<{
id: string
email: string
@@ -102,25 +43,15 @@ interface CredentialGroupBlockOutput {
}
const INVITE_OPERATIONS = ['send_invite', 'get_invite_link'] as const
-const GROUP_OPERATIONS = [
- 'list_credentials',
- 'list_mcp_connections',
- ...INVITE_OPERATIONS,
- 'list_people',
-] as const
-const LIST_OPERATIONS = [
- 'list_credentials',
- 'list_mcp_connections',
- 'list_people',
- 'list_groups',
-] as const
+const LIST_OPERATIONS = ['list_credentials', 'list_mcp_connections', 'list_people'] as const
export const CredentialGroupBlock: BlockConfig = {
type: 'credential_group',
- name: 'Credential Groups',
- description: 'Invite people and use credentials or MCP connections from Credential Groups',
+ name: 'Connected Accounts (Legacy)',
+ hideFromToolbar: true,
+ description: 'Invite people and use connected accounts in this workspace',
longDescription:
- 'List usable managed credentials or MCP connections, inspect invited people, send or generate an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.',
+ 'List usable managed credentials or MCP connections, inspect invited people, send an invitation, or generate an account-connection link in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.',
bestPractices: `
- "List Credentials" returns every active credential. Filter by email to select one enrolled person, by provider to select one account type, or by both for an exact match.
- Provider blocks can use the current actor's enrolled credential by default. Using another enrollment requires an explicit workflow access grant.
@@ -136,55 +67,28 @@ export const CredentialGroupBlock: BlockConfig = {
bgColor: '#8B5CF6',
icon: GridOffset,
canvasPresentation: {
- defaultTitle: 'Credential Groups',
+ defaultTitle: 'Connected Accounts',
sentences: {
byOperation: {
list_credentials: [
- {
- text: 'List credentials from',
- field: ['credentialGroup', 'manualCredentialGroup'],
- core: true,
- },
+ 'List connected accounts',
{ text: ', for', field: 'email' },
{ text: ', from', field: ['providerFilter', 'manualProviderIds'] },
- { text: ', up to', field: 'limit', after: 'credentials' },
+ { text: ', up to', field: 'limit', after: 'accounts' },
],
list_mcp_connections: [
- {
- text: 'List MCP connections from',
- field: ['credentialGroup', 'manualCredentialGroup'],
- core: true,
- },
+ 'List MCP connections',
{ text: ', for', field: 'email' },
{ text: ', on server', field: 'mcpServerId' },
{ text: ', up to', field: 'limit', after: 'connections' },
],
- send_invite: [
- { text: 'Invite', field: 'email', core: true },
- {
- text: 'to',
- field: ['credentialGroup', 'manualCredentialGroup'],
- core: true,
- },
- ],
- get_invite_link: [
- { text: 'Get invite link for', field: 'email', core: true },
- {
- text: 'in',
- field: ['credentialGroup', 'manualCredentialGroup'],
- core: true,
- },
- ],
+ send_invite: [{ text: 'Invite', field: 'email', core: true }],
+ get_invite_link: [{ text: 'Get invite link for', field: 'email', core: true }],
list_people: [
- {
- text: 'List people in',
- field: ['credentialGroup', 'manualCredentialGroup'],
- core: true,
- },
+ 'List invited people',
{ text: ', matching', field: 'email' },
{ text: ', with status', field: 'peopleStatuses' },
],
- list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }],
},
},
},
@@ -200,37 +104,15 @@ export const CredentialGroupBlock: BlockConfig = {
{ label: 'Send Invite', id: 'send_invite' },
{ label: 'Get Invite Link', id: 'get_invite_link' },
{ label: 'List People', id: 'list_people' },
- { label: 'List Credential Groups', id: 'list_groups' },
],
value: () => 'list_credentials',
},
- {
- id: 'credentialGroup',
- title: 'Credential Group',
- type: 'dropdown',
- selectorKey: 'workspace.credentialGroups',
- required: { field: 'operation', value: [...GROUP_OPERATIONS] },
- mode: 'basic',
- canonicalParamId: 'credentialGroupId',
- condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
- },
- {
- id: 'manualCredentialGroup',
- title: 'Credential Group ID',
- type: 'short-input',
- required: { field: 'operation', value: [...GROUP_OPERATIONS] },
- mode: 'advanced',
- placeholder: 'Enter credential group ID',
- canonicalParamId: 'credentialGroupId',
- condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
- },
{
id: 'email',
title: 'Email',
type: 'short-input',
required: { field: 'operation', value: [...INVITE_OPERATIONS] },
placeholder: 'person@example.com',
- condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
},
{
id: 'providerFilter',
@@ -242,7 +124,6 @@ export const CredentialGroupBlock: BlockConfig = {
required: false,
mode: 'basic',
canonicalParamId: 'credentialProviderIds',
- dependsOn: ['credentialGroupId'],
condition: { field: 'operation', value: 'list_credentials' },
},
{
@@ -252,7 +133,6 @@ export const CredentialGroupBlock: BlockConfig = {
required: false,
mode: 'advanced',
canonicalParamId: 'credentialProviderIds',
- dependsOn: ['credentialGroupId'],
placeholder: '["google-email", "slack"] — leave empty for all providers',
condition: { field: 'operation', value: 'list_credentials' },
},
@@ -304,9 +184,8 @@ export const CredentialGroupBlock: BlockConfig = {
operation: {
type: 'string',
description:
- "'list_credentials', 'list_mcp_connections', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'",
+ "'list_credentials', 'list_mcp_connections', 'send_invite', 'get_invite_link', or 'list_people'",
},
- credentialGroupId: { type: 'string', description: 'Credential Group ID' },
email: {
type: 'string',
description: 'Recipient email for invites or an optional credential/people-list filter',
@@ -339,12 +218,6 @@ export const CredentialGroupBlock: BlockConfig = {
'Usable MCP connection references (credentialId, email, displayName, mcpServerId, mcpServerName, toolNames)',
condition: { field: 'operation', value: 'list_mcp_connections' },
},
- credentialGroups: {
- type: 'json',
- description:
- 'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)',
- condition: { field: 'operation', value: 'list_groups' },
- },
people: {
type: 'json',
description:
diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts
index 1ef89687e18..4dd13a27dc1 100644
--- a/apps/sim/blocks/blocks/credential.ts
+++ b/apps/sim/blocks/blocks/credential.ts
@@ -1,27 +1,27 @@
import { CredentialIcon } from '@/components/icons'
+import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID } from '@/lib/credential-groups/trigger-constants'
import type { BlockConfig } from '@/blocks/types'
+import { getTrigger } from '@/triggers'
-interface CredentialBlockOutput {
- success: boolean
- output: {
- credentialId: string
- displayName: string
- providerId: string
- credentials: Array<{
- credentialId: string
- displayName: string
- providerId: string
- }>
- count: number
- }
-}
+const ORGANIZATION_OPERATIONS = [
+ 'find_organization_account',
+ 'list_organization_accounts',
+ 'find_organization_mcp_connection',
+ 'list_organization_mcp_connections',
+]
+const ORGANIZATION_LIST_OPERATIONS = [
+ 'list_organization_accounts',
+ 'list_organization_mcp_connections',
+]
+const OAUTH_FIND_OPERATIONS = ['select', 'find_organization_account']
+const MCP_OPERATIONS = ['find_organization_mcp_connection', 'list_organization_mcp_connections']
-export const CredentialBlock: BlockConfig = {
+export const CredentialBlock: BlockConfig = {
type: 'credential',
name: 'Credential',
- description: 'Select or list OAuth credentials',
+ description: 'Select credentials or find organization accounts and MCP connections',
longDescription:
- 'Select an OAuth credential once and pipe its ID into any downstream block that requires authentication, or list all OAuth credentials in the workspace for iteration. No secrets are ever exposed — only credential IDs and metadata.',
+ 'Select workspace OAuth credentials or find and list organization accounts in an allowlisted workspace. Organization accounts are shared with every authorized workflow in that workspace. Returns credential references and account metadata. Manage invitations in organization settings.',
bestPractices: `
- Use "Select Credential" to define an OAuth credential once and reference in multiple downstream blocks instead of repeating credential IDs.
- Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts).
@@ -38,6 +38,16 @@ export const CredentialBlock: BlockConfig = {
byOperation: {
select: ['Select an OAuth credential'],
list: ['List OAuth credentials', { text: 'for', field: 'providerFilter' }],
+ find_organization_account: ['Find organization account', { text: 'for', field: 'email' }],
+ list_organization_accounts: ['List organization accounts', { text: 'for', field: 'email' }],
+ find_organization_mcp_connection: [
+ 'Find organization MCP connection',
+ { text: 'for', field: 'email' },
+ ],
+ list_organization_mcp_connections: [
+ 'List organization MCP connections',
+ { text: 'for', field: 'email' },
+ ],
},
},
},
@@ -50,6 +60,10 @@ export const CredentialBlock: BlockConfig = {
options: [
{ label: 'Select Credential', id: 'select' },
{ label: 'List Credentials', id: 'list' },
+ { label: 'Find Organization Account', id: 'find_organization_account' },
+ { label: 'List Organization Accounts', id: 'list_organization_accounts' },
+ { label: 'Find Organization MCP Connection', id: 'find_organization_mcp_connection' },
+ { label: 'List Organization MCP Connections', id: 'list_organization_mcp_connections' },
],
value: () => 'select',
},
@@ -81,12 +95,75 @@ export const CredentialBlock: BlockConfig = {
canonicalParamId: 'credentialId',
condition: { field: 'operation', value: 'select' },
},
+ {
+ id: 'email',
+ title: 'Email',
+ type: 'short-input',
+ placeholder: 'person@example.com',
+ condition: { field: 'operation', value: ORGANIZATION_OPERATIONS },
+ required: {
+ field: 'operation',
+ value: ['find_organization_account', 'find_organization_mcp_connection'],
+ },
+ },
+ {
+ id: 'organizationProvider',
+ title: 'Provider',
+ type: 'dropdown',
+ selectorKey: 'workspace.credentialGroupProviders',
+ condition: { field: 'operation', value: 'find_organization_account' },
+ required: true,
+ },
+ {
+ id: 'organizationProviders',
+ title: 'Providers',
+ type: 'dropdown',
+ multiSelect: true,
+ selectorKey: 'workspace.credentialGroupProviders',
+ condition: { field: 'operation', value: 'list_organization_accounts' },
+ },
+ {
+ id: 'mcpProvider',
+ title: 'MCP provider',
+ type: 'dropdown',
+ selectorKey: 'workspace.organizationMcpProviders',
+ condition: { field: 'operation', value: MCP_OPERATIONS },
+ required: { field: 'operation', value: 'find_organization_mcp_connection' },
+ },
+ {
+ id: 'limit',
+ title: 'Limit',
+ type: 'short-input',
+ value: () => '100',
+ condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS },
+ },
+ {
+ id: 'cursor',
+ title: 'Cursor',
+ type: 'short-input',
+ placeholder: 'Previous page nextCursor',
+ condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS },
+ },
+ ...getTrigger(CREDENTIAL_GROUP_EVENT_TRIGGER_ID).subBlocks,
],
+ triggers: { enabled: true, available: [CREDENTIAL_GROUP_EVENT_TRIGGER_ID] },
tools: {
access: [],
},
inputs: {
- operation: { type: 'string', description: "'select' or 'list'" },
+ operation: { type: 'string', description: 'Credential operation' },
+ email: { type: 'string', description: 'Enrollment email' },
+ organizationProvider: {
+ type: 'string',
+ description: 'Organization OAuth provider ID for an exact match',
+ },
+ organizationProviders: {
+ type: 'json',
+ description: 'Optional organization OAuth provider IDs',
+ },
+ mcpProvider: { type: 'string', description: 'Managed MCP provider ID' },
+ limit: { type: 'number', description: 'Page size from 1 to 100' },
+ cursor: { type: 'string', description: 'Previous page nextCursor' },
credentialId: {
type: 'string',
description: 'The OAuth credential ID to resolve (select operation)',
@@ -101,28 +178,73 @@ export const CredentialBlock: BlockConfig = {
credentialId: {
type: 'string',
description: "Credential ID — pipe into other blocks' credential fields",
- condition: { field: 'operation', value: 'select' },
+ condition: {
+ field: 'operation',
+ value: [...OAUTH_FIND_OPERATIONS, 'find_organization_mcp_connection'],
+ },
},
displayName: {
type: 'string',
description: 'Human-readable name of the credential',
- condition: { field: 'operation', value: 'select' },
+ condition: {
+ field: 'operation',
+ value: [...OAUTH_FIND_OPERATIONS, 'find_organization_mcp_connection'],
+ },
},
providerId: {
type: 'string',
description: 'OAuth provider ID (e.g. google-email, slack)',
- condition: { field: 'operation', value: 'select' },
+ condition: { field: 'operation', value: OAUTH_FIND_OPERATIONS },
},
credentials: {
type: 'json',
description:
'Array of OAuth credential objects, each with credentialId, displayName, and providerId',
- condition: { field: 'operation', value: 'list' },
+ condition: { field: 'operation', value: ['list', 'list_organization_accounts'] },
},
count: {
type: 'number',
- description: 'Number of credentials returned',
- condition: { field: 'operation', value: 'list' },
+ description: 'Number of connections returned',
+ condition: { field: 'operation', value: ['list', ...ORGANIZATION_LIST_OPERATIONS] },
+ },
+ email: {
+ type: 'string',
+ description: 'Enrollment email',
+ condition: {
+ field: 'operation',
+ value: ['find_organization_account', 'find_organization_mcp_connection'],
+ },
+ },
+ mcpServerId: {
+ type: 'string',
+ description: 'Shared MCP server configuration ID; use credentialId to select the account',
+ condition: { field: 'operation', value: 'find_organization_mcp_connection' },
+ },
+ mcpServerName: {
+ type: 'string',
+ description: 'MCP server name',
+ condition: { field: 'operation', value: 'find_organization_mcp_connection' },
+ },
+ toolNames: {
+ type: 'json',
+ description: 'Tools available to this connection',
+ condition: { field: 'operation', value: 'find_organization_mcp_connection' },
+ },
+ mcpConnections: {
+ type: 'json',
+ description:
+ 'Managed MCP connections with credentialId, email, mcpServerId, mcpServerName, displayName, and toolNames',
+ condition: { field: 'operation', value: 'list_organization_mcp_connections' },
+ },
+ hasMore: {
+ type: 'boolean',
+ description: 'Whether another page is available',
+ condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS },
+ },
+ nextCursor: {
+ type: 'string',
+ description: 'Next page cursor, or null',
+ condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS },
},
},
}
diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts
index c2e1c94cd20..b2e06c5b7ec 100644
--- a/apps/sim/blocks/blocks/slack.ts
+++ b/apps/sim/blocks/blocks/slack.ts
@@ -2657,7 +2657,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
channels: {
type: 'json',
description:
- 'Array of up to 10,000 accessible conversation objects. Credential-group user tokens also include direct and group DMs, with type fields (is_channel, is_im, is_mpim) and DM participant field user.',
+ 'Array of up to 10,000 accessible public and private channel objects, including conversation type and membership fields.',
},
count: {
type: 'number',
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx
index 55b14677dd3..3f00597bbed 100644
--- a/apps/sim/components/emails/billing/credit-purchase-email.tsx
+++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx
@@ -3,6 +3,7 @@ import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditPurchaseEmailProps {
@@ -47,7 +48,7 @@ export function CreditPurchaseEmail({
Credits are applied automatically to your workflow executions.
- View Dashboard
+ View Dashboard
diff --git a/apps/sim/components/identity-tile/identity-tile.tsx b/apps/sim/components/identity-tile/identity-tile.tsx
new file mode 100644
index 00000000000..2df3f7a4acb
--- /dev/null
+++ b/apps/sim/components/identity-tile/identity-tile.tsx
@@ -0,0 +1,71 @@
+import { cn } from '@sim/emcn'
+
+interface IdentityTileProps {
+ /** Letter shown when there is no uploaded mark. */
+ initial: string
+ logoUrl?: string | null
+ /** Accessible name for an uploaded mark; empty when the name is already beside it. */
+ alt?: string
+ /** Layout-only extras (visibility, positioning). Never chrome. */
+ className?: string
+ /** `data-slot` hook for tests and styling. */
+ slot?: string
+ /** `sm` is the 16px rail mark; `lg` is the 36px tile a row or card leads with. */
+ size?: 'sm' | 'lg'
+}
+
+const SIZE_CLASS = {
+ sm: 'size-[16px] rounded-sm text-micro',
+ lg: 'size-9 rounded-lg text-base',
+} as const
+
+/**
+ * The 16px mark for a workspace or organization: its uploaded logo, or its
+ * initial on a neutral tile. There is no per-entity color — every tile is the
+ * same gray so an uploaded mark is the only thing that distinguishes one from
+ * another, exactly as an icon would.
+ *
+ * Chrome matches the chip family at tile scale: `rounded-sm` is the chip's
+ * `rounded-lg` scaled to a 16px box, and the letter sits at the smallest type
+ * token. The fill is `--surface-6`, one step past the chip hover and active
+ * fills, so the tile still reads as a tile on a hovered or selected row instead
+ * of dissolving into it. The letter is the icon gray in light mode and steps up
+ * to the secondary text gray in dark mode, where the icon gray sits too close
+ * to that fill. Plain `img`/`div`
+ * rather than the emcn `Avatar`, whose Radix root renders a `` — and globals
+ * fade every `span` in the collapsed rail to `opacity: 0`, which would blank the
+ * mark exactly where it is the only thing left to see.
+ */
+export function IdentityTile({
+ initial,
+ logoUrl,
+ alt = '',
+ className,
+ slot,
+ size = 'sm',
+}: IdentityTileProps) {
+ if (logoUrl) {
+ return (
+
+ )
+ }
+ return (
+
+ {initial}
+
+ )
+}
diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx
index 7cefeba5d47..2112668c818 100644
--- a/apps/sim/components/settings/account-settings-renderer.tsx
+++ b/apps/sim/components/settings/account-settings-renderer.tsx
@@ -6,6 +6,7 @@ import { usePostHog } from 'posthog-js/react'
import type { AccountSettingsSection } from '@/components/settings/navigation'
import { captureEvent } from '@/lib/posthog/client'
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
+import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts'
const Billing = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then(
@@ -39,6 +40,7 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp
captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section })
}, [posthog, section])
+ if (section === 'connected-accounts') return
if (section === 'general') return
if (section === 'billing') return
if (section === 'api-keys') return
diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts
index 270209f733d..8af92604619 100644
--- a/apps/sim/components/settings/navigation.test.ts
+++ b/apps/sim/components/settings/navigation.test.ts
@@ -62,7 +62,6 @@ const SELF_HOSTED_WORKSPACE_SECTIONS = WORKSPACE_SETTINGS_ITEMS.map(({ id }) =>
)
const ALL_ENTITLEMENTS = {
- credentialGroups: true,
customBlocks: true,
forks: true,
inbox: true,
@@ -106,7 +105,6 @@ describe('settings navigation boundaries', () => {
'organization',
'usage',
'secrets',
- 'credential-groups',
'custom-tools',
'mcp',
'apikeys',
@@ -129,6 +127,7 @@ describe('settings navigation boundaries', () => {
'general',
'billing',
'api-keys',
+ 'connected-accounts',
'admin',
'mothership',
])
@@ -138,7 +137,6 @@ describe('settings navigation boundaries', () => {
'secrets',
'byok',
'sandboxes',
- 'credential-groups',
'custom-tools',
'mcp',
'workflow-mcp-servers',
@@ -235,6 +233,7 @@ describe('settings navigation boundaries', () => {
hasEnterprisePlan: true,
hosted: false,
selfHosted: {
+ 'connected-accounts': true,
'access-control': false,
'audit-logs': false,
sso: true,
@@ -335,7 +334,6 @@ describe('settings navigation boundaries', () => {
secrets: 'secrets',
byok: 'byok',
sandboxes: 'sandboxes',
- 'credential-groups': 'credential-groups',
'custom-tools': 'custom-tools',
mcp: 'mcp',
'workflow-mcp-servers': 'workflow-mcp-servers',
@@ -560,7 +558,6 @@ describe('settings navigation boundaries', () => {
expect(items.map(({ id }) => id)).toEqual([
'teammates',
- 'credential-groups',
'workflow-mcp-servers',
'recently-deleted',
'forks',
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
index eef5fa375ec..4cb95e82f4d 100644
--- a/apps/sim/components/settings/navigation.ts
+++ b/apps/sim/components/settings/navigation.ts
@@ -8,6 +8,7 @@ import {
Globe,
GridOffset,
HexSimple,
+ Integration,
Key,
KeySquare,
Lock,
@@ -30,10 +31,17 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/wo
import { CodeIcon, McpIcon } from '@/components/icons'
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'
import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces'
+import { organizationRoutes } from '@/lib/navigation/paths'
export type SettingsPlane = 'account' | 'selfhost' | 'workspace'
-export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership'
+export type AccountSettingsSection =
+ | 'connected-accounts'
+ | 'general'
+ | 'billing'
+ | 'api-keys'
+ | 'admin'
+ | 'mothership'
/**
* Settings a self-hoster needs from the managed service: their profile, what
@@ -42,6 +50,9 @@ export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin
export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys'
export type OrganizationSettingsSection =
+ | 'integrations'
+ | 'connected-accounts'
+ | 'search-mcp'
| 'members'
| 'billing'
| 'usage'
@@ -56,7 +67,6 @@ export type OrganizationSettingsSection =
export type WorkspaceSettingsSection =
| 'teammates'
| 'secrets'
- | 'credential-groups'
| 'byok'
| 'sandboxes'
| 'custom-tools'
@@ -90,7 +100,6 @@ export type UnifiedSettingsSection =
| 'browser'
| 'terminal'
| 'secrets'
- | 'credential-groups'
| 'access-control'
| 'custom-blocks'
| 'audit-logs'
@@ -308,7 +317,7 @@ export const ACCOUNT_SETTINGS_GROUPS = [
] as const
/** Planes with their own standalone shell; the workspace plane renders inside the editor. */
-export type StandaloneSettingsPlane = Exclude
+export type StandaloneSettingsPlane = Exclude | 'organization'
/**
* Per-plane sidebar chrome. Self-host is reached from outside the app (the CLI
@@ -321,6 +330,7 @@ export const SETTINGS_PLANE_CHROME: Record<
> = {
account: { label: 'Account', showWordmark: false },
selfhost: { label: 'Self-host', showWordmark: true },
+ organization: { label: 'Organization', showWordmark: false },
}
export const SELFHOST_SETTINGS_GROUPS = [
@@ -413,7 +423,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'forks',
description: 'Fork this workspace and sync changes with its parent.',
- group: 'organization',
+ group: 'workspace',
order: 3,
},
planes: {
@@ -464,7 +474,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
icon: Users,
unified: {
id: 'organization',
- description: "Manage your organization's members and seats.",
+ description: 'Members and workspace access in your organization.',
group: 'organization',
order: 0,
hideWhenBillingDisabled: true,
@@ -518,19 +528,15 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
},
},
{
- label: 'Credential groups',
+ label: 'Connected accounts',
icon: GridOffset,
- unified: {
- id: 'credential-groups',
- description: 'Collect and manage OAuth credentials for people outside this workspace.',
- group: 'workspace',
- order: 9,
- requiresEnterprise: true,
- allowNonOrgAdmin: true,
- selfHostedOverride: 'always',
- },
planes: {
- workspace: { id: 'credential-groups', group: 'workspace', order: 4 },
+ account: {
+ id: 'connected-accounts',
+ group: 'account',
+ order: 3,
+ description: 'Manage accounts you have contributed to organizations.',
+ },
},
},
{
@@ -766,7 +772,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'custom-blocks',
description: 'Publish workflows as reusable blocks for your organization.',
- group: 'organization',
+ group: 'workspace',
order: 2,
requiresHosted: true,
requiresEnterprise: true,
@@ -877,6 +883,85 @@ export const ORGANIZATION_PLANE_UNIFIED_SECTIONS: ReadonlySet =
+ {
+ billing: 'account',
+ members: 'organization',
+ 'connected-accounts': 'organization',
+ usage: 'organization',
+ whitelabeling: 'organization',
+ 'audit-logs': 'governance',
+ 'access-control': 'governance',
+ sso: 'governance',
+ sessions: 'governance',
+ 'data-retention': 'governance',
+ 'data-drains': 'governance',
+ integrations: 'sim-search',
+ 'search-mcp': 'sim-search',
+ }
+
+export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] = (
+ Object.keys(ORGANIZATION_SECTION_GROUPS) as OrganizationSettingsSection[]
+).map((id) => {
+ const group = ORGANIZATION_SECTION_GROUPS[id]
+ if (id === 'connected-accounts') {
+ return {
+ id,
+ label: 'Connected accounts',
+ description: 'Manage accounts shared with your organization’s workflows.',
+ icon: GridOffset,
+ group,
+ }
+ }
+ if (id === 'integrations') {
+ return {
+ id,
+ label: 'Integrations',
+ description: 'Set up the sources your organization searches.',
+ icon: Integration,
+ group,
+ }
+ }
+ if (id === 'search-mcp') {
+ return {
+ id,
+ label: 'Search MCP',
+ description: 'Search your sources from other apps.',
+ icon: Server,
+ group,
+ }
+ }
+ const item = buildUnifiedSettingsCatalog().find((entry) => entry.organizationSection === id)
+ if (!item) throw new Error(`Organization settings section "${id}" has no registry entry`)
+ return { ...item, id, group }
+})
+
+export function getOrganizationSettingsHref(
+ organizationId: string,
+ section: OrganizationSettingsSection,
+ searchParams?: SettingsHrefSearchParams
+): string {
+ return withSettingsSearchParams(
+ organizationRoutes(organizationId).settingsSection(section),
+ searchParams
+ )
+}
+
/**
* Unified section id to the organization-scoped section it acts on, for the gates
* that take an {@link OrganizationSettingsSection} (`canOpenOrganizationSettingsSection`,
@@ -920,6 +1005,7 @@ export function resolveOrganizationSectionAccess({
isTargetOrganizationAdmin,
}: ResolveOrganizationSectionAccessOptions): OrganizationSectionAccess {
if (!isTargetOrganizationMember) return 'unavailable'
+ if (section === 'search-mcp') return 'view'
if (section === 'members') return isTargetOrganizationAdmin ? 'manage' : 'view'
return isTargetOrganizationAdmin ? 'manage' : 'unavailable'
}
@@ -941,6 +1027,7 @@ export function getOrganizationSettingsFeatures(
hasEnterprisePlan,
hosted: deployment.hosted,
selfHosted: {
+ 'connected-accounts': true,
'access-control': features.accessControl,
'audit-logs': features.auditLogs,
sso: features.sso,
@@ -961,8 +1048,10 @@ export function isOrganizationSettingsSectionAvailable(
section: OrganizationSettingsSection,
features: OrganizationSettingsFeatures
): boolean {
- if (section === 'members') return true
+ if (section === 'members' || section === 'search-mcp') return true
if (section === 'billing') return features.billingEnabled
+ /* Sim Search itself is enterprise on the hosted product; self-hosted gates it by flag, not by section. */
+ if (section === 'integrations') return !features.hosted || features.hasEnterprisePlan
if (features.hosted) return features.hasEnterprisePlan
return features.selfHosted[section] ?? false
}
@@ -992,7 +1081,6 @@ export function workspaceSectionUsesPermissionConfig(section: WorkspaceSettingsS
}
export interface WorkspaceSettingsEntitlements {
- credentialGroups: boolean
customBlocks: boolean
forks: boolean
inbox: boolean
@@ -1061,7 +1149,6 @@ export interface ResolvedWorkspaceNavigationItem
const WORKSPACE_MUTATION_PERMISSION: Record = {
teammates: 'admin',
secrets: 'write',
- 'credential-groups': 'admin',
byok: 'admin',
sandboxes: 'admin',
'custom-tools': 'write',
@@ -1100,12 +1187,6 @@ export function resolveWorkspaceNavigation({
const permissionConfigKey = WORKSPACE_PERMISSION_CONFIG_KEYS[item.id]
if (permissionConfigKey && permissionConfig[permissionConfigKey]) return []
if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return []
- if (
- item.id === 'credential-groups' &&
- (permission !== 'admin' || !entitlements.credentialGroups)
- ) {
- return []
- }
if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id]
diff --git a/apps/sim/components/settings/settings-guarded-link.tsx b/apps/sim/components/settings/settings-guarded-link.tsx
new file mode 100644
index 00000000000..eb0565c27ee
--- /dev/null
+++ b/apps/sim/components/settings/settings-guarded-link.tsx
@@ -0,0 +1,32 @@
+'use client'
+
+import type { ComponentProps } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+interface SettingsGuardedLinkProps
+ extends Omit, 'href' | 'onNavigate'> {
+ href: string
+ onNavigate?: () => void
+}
+
+/** Preserves settings drafts when navigating through menus outside the settings sidebar. */
+export function SettingsGuardedLink({ href, onNavigate, ...props }: SettingsGuardedLinkProps) {
+ const router = useRouter()
+
+ return (
+ {
+ const { isDirty, navigationBlocked, requestLeave } = useSettingsDirtyStore.getState()
+ if (isDirty || navigationBlocked) {
+ event.preventDefault()
+ requestLeave(() => router.push(href))
+ }
+ onNavigate?.()
+ }}
+ />
+ )
+}
diff --git a/apps/sim/components/settings/settings-sidebar.test.tsx b/apps/sim/components/settings/settings-sidebar.test.tsx
new file mode 100644
index 00000000000..97598ba687b
--- /dev/null
+++ b/apps/sim/components/settings/settings-sidebar.test.tsx
@@ -0,0 +1,137 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ComponentProps } from 'react'
+import { Users } from '@sim/emcn/icons'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockPush, mockReplace, mockNavigate } = vi.hoisted(() => ({
+ mockPush: vi.fn(),
+ mockReplace: vi.fn(),
+ mockNavigate: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/o/org-a/settings/members',
+ useRouter: () => ({ push: mockPush, replace: mockReplace }),
+}))
+vi.mock('@/app/(landing)/components/navbar/components', () => ({ SimWordmark: () => null }))
+vi.mock('@/components/settings/settings-intent-link', () => ({
+ SettingsIntentLink: ({
+ onNavigate,
+ replace: _replace,
+ scroll: _scroll,
+ ...props
+ }: ComponentProps<'a'> & {
+ replace?: boolean
+ scroll?: boolean
+ onNavigate?: (event: { preventDefault: () => void }) => void
+ }) => (
+ {
+ event.preventDefault()
+ let prevented = false
+ onNavigate?.({
+ preventDefault: () => {
+ prevented = true
+ },
+ })
+ if (!prevented) mockNavigate(props.href)
+ }}
+ />
+ ),
+}))
+
+import { SettingsSidebar } from '@/components/settings/settings-sidebar'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+let root: Root
+let container: HTMLDivElement
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ useSettingsDirtyStore.getState().reset()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ useSettingsDirtyStore.getState().reset()
+})
+
+function renderSidebar(isCollapsed = false) {
+ act(() =>
+ root.render(
+ `/o/org-a/settings/${section}`}
+ backHref='/o/org-a/home'
+ isCollapsed={isCollapsed}
+ />
+ )
+ )
+}
+
+function button(label: string): HTMLButtonElement {
+ const element = [...document.querySelectorAll('button')].find(
+ (candidate) => candidate.textContent?.trim() === label
+ )
+ if (!element) throw new Error(`Missing button: ${label}`)
+ return element
+}
+
+describe('SettingsSidebar interactions', () => {
+ it('keeps destinations available in the icon rail after collapsing a section', () => {
+ renderSidebar()
+ act(() => button('Organization').click())
+ expect(container.querySelector('a')).toBeNull()
+
+ renderSidebar(true)
+ expect(container.querySelectorAll('a')).toHaveLength(2)
+
+ renderSidebar()
+ expect(button('Organization')).toHaveAttribute('aria-expanded', 'false')
+ act(() => button('Organization').click())
+ expect(container.querySelectorAll('a')).toHaveLength(2)
+ })
+
+ it('preserves dirty settings when Back is cancelled, then leaves only after confirmation', () => {
+ renderSidebar()
+ act(() => useSettingsDirtyStore.getState().setDirty(true))
+ act(() => button('Back').click())
+ expect(mockPush).not.toHaveBeenCalled()
+ act(() => button('Keep editing').click())
+ expect(useSettingsDirtyStore.getState().isDirty).toBe(true)
+ expect(mockPush).not.toHaveBeenCalled()
+
+ act(() => button('Back').click())
+ act(() => button('Discard changes').click())
+ expect(mockPush).toHaveBeenCalledWith('/o/org-a/home')
+ })
+
+ it('blocks section navigation during a save even when the draft is already clean', () => {
+ renderSidebar()
+ act(() => useSettingsDirtyStore.getState().setNavigationBlocked(true))
+ act(() => container.querySelector('a[href$="search-mcp"]')?.click())
+ expect(mockNavigate).not.toHaveBeenCalled()
+ expect(mockReplace).not.toHaveBeenCalled()
+ expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
+
+ act(() => useSettingsDirtyStore.getState().setNavigationBlocked(false))
+ act(() => container.querySelector('a[href$="search-mcp"]')?.click())
+ expect(mockNavigate).toHaveBeenCalledWith('/o/org-a/settings/search-mcp')
+ })
+})
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx
index c210e9bbd1b..1be5ff22031 100644
--- a/apps/sim/components/settings/settings-sidebar.tsx
+++ b/apps/sim/components/settings/settings-sidebar.tsx
@@ -1,15 +1,19 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
+import { type ComponentType, useRef } from 'react'
import {
+ Chip,
ChipConfirmModal,
- chipIconSlotClass,
+ ChipTag,
+ chipContentIconClass,
chipVariants,
cn,
OverflowText,
- Tooltip,
+ scrollFadeAttributes,
+ scrollFadeClass,
+ useScrollEdges,
} from '@sim/emcn'
-import { ChevronLeft } from '@sim/emcn/icons'
+import { ArrowUpRight, ChevronLeft } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import {
SETTINGS_PLANE_CHROME,
@@ -18,18 +22,25 @@ import {
type StandaloneSettingsPlane,
} from '@/components/settings/navigation'
import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
+import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_RAIL_CHIP_CLASS,
+ SIDEBAR_SECTION_GAP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
/**
* The marketing landing page. `?home` is required: the proxy bounces a
- * signed-in user off `/` to `/workspace` unless the param is present.
+ * signed-in user off `/` to the app entry unless the param is present.
*/
const LANDING_HREF = '/?home'
-/** Where the Back chip goes on planes that don't show the wordmark. */
-const WORKSPACE_HREF = '/workspace'
-
interface SettingsNavigationGroup {
key: string
title: string
@@ -40,32 +51,28 @@ interface SidebarSettingsItem
locked?: boolean
}
+/**
+ * A row that leads out of these settings rather than to a section of them —
+ * drawn like the workspace sidebar's Organization row, with the up-right arrow.
+ * Rendered after its group's sections.
+ */
+export interface SettingsSidebarOutboundLink {
+ id: string
+ group: string
+ label: string
+ icon: ComponentType<{ className?: string }>
+}
+
interface SettingsSidebarProps {
activeSection: string
plane: StandaloneSettingsPlane
groups: readonly SettingsNavigationGroup[]
hrefForSection: (section: Section) => string
items: readonly SidebarSettingsItem[]
+ outboundLinks?: readonly SettingsSidebarOutboundLink[]
isCollapsed?: boolean
showCollapsedTooltips?: boolean
-}
-
-function SidebarTooltip({
- children,
- label,
- enabled,
-}: {
- children: React.ReactElement
- label: string
- enabled: boolean
-}) {
- if (!enabled) return children
- return (
-
- {children}
- {label}
-
- )
+ backHref?: string
}
export function SettingsSidebar({
@@ -74,8 +81,10 @@ export function SettingsSidebar({
groups,
hrefForSection,
items,
+ outboundLinks = [],
isCollapsed = false,
showCollapsedTooltips = false,
+ backHref = APP_ENTRY_PATH,
}: SettingsSidebarProps) {
const scrollContainerRef = useRef(null)
const scrollContentRef = useRef(null)
@@ -85,26 +94,25 @@ export function SettingsSidebar({
const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
-
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
- const updateScrollState = () => setHasOverflowTop(container.scrollTop > 1)
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) observer.observe(scrollContentRef.current)
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [isCollapsed])
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
return (
<>
-
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
+
{/* Both stay buttons, not Links: leaving settings must run the unsaved-changes guard. */}
{SETTINGS_PLANE_CHROME[plane].showWordmark ? (
({
) : (
- requestLeave(() => router.push(WORKSPACE_HREF))}
- className={chipVariants({ fullWidth: true })}
+ requestLeave(() => router.push(backHref))}
>
- {/* The 16px slot every settings row gives its icon, so Back's label starts on their baseline. */}
-
-
-
- Back
-
+ Back
+
)}
@@ -135,23 +140,29 @@ export function SettingsSidebar
({
{groups
.map((group) => ({
...group,
items: items.filter((item) => item.group === group.key),
+ links: outboundLinks.filter((link) => link.group === group.key),
}))
- .filter((group) => group.items.length > 0)
+ .filter((group) => group.items.length > 0 || group.links.length > 0)
.map((group, index) => (
-
0 && 'mt-6', 'flex shrink-0 flex-col')}>
-
-
+
0 && SIDEBAR_SECTION_GAP_CLASS, 'shrink-0')}
+ >
+
{group.items.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
@@ -167,33 +178,66 @@ export function SettingsSidebar
({
replace
scroll={false}
aria-current={active ? 'page' : undefined}
- className={chipVariants({ active, fullWidth: true })}
+ className={cn(
+ chipVariants({ active, fullWidth: true }),
+ SIDEBAR_RAIL_CHIP_CLASS
+ )}
onNavigate={(event) => {
if (active) {
event.preventDefault()
return
}
- if (!useSettingsDirtyStore.getState().isDirty) return
+ const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
+ if (!isDirty && !navigationBlocked) return
event.preventDefault()
requestLeave(() => router.replace(href, { scroll: false }))
}}
>
-
+
{item.locked && (
-
+
Plan
-
+
)}
)
})}
+ {group.links.map((link) => {
+ const Icon = link.icon
+ return (
+
+
+
+
+
+
+
+ )
+ })}
-
+
))}
diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx
index 5a2f5ccbf68..42394caab56 100644
--- a/apps/sim/components/settings/standalone-settings-shell.tsx
+++ b/apps/sim/components/settings/standalone-settings-shell.tsx
@@ -90,9 +90,9 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
{/*
Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless
- sidebar column against the app surface, and only the content pane
- carrying the rounded border. Keep the two in step — a settings page
- should look the same whether it is reached inside a workspace or not.
+ sidebar column against the app surface, meeting the content pane on a
+ single hairline divider with no gutter. Keep the two in step — a settings
+ page should look the same whether it is reached inside a workspace or not.
*/}
-
-
+
+
diff --git a/apps/sim/connectors/auth.ts b/apps/sim/connectors/auth.ts
new file mode 100644
index 00000000000..77dc6f57382
--- /dev/null
+++ b/apps/sim/connectors/auth.ts
@@ -0,0 +1,24 @@
+import type { ConnectorAuthConfig } from '@/connectors/types'
+
+/** Whether a credential can authenticate the selected connection method. */
+export function isConnectorCredentialTypeAllowed(
+ auth: ConnectorAuthConfig,
+ accessMode: string,
+ credentialType: 'oauth' | 'service_account' | undefined
+): boolean {
+ return (
+ auth.mode !== 'oauth' ||
+ accessMode !== 'admin' ||
+ !auth.adminCredentialType ||
+ credentialType === auth.adminCredentialType
+ )
+}
+
+/** Workspace token input supported by a connector, independent of its member OAuth method. */
+export function getConnectorApiKeyConfig(
+ auth: ConnectorAuthConfig
+):
+ | Pick, 'label' | 'placeholder' | 'optional'>
+ | undefined {
+ return auth.mode === 'apiKey' ? auth : auth.apiKey
+}
diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts
index 383a8ca8164..2f55d62c6e9 100644
--- a/apps/sim/connectors/confluence/confluence.test.ts
+++ b/apps/sim/connectors/confluence/confluence.test.ts
@@ -9,14 +9,30 @@ import {
import {
buildLastModifiedClause,
confluenceConnector,
+ confluenceStorageToPlainText,
escapeCql,
- extractCursor,
isCurrentContent,
preserveConfluenceCallouts,
readIncludedLabels,
} from '@/connectors/confluence/confluence'
+import { extractCursor } from '@/connectors/confluence/cursor'
import { htmlToPlainText } from '@/connectors/utils'
+describe('Confluence service-account scopes', () => {
+ it('requests metadata and role reads needed for complete mirrored ACLs', () => {
+ expect(confluenceConnector.auth.mode).toBe('oauth')
+ if (confluenceConnector.auth.mode !== 'oauth') throw new Error('Expected OAuth authentication')
+ expect(confluenceConnector.auth.serviceAccountScopes).toEqual(
+ expect.arrayContaining([
+ 'read:content.metadata:confluence',
+ 'read:space.permission:confluence',
+ 'read:group:confluence',
+ 'read:user:confluence',
+ ])
+ )
+ })
+})
+
describe('escapeCql', () => {
it.concurrent('returns plain strings unchanged', () => {
expect(escapeCql('Engineering')).toBe('Engineering')
@@ -60,6 +76,40 @@ describe('buildLastModifiedClause', () => {
})
})
+describe('Confluence rejected credentials', () => {
+ afterEach(() => vi.unstubAllGlobals())
+
+ it.each(['discovery', 'space', 'pages', 'cql', 'content'] as const)(
+ 'preserves authenticated401 at the %s boundary',
+ async (boundary) => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('', { status: 401 }))
+ )
+ const config = {
+ domain: 'revocation-fixture.atlassian.net',
+ spaceKey: 'ENG',
+ ...(boundary === 'cql' ? { labelFilter: 'fixture' } : {}),
+ }
+ const context =
+ boundary === 'discovery'
+ ? {}
+ : { cloudId: 'cloud', ...(boundary === 'pages' ? { spaceId: 'space' } : {}) }
+ const request =
+ boundary === 'content'
+ ? confluenceConnector.getDocument('token', config, 'page', context)
+ : confluenceConnector.listDocuments('token', config, undefined, context)
+ const error = await request.catch((error: unknown) => error)
+ expect(confluenceConnector.isCredentialInvalidError?.(error)).toBe(true)
+ }
+ )
+
+ it.each([403, 404, 429, 503])('does not invalidate credentials for status%s', (status) => {
+ const error = Object.assign(new Error('Provider request failed'), { status })
+ expect(confluenceConnector.isCredentialInvalidError?.(error)).toBe(false)
+ })
+})
+
describe('confluence listing scope classification', () => {
it.concurrent('treats a token that reaches no Atlassian site as not on the site', () => {
expect(
@@ -471,3 +521,345 @@ describe('confluence incremental CQL listing', () => {
expect(cqlOfCall(1)).toBe(cqlOfCall(0))
})
})
+
+describe('confluenceStorageToPlainText', () => {
+ it('preserves rich text, word boundaries, link labels, and encoded literals', () => {
+ const storage =
+ 'Overview Unbreak able & readable.
' +
+ '' +
+ '' +
+ ' ' +
+ ']]> ' +
+ ' Next
'
+
+ expect(confluenceStorageToPlainText(storage)).toBe(
+ 'Overview Unbreakable & readable. First Second Name Value Read Next'
+ )
+ })
+
+ it('retains nested local callouts and literal code without indexing macro parameters', () => {
+ const storage =
+ 'Caution ' +
+ '#ff0000 ' +
+ 'Outer body
' +
+ 'Do not run:
xml ' +
+ ']]> ' +
+ ' ' +
+ ' '
+
+ expect(confluenceStorageToPlainText(storage)).toBe(
+ '[CALLOUT: Caution] Outer body [WARNING] Do not run: '
+ )
+ })
+
+ it('omits inclusion references, remote macro bodies, and extension metadata', () => {
+ const storage =
+ 'Public body
' +
+ 'PRIVATE:Salary ' +
+ 'private-project ' +
+ 'Cached private issue
' +
+ '' +
+ 'remote-parameters '
+
+ expect(confluenceStorageToPlainText(storage)).toBe('Public body')
+ })
+
+ it.each(['expand', 'excerpt', 'noformat'])(
+ 'retains the authored content of the %s macro',
+ (name) => {
+ const storage =
+ `` +
+ ' '
+ expect(confluenceStorageToPlainText(storage)).toBe('Locally authored content')
+ }
+ )
+})
+
+describe('Confluence permission-scoped content', () => {
+ const config = { domain: 'example.atlassian.net', spaceKey: 'ENG' }
+ const storage =
+ 'Shared handbook
' +
+ '' +
+ ' ' +
+ ' ' +
+ '' +
+ 'Local information
' +
+ ' '
+ const view = 'Shared handbook
CONFIDENTIAL SALARY DATA
Local information
'
+
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string | URL | Request) => {
+ const format = new URL(String(input)).searchParams.get('body-format')
+ return new Response(
+ JSON.stringify({
+ id: 'shared-page',
+ title: 'Shared handbook',
+ status: 'current',
+ spaceId: 'space-1',
+ version: { number: 1 },
+ body: { [format ?? 'view']: { value: format === 'storage' ? storage : view } },
+ }),
+ { headers: { 'Content-Type': 'application/json' } }
+ )
+ })
+ )
+ })
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ it.each([{ mirrorsSourceAcls: true }, { perMemberListing: true, memberId: 'member-1' }])(
+ 'keeps external restricted content out of a shared page for %j',
+ async (mode) => {
+ const document = await confluenceConnector.getDocument(
+ 'authorized-reader',
+ config,
+ 'shared-page',
+ {
+ cloudId: 'cloud-1',
+ ...mode,
+ }
+ )
+
+ expect(document?.content).toContain('Shared handbook')
+ expect(document?.content).toContain('Local information')
+ expect(document?.content).not.toContain('CONFIDENTIAL SALARY DATA')
+ expect(document?.contentHash).toContain('storage')
+ }
+ )
+
+ it('retains rendered inclusions for ordinary workspace knowledge bases', async () => {
+ const document = await confluenceConnector.getDocument(
+ 'workspace-account',
+ config,
+ 'shared-page',
+ {
+ cloudId: 'cloud-1',
+ }
+ )
+
+ expect(document?.content).toContain('CONFIDENTIAL SALARY DATA')
+ expect(document?.contentHash).toBe('confluence:view-callouts:shared-page:1')
+ })
+
+ it('rejects a missing storage body without falling back to rendered content', async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: 'shared-page',
+ version: { number: 1 },
+ body: { view: { value: view } },
+ })
+ )
+ )
+
+ await expect(
+ confluenceConnector.getDocument('token', config, 'shared-page', {
+ cloudId: 'cloud-1',
+ mirrorsSourceAcls: true,
+ })
+ ).rejects.toThrow('missing its storage body')
+ })
+
+ it.each([{ mirrorsSourceAcls: true }, { perMemberListing: true, memberId: 'member-1' }, {}])(
+ 'keeps v2, CQL, and hydration hashes consistent for %j',
+ async (mode) => {
+ const content = {
+ id: 'shared-page',
+ title: 'Shared handbook',
+ status: 'current',
+ spaceId: 'space-1',
+ version: { number: 1 },
+ }
+ vi.mocked(fetch).mockImplementation(async (input) => {
+ const url = new URL(String(input))
+ if (url.pathname.endsWith('/spaces')) {
+ return new Response(JSON.stringify({ results: [{ id: 'space-1', key: 'ENG' }] }))
+ }
+ const format = url.searchParams.get('body-format')
+ return new Response(
+ JSON.stringify(
+ format
+ ? { ...content, body: { [format]: { value: format === 'storage' ? storage : view } } }
+ : { results: [content] }
+ )
+ )
+ })
+ const context = { cloudId: 'cloud-1', ...mode }
+ const v2 = await confluenceConnector.listDocuments('token', config, undefined, { ...context })
+ const cql = await confluenceConnector.listDocuments(
+ 'token',
+ { ...config, labelFilter: 'published' },
+ undefined,
+ { ...context }
+ )
+ const hydrated = await confluenceConnector.getDocument(
+ 'token',
+ config,
+ 'shared-page',
+ context
+ )
+ const expectedHash =
+ 'mirrorsSourceAcls' in mode || 'perMemberListing' in mode
+ ? 'confluence:storage-local-body-v1:shared-page:1'
+ : 'confluence:view-callouts:shared-page:1'
+
+ expect(v2.documents[0].contentHash).toBe(expectedHash)
+ expect(cql.documents[0].contentHash).toBe(expectedHash)
+ expect(hydrated?.contentHash).toBe(expectedHash)
+ }
+ )
+})
+
+describe('confluence mirrored permissions', () => {
+ const fetchMock =
+ vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>()
+
+ function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+
+ /** A two-space site where each space is readable by one different person. */
+ function site() {
+ fetchMock.mockImplementation(async (input) => {
+ const url = new URL(String(input))
+ const path = url.pathname
+ if (path.endsWith('/api/v2/spaces')) {
+ const key = url.searchParams.get('keys')
+ return jsonResponse({ results: [{ id: key === 'ENG' ? '1' : '2', key }] })
+ }
+ if (path.endsWith('/spaces/1/permissions')) {
+ return jsonResponse({
+ results: [
+ {
+ principal: { type: 'user', id: 'acc-eng' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ }
+ if (path.endsWith('/spaces/2/permissions')) {
+ return jsonResponse({
+ results: [
+ {
+ principal: { type: 'user', id: 'acc-hr' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ }
+ if (path.includes('/restriction/byOperation/read')) {
+ return jsonResponse({ restrictions: { user: { results: [] }, group: { results: [] } } })
+ }
+ if (path.endsWith('/ancestors')) return jsonResponse({ results: [] })
+ return jsonResponse({ error: `unexpected ${path}` }, 500)
+ })
+ }
+
+ function page(externalId: string, spaceKey: string, contentType = 'page') {
+ return {
+ externalId,
+ title: externalId,
+ content: '',
+ mimeType: 'text/plain',
+ contentHash: externalId,
+ metadata: { spaceKey, contentType },
+ }
+ }
+
+ beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ /**
+ * The bug this pins: a connector over two spaces once pooled every space's
+ * readers and gave the pool to every unrestricted page, so a reader of one
+ * space could read the other's pages.
+ */
+ it("gives an unrestricted page its own space's readers, never another space's", async () => {
+ site()
+
+ const acls = await confluenceConnector.getDocumentAcls?.(
+ 'token',
+ { domain: 'example.atlassian.net', spaceKey: ['ENG', 'HR'] },
+ [page('eng-page', 'ENG'), page('hr-post', 'HR', 'blogpost')],
+ { cloudId: 'cloud-1' }
+ )
+
+ expect(acls).toEqual({
+ 'eng-page': ['s:confluence:-:acc-eng'],
+ 'hr-post': ['s:confluence:-:acc-hr'],
+ })
+ /** A blog post has no ancestors and is never asked for them. */
+ const asked = fetchMock.mock.calls.map(([input]) => new URL(String(input)).pathname)
+ expect(asked.some((path) => path.includes('/blogposts/hr-post/ancestors'))).toBe(false)
+ expect(asked.some((path) => path.includes('/pages/eng-page/ancestors'))).toBe(true)
+ expect(asked.some((path) => path.includes('/user/'))).toBe(false)
+ })
+
+ it('omits a page whose permissions could not be read and still answers for the rest', async () => {
+ site()
+ const healthy = fetchMock.getMockImplementation()!
+ fetchMock.mockImplementation(async (input, init) => {
+ if (String(input).includes('/content/broken/restriction')) {
+ return jsonResponse({ error: 'nope' }, 404)
+ }
+ return healthy(input, init)
+ })
+
+ const acls = await confluenceConnector.getDocumentAcls?.(
+ 'token',
+ { domain: 'example.atlassian.net', spaceKey: 'ENG' },
+ [page('eng-page', 'ENG'), page('broken', 'ENG')],
+ { cloudId: 'cloud-1' }
+ )
+
+ expect(acls).toEqual({ 'eng-page': ['s:confluence:-:acc-eng'] })
+ })
+
+ it('loads restrictions above the first ancestor batch even when the page and parent already restrict access', async () => {
+ site()
+ const healthy = fetchMock.getMockImplementation()!
+ fetchMock.mockImplementation(async (input, init) => {
+ const path = new URL(String(input)).pathname
+ if (path.endsWith('/pages/eng-page/ancestors'))
+ return jsonResponse({ results: [{ id: 'parent' }] })
+ if (path.endsWith('/pages/parent/ancestors'))
+ return jsonResponse({ results: [{ id: 'grandparent' }] })
+ if (path.endsWith('/ancestors')) return jsonResponse({ results: [] })
+ const match = path.match(/\/content\/([^/]+)\/restriction\/byOperation\/read/)
+ if (match) {
+ return jsonResponse({
+ restrictions: {
+ user: { results: [] },
+ group: { results: [{ id: `group-${match[1]}` }] },
+ },
+ })
+ }
+ return healthy(input, init)
+ })
+ const acls = await confluenceConnector.getDocumentAcls?.(
+ 'token',
+ { domain: 'example.atlassian.net', spaceKey: 'ENG' },
+ [page('eng-page', 'ENG')],
+ { cloudId: 'cloud-1' }
+ )
+ expect(acls?.['eng-page']).toEqual({
+ acl: ['s:confluence:-:acc-eng'],
+ requirements: expect.arrayContaining([
+ ['g:confluence:cloud-1:group-eng-page'],
+ ['g:confluence:cloud-1:group-parent'],
+ ['g:confluence:cloud-1:group-grandparent'],
+ ]),
+ })
+ })
+})
diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts
index 6ad3f97e199..7270b41c682 100644
--- a/apps/sim/connectors/confluence/confluence.ts
+++ b/apps/sim/connectors/confluence/confluence.ts
@@ -1,12 +1,31 @@
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
+import { getErrorMessage } from '@sim/utils/errors'
import * as cheerio from 'cheerio'
import {
AtlassianSiteNotAccessibleError,
AtlassianSiteNotMatchedError,
} from '@/lib/atlassian/discovery'
-import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
+import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
+import {
+ type ConfluenceRestriction,
+ confluencePageAcl,
+} from '@/lib/knowledge/access/confluence-permissions'
+import type { MirroredDocumentAcl } from '@/lib/knowledge/access/types'
+import {
+ createRetryableHttpError,
+ fetchWithRetry,
+ type RetryOptions,
+ VALIDATE_RETRY_OPTIONS,
+} from '@/lib/knowledge/documents/utils'
+import { extractCursor } from '@/connectors/confluence/cursor'
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
+import {
+ describeContent,
+ getReadRestriction,
+ listAncestorIds,
+ listSpaceReadPrincipals,
+ openConfluenceDirectory,
+} from '@/connectors/confluence/permissions'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils'
import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils'
@@ -96,6 +115,8 @@ function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio
$node.contents().each((_, child) => {
if (child.type === 'text') {
current += $(child).text()
+ } else if (child.type === 'cdata') {
+ current += $(child.children).text()
} else if (child.type === 'tag') {
const tag = child.tagName?.toLowerCase()
if (tag && INLINE_FORMATTING_TAGS.has(tag)) {
@@ -174,6 +195,64 @@ export function preserveConfluenceCallouts(html: string): string {
return $.html()
}
+const STORAGE_MACRO_SELECTOR = 'ac\\:structured-macro, ac\\:macro'
+const LOCAL_STORAGE_MACROS = new Set([
+ 'info',
+ 'note',
+ 'warning',
+ 'tip',
+ 'panel',
+ 'expand',
+ 'excerpt',
+ 'code',
+ 'noformat',
+])
+
+/**
+ * Search authorizes the containing page, not content expanded from another
+ * resource. Read authored storage text and known local macro bodies only;
+ * inclusion and third-party macros may render differently for each reader.
+ */
+export function confluenceStorageToPlainText(storage: string): string {
+ const $ = cheerio.load(
+ storage,
+ { xml: { xmlMode: false, recognizeCDATA: true, recognizeSelfClosing: true } },
+ false
+ )
+ $('ac\\:adf-extension').remove()
+
+ $(STORAGE_MACRO_SELECTOR).each((_, element) => {
+ if (!LOCAL_STORAGE_MACROS.has($(element).attr('ac:name') ?? '')) {
+ $(element).remove()
+ }
+ })
+
+ for (const element of $(STORAGE_MACRO_SELECTOR).toArray().reverse()) {
+ const macro = $(element)
+ const name = macro.attr('ac:name') ?? ''
+ const title = macro.children('ac\\:parameter[ac\\:name="title"]').text().trim()
+ const body = extractBlockJoinedText(
+ $,
+ macro.children('ac\\:rich-text-body, ac\\:plain-text-body')
+ )
+ const label =
+ name === 'panel'
+ ? title
+ ? `[CALLOUT: ${title}]`
+ : '[CALLOUT]'
+ : CALLOUT_LABELS[name === 'info' ? 'information' : name]
+ const text = [label, name === 'panel' ? '' : title, body].filter(Boolean).join(' ')
+ macro.replaceWith($('
').text(text))
+ }
+
+ $('ac\\:parameter, ac\\:default-parameter, script, style').remove()
+ return extractBlockJoinedText($, $.root()).replace(/\s+/g, ' ').trim()
+}
+
+function usesPermissionScopedContent(syncContext?: Record): boolean {
+ return syncContext?.perMemberListing === true || syncContext?.mirrorsSourceAcls === true
+}
+
/**
* Escapes a value for use inside CQL double-quoted strings.
*/
@@ -219,32 +298,14 @@ export function readIncludedLabels(page: Record): string[] {
return results.map((label) => String(label.name ?? '')).filter(Boolean)
}
-/**
- * Extracts the `cursor` query value from a relative `_links.next` URL. Both the
- * v2 endpoints and the v1 CQL search return the next page as a relative path
- * carrying an opaque cursor, so the value has to be parsed back out rather than
- * derived.
- */
-export function extractCursor(nextLink: unknown): string | undefined {
- if (typeof nextLink !== 'string' || !nextLink) return undefined
- try {
- return new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
- } catch {
- return undefined
- }
-}
-
/**
* Body representation marker embedded in the contentHash. Bumping this
- * invalidates every previously-synced Confluence document so a one-time
- * re-hydration picks up content newly reachable by the current extraction
- * (e.g. the switch from `storage` to rendered `view`, which expands Include
- * Page / Excerpt macros; or `preserveConfluenceCallouts`, which stops
- * flattening panel/info/note/warning/tip macros into indistinguishable plain
- * text). Without it, already-indexed pages whose version is unchanged
- * classify as `unchanged` and keep their stale (pre-fix) content.
+ * causes the next complete listing to rehydrate pages even if their version
+ * is unchanged. Search must replace rendered inclusions with authored content;
+ * ordinary knowledge bases retain their existing rendered representation.
*/
const CONTENT_REPRESENTATION = 'view-callouts'
+const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v1'
/**
* Produces a canonical metadata stub with a deterministic contentHash that
@@ -254,14 +315,22 @@ function pageToStub(
page: Record,
options: {
spaceId?: unknown
+ /** The space's key, which the permission pass resolves the page's space from. */
+ spaceKey?: string
+ /** `page` or `blogpost`; only a page has ancestors to inherit restrictions from. */
+ contentType?: string
labels?: string[]
sourceUrl?: string
- } = {}
+ } = {},
+ syncContext?: Record
): ExternalDocument {
const version = page.version as Record | undefined
const versionNumber = version?.number as number | undefined
const lastModified = (version?.createdAt ?? version?.when ?? '') as string
const versionKey = versionNumber ?? lastModified
+ const representation = usesPermissionScopedContent(syncContext)
+ ? SCOPED_CONTENT_REPRESENTATION
+ : CONTENT_REPRESENTATION
return {
externalId: String(page.id),
@@ -270,9 +339,11 @@ function pageToStub(
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: options.sourceUrl,
- contentHash: `confluence:${CONTENT_REPRESENTATION}:${page.id}:${versionKey}`,
+ contentHash: `confluence:${representation}:${page.id}:${versionKey}`,
metadata: {
spaceId: options.spaceId,
+ spaceKey: options.spaceKey,
+ contentType: options.contentType,
status: page.status,
version: versionNumber,
labels: options.labels ?? [],
@@ -284,21 +355,191 @@ function pageToStub(
/**
* Converts a v1 CQL search result item to a lightweight metadata stub.
*/
-function cqlResultToStub(item: Record, domain: string): ExternalDocument {
+function cqlResultToStub(
+ item: Record,
+ domain: string,
+ syncContext?: Record
+): ExternalDocument {
const links = item._links as Record | undefined
const metadata = item.metadata as Record | undefined
const labelsWrapper = metadata?.labels as Record | undefined
const labelResults = (labelsWrapper?.results || []) as Record[]
const labels = labelResults.map((l) => l.name as string)
- return pageToStub(item, {
- spaceId: (item.space as Record)?.key,
- labels,
- sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
+ const spaceKey = (item.space as Record)?.key
+ return pageToStub(
+ item,
+ {
+ spaceId: spaceKey,
+ spaceKey: typeof spaceKey === 'string' ? spaceKey : undefined,
+ contentType: typeof item.type === 'string' ? item.type : undefined,
+ labels,
+ sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
+ },
+ syncContext
+ )
+}
+
+/**
+ * The site's cloud id, memoised on the run so it is discovered once per sync
+ * rather than once per call — and taken from the credential where a service
+ * account already carries it, since its API token cannot call
+ * `accessible-resources` to discover one.
+ */
+async function resolveCloudId(
+ accessToken: string,
+ sourceConfig: Record,
+ syncContext?: Record,
+ retryOptions?: RetryOptions
+): Promise {
+ const cached = syncContext?.cloudId
+ if (typeof cached === 'string' && cached) return cached
+ const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
+ const cloudId = await getConfluenceCloudId(domain, accessToken, retryOptions)
+ if (syncContext) syncContext.cloudId = cloudId
+ return cloudId
+}
+
+/**
+ * The provider segment of every Confluence group token. Fixed, and baked into
+ * stored ACLs, so it must never change.
+ */
+const CONFLUENCE_ACL_PROVIDER_ID = 'confluence'
+
+/**
+ * One in-flight promise per key, so a value fetched for one page is shared by
+ * every other page that needs it — an ancestor's restriction is consulted by
+ * all its descendants, and a space's readers by every page in it.
+ */
+function memoizeAsync(load: (key: K) => Promise): (key: K) => Promise {
+ const cache = new Map>()
+ return (key: K) => {
+ let pending = cache.get(key)
+ if (!pending) {
+ pending = load(key)
+ cache.set(key, pending)
+ }
+ return pending
+ }
+}
+
+/** Pages whose restrictions are resolved at once. Bounded to keep a crawl responsive. */
+const ACL_CONCURRENCY = 8
+
+/** Where a listed piece of content lives, as the permission pass needs it. */
+interface ContentLocation {
+ spaceId: string
+ contentType: string
+}
+
+/**
+ * Resolves who may read each listed page.
+ *
+ * Confluence reports a page's restrictions only when asked for that page, so
+ * unlike Drive this cannot ride along with the listing. Two things are cached
+ * for the batch: each space's read principals and each page's restriction,
+ * which may be consulted by many descendants.
+ *
+ * A page falls back to *its own* space's readers, never the union of every
+ * configured space: a connector over two spaces must not let a reader of one
+ * into the unrestricted pages of the other.
+ *
+ * A page whose restrictions could not be read this run is omitted, which the
+ * engine stores as readable by nobody, and the rest of the batch still
+ * resolves — the same per-document containment Drive has.
+ */
+async function resolveConfluenceAcls(
+ accessToken: string,
+ sourceConfig: Record,
+ documents: readonly ExternalDocument[],
+ syncContext?: Record
+): Promise> {
+ const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
+
+ const spaceIdForKey = memoizeAsync((spaceKey: string) =>
+ resolveSpaceId(cloudId, accessToken, spaceKey)
+ )
+ const spacePrincipalsFor = memoizeAsync((spaceId: string) =>
+ listSpaceReadPrincipals(cloudId, accessToken, spaceId)
+ )
+ const readRestriction = memoizeAsync((contentId: string) =>
+ getReadRestriction(cloudId, accessToken, contentId)
+ )
+
+ /** The listing usually says where a page lives; anything it did not describe is asked. */
+ const locate = async (doc: ExternalDocument): Promise => {
+ const spaceKey = doc.metadata?.spaceKey
+ const contentType = doc.metadata?.contentType
+ if (typeof spaceKey === 'string' && spaceKey) {
+ return {
+ spaceId: await spaceIdForKey(spaceKey),
+ contentType: typeof contentType === 'string' ? contentType : 'page',
+ }
+ }
+ return describeContent(cloudId, accessToken, doc.externalId)
+ }
+
+ /** One entry per page whose permissions this run could read in full. */
+ const resolved = new Map()
+ let unreadable = 0
+ await mapWithConcurrency(documents, ACL_CONCURRENCY, async (doc) => {
+ const externalId = doc.externalId
+ try {
+ const location = await locate(doc)
+ if (!location) {
+ unreadable += 1
+ return
+ }
+ const own = await readRestriction(externalId)
+ /**
+ * Every ancestor restriction still applies when the page has its own.
+ * A blog post has no ancestors to inherit from.
+ */
+ const chain: ConfluenceRestriction[] = [own]
+ if (location.contentType !== 'blogpost') {
+ for (const ancestorId of await listAncestorIds(cloudId, accessToken, externalId)) {
+ const restriction = await readRestriction(ancestorId)
+ chain.push(restriction)
+ }
+ }
+ await spacePrincipalsFor(location.spaceId)
+ resolved.set(externalId, { spaceId: location.spaceId, chain })
+ } catch (error) {
+ unreadable += 1
+ logger.warn("Could not read a page's permissions; it stays readable by nobody", {
+ cloudId,
+ externalId,
+ error: getErrorMessage(error),
+ })
+ }
})
+
+ const acls: Record = {}
+ for (const [externalId, { spaceId, chain }] of resolved) {
+ const result = confluencePageAcl({
+ spacePrincipals: await spacePrincipalsFor(spaceId),
+ restrictionChain: chain,
+ providerId: CONFLUENCE_ACL_PROVIDER_ID,
+ tenantId: cloudId,
+ })
+ acls[externalId] =
+ result.requirements.length > 0
+ ? { acl: result.acl, requirements: result.requirements }
+ : result.acl
+ }
+
+ if (unreadable > 0) {
+ logger.warn('Some Confluence pages had unreadable permissions and stay readable by nobody', {
+ cloudId,
+ unreadable,
+ })
+ }
+ return acls
}
export const confluenceConnector: ConnectorConfig = {
+ isCredentialInvalidError: (error) =>
+ error instanceof Error && 'status' in error && error.status === 401,
...confluenceConnectorMeta,
listDocuments: async (
@@ -318,11 +559,7 @@ export const confluenceConnector: ConnectorConfig = {
throw new Error('At least one space key is required')
}
- let cloudId = syncContext?.cloudId as string | undefined
- if (!cloudId) {
- cloudId = await getConfluenceCloudId(domain, accessToken)
- if (syncContext) syncContext.cloudId = cloudId
- }
+ const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
/**
* Route through CQL when a label filter is set, when multiple spaces are
@@ -379,6 +616,15 @@ export const confluenceConnector: ConnectorConfig = {
)
},
+ getDocumentAcls: resolveConfluenceAcls,
+
+ openDirectory: async (accessToken, sourceConfig, syncContext) =>
+ openConfluenceDirectory(
+ CONFLUENCE_ACL_PROVIDER_ID,
+ await resolveCloudId(accessToken, sourceConfig, syncContext),
+ accessToken
+ ),
+
getDocument: async (
accessToken: string,
sourceConfig: Record,
@@ -386,24 +632,13 @@ export const confluenceConnector: ConnectorConfig = {
syncContext?: Record
): Promise => {
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
- let cloudId = syncContext?.cloudId as string | undefined
- if (!cloudId) {
- cloudId = await getConfluenceCloudId(domain, accessToken)
- if (syncContext) syncContext.cloudId = cloudId
- }
+ const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
- /**
- * Fetch the `view` representation rather than `storage`. Storage format only
- * carries unexpanded macro references (e.g. Include Page / Excerpt Include),
- * so "mirrored" content that pulls in another page's body is stripped to
- * nothing by `htmlToPlainText`. The `view` representation is server-rendered
- * HTML with those macros expanded inline, so included content is indexed too.
- * The v2 single-item GET (`/pages/{id}`, `/blogposts/{id}`) supports
- * `body-format=view`; only the bulk list endpoints are limited to storage/adf.
- */
+ const scopedContent = usesPermissionScopedContent(syncContext)
+ const bodyFormat = scopedContent ? 'storage' : 'view'
let page: Record | null = null
for (const endpoint of ['pages', 'blogposts']) {
- const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${encodeURIComponent(externalId)}?body-format=view&include-labels=true`
+ const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${encodeURIComponent(externalId)}?body-format=${bodyFormat}&include-labels=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
@@ -416,6 +651,7 @@ export const confluenceConnector: ConnectorConfig = {
page = await response.json()
break
}
+ if (response.status === 401) throw await createRetryableHttpError(response)
if (response.status !== 404) {
throw new Error(`Failed to get Confluence content: ${response.status}`)
}
@@ -423,16 +659,25 @@ export const confluenceConnector: ConnectorConfig = {
if (!page || !isCurrentContent(page)) return null
const body = page.body as Record | undefined
- const view = body?.view as Record | undefined
- const rawContent = (view?.value as string) || ''
- const plainText = htmlToPlainText(preserveConfluenceCallouts(rawContent))
+ const representation = body?.[bodyFormat] as Record | undefined
+ if (scopedContent && typeof representation?.value !== 'string') {
+ throw new Error('Confluence content is missing its storage body')
+ }
+ const rawContent = (representation?.value as string) || ''
+ const plainText = scopedContent
+ ? confluenceStorageToPlainText(rawContent)
+ : htmlToPlainText(preserveConfluenceCallouts(rawContent))
const links = page._links as Record | undefined
- const stub = pageToStub(page, {
- spaceId: page.spaceId,
- labels: readIncludedLabels(page),
- sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
- })
+ const stub = pageToStub(
+ page,
+ {
+ spaceId: page.spaceId,
+ labels: readIncludedLabels(page),
+ sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
+ },
+ syncContext
+ )
return {
...stub,
@@ -443,7 +688,8 @@ export const confluenceConnector: ConnectorConfig = {
validateConfig: async (
accessToken: string,
- sourceConfig: Record
+ sourceConfig: Record,
+ syncContext?: Record
): Promise<{ valid: boolean; error?: string }> => {
const domain = sourceConfig.domain as string
const spaceKeys = parseMultiValue(sourceConfig.spaceKey)
@@ -458,7 +704,12 @@ export const confluenceConnector: ConnectorConfig = {
}
try {
- const cloudId = await getConfluenceCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS)
+ const cloudId = await resolveCloudId(
+ accessToken,
+ sourceConfig,
+ syncContext,
+ VALIDATE_RETRY_OPTIONS
+ )
const params = new URLSearchParams()
for (const key of spaceKeys) params.append('keys', key)
params.append('limit', String(Math.max(spaceKeys.length, 1)))
@@ -489,7 +740,7 @@ export const confluenceConnector: ConnectorConfig = {
}
return { valid: true }
} catch (error) {
- return { valid: false, error: toError(error).message || 'Failed to validate configuration' }
+ return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') }
}
},
@@ -561,6 +812,7 @@ async function listDocumentsV2(
})
if (!response.ok) {
+ if (response.status === 401) throw await createRetryableHttpError(response)
const errorText = await response.text()
logger.error(`Failed to list Confluence ${endpoint}`, {
status: response.status,
@@ -576,10 +828,16 @@ async function listDocumentsV2(
.filter(isCurrentContent)
.map((page) => {
const links = page._links as Record | undefined
- return pageToStub(page, {
- spaceId: page.spaceId,
- sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
- })
+ return pageToStub(
+ page,
+ {
+ spaceId: page.spaceId,
+ spaceKey,
+ contentType,
+ sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
+ },
+ syncContext
+ )
})
const nextCursor = extractCursor((data._links as Record | undefined)?.next)
@@ -805,6 +1063,7 @@ async function listDocumentsViaCql(
})
if (!response.ok) {
+ if (response.status === 401) throw await createRetryableHttpError(response)
const errorText = await response.text()
logger.error('Failed to search Confluence via CQL', {
status: response.status,
@@ -818,7 +1077,7 @@ async function listDocumentsViaCql(
const allDocuments: ExternalDocument[] = (results as Record[])
.filter(isCurrentContent)
- .map((item) => cqlResultToStub(item, domain))
+ .map((item) => cqlResultToStub(item, domain, syncContext))
/**
* Trim to the remaining budget. Trimming stops the walk (`hitLimit` below is
@@ -869,6 +1128,7 @@ async function resolveSpaceId(
})
if (!response.ok) {
+ if (response.status === 401) throw await createRetryableHttpError(response)
throw new Error(`Failed to resolve space key "${spaceKey}": ${response.status}`)
}
diff --git a/apps/sim/connectors/confluence/cursor.ts b/apps/sim/connectors/confluence/cursor.ts
new file mode 100644
index 00000000000..dae7f2146b0
--- /dev/null
+++ b/apps/sim/connectors/confluence/cursor.ts
@@ -0,0 +1,18 @@
+/**
+ * Extracts the `cursor` query value from a relative `_links.next` URL. Both the
+ * v2 endpoints and the v1 CQL search return the next page as a relative path
+ * carrying an opaque cursor, so the value has to be parsed back out rather than
+ * derived.
+ *
+ * A leaf of its own because both the content listing and the permission
+ * listings page the same way, and a second parser that read a link slightly
+ * differently would silently stop paginating.
+ */
+export function extractCursor(nextLink: unknown): string | undefined {
+ if (typeof nextLink !== 'string' || !nextLink) return undefined
+ try {
+ return new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/sim/connectors/confluence/meta.ts b/apps/sim/connectors/confluence/meta.ts
index ed114e242ea..63f683648d1 100644
--- a/apps/sim/connectors/confluence/meta.ts
+++ b/apps/sim/connectors/confluence/meta.ts
@@ -2,6 +2,8 @@ import { ConfluenceIcon } from '@/components/icons'
import type { ConnectorMeta } from '@/connectors/types'
export const confluenceConnectorMeta: ConnectorMeta = {
+ search: true,
+ searchDocsUrl: 'https://docs.sim.ai/search/confluence',
id: 'confluence',
name: 'Confluence',
description: 'Sync pages from a Confluence space',
@@ -20,6 +22,21 @@ export const confluenceConnectorMeta: ConnectorMeta = {
'search:confluence',
'offline_access',
],
+ /** Mirroring also reads ancestor restrictions, space roles, and user/group identities. */
+ serviceAccountScopes: [
+ 'read:confluence-content.all',
+ 'read:page:confluence',
+ 'read:blogpost:confluence',
+ 'read:space:confluence',
+ 'read:label:confluence',
+ 'search:confluence',
+ 'read:confluence-space.summary',
+ 'read:content.metadata:confluence',
+ 'read:space.permission:confluence',
+ 'read:confluence-user',
+ 'read:user:confluence',
+ 'read:group:confluence',
+ ],
},
/**
@@ -34,6 +51,15 @@ export const confluenceConnectorMeta: ConnectorMeta = {
/** CQL search under a member's token returns only content that member may view. */
permissionScopedListing: { capFieldIds: ['maxPages'] },
+ /**
+ * Space permissions and page restrictions are both readable, so one crawl
+ * under an administrative credential can mirror them. Unlike Drive they come
+ * back per page rather than with the listing, which is what
+ * `getDocumentAcls` exists for.
+ */
+ mirrorsSourceAcls: true,
+ requiresMemberIdentity: true,
+
configFields: [
{
id: 'domain',
diff --git a/apps/sim/connectors/confluence/permissions.test.ts b/apps/sim/connectors/confluence/permissions.test.ts
new file mode 100644
index 00000000000..7c93dd1c50e
--- /dev/null
+++ b/apps/sim/connectors/confluence/permissions.test.ts
@@ -0,0 +1,507 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ getReadRestriction,
+ listAncestorIds,
+ listGroupMemberTokens,
+ listSpaceReadPrincipals,
+} from '@/connectors/confluence/permissions'
+
+const mockFetch = vi.fn()
+const CLOUD = 'cloud-1'
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+})
+
+describe('listSpaceReadPrincipals', () => {
+ it('keeps only the permission that grants reading the space', () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'user', id: 'acc-1' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ {
+ principal: { type: 'group', id: 'grp-1' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ {
+ principal: { type: 'user', id: 'acc-2' },
+ operation: { key: 'delete', targetType: 'page' },
+ },
+ {
+ principal: { type: 'user', id: 'acc-3' },
+ operation: { key: 'read', targetType: 'page' },
+ },
+ ],
+ })
+ )
+
+ return expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([
+ { kind: 'user', id: 'acc-1' },
+ { kind: 'group', id: 'grp-1' },
+ ])
+ })
+
+ it('never grants public access for anonymous or unknown access classes', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'ACCESS_CLASS', id: 'anonymous-users' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ {
+ principal: { type: 'access-class', id: 'unknown-class' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([])
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('expands flattened licensed and admin classes into unique provider groups', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: ['ALL_LICENSED_USERS', 'ALL_PRODUCT_ADMINS', 'ALL_LICENSED_USERS'].map((id) => ({
+ principal: { type: 'access-class', id },
+ operation: { key: 'read', targetType: 'space' },
+ })),
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'staff' }, { id: 'both' }] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'admins' }, { id: 'both' }] }))
+
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([
+ { kind: 'group', id: 'staff' },
+ { kind: 'group', id: 'both' },
+ { kind: 'group', id: 'admins' },
+ ])
+ expect(
+ mockFetch.mock.calls
+ .slice(1)
+ .map(([url]) => new URL(String(url)).searchParams.get('accessType'))
+ ).toEqual(['user', 'admin'])
+ expect(
+ mockFetch.mock.calls.every(([url]) => String(url).includes(`/ex/confluence/${CLOUD}/`))
+ ).toBe(true)
+ })
+
+ it('includes admin-only licensed users even when no admin class is assigned', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'ACCESS_CLASS', id: 'ALL_LICENSED_USERS' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'admins' }] }))
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([
+ { kind: 'group', id: 'admins' },
+ ])
+ })
+
+ it('expands admin role assignments without granting ordinary licensed users', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'role', id: 'reader-role' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ { principal: { principalType: 'ACCESS_CLASS', principalId: 'all-product-admins' } },
+ { principal: { principalType: 'USER', principalId: 'direct-user' } },
+ { principal: { principalType: 'GROUP', principalId: 'admins' } },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'admins' }] }))
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([
+ { kind: 'user', id: 'direct-user' },
+ { kind: 'group', id: 'admins' },
+ ])
+ expect(String(mockFetch.mock.calls[1][0])).toContain('/spaces/space-1/role-assignments?')
+ expect(new URL(String(mockFetch.mock.calls[2][0])).searchParams.get('accessType')).toBe('admin')
+ })
+
+ it('drains access groups despite short pages and preserves its access filter', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'access-class', id: 'ALL_PRODUCT_ADMINS' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [{ id: 'first' }],
+ size: 1,
+ _links: { next: '/rest/api/group?start=1' },
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'second' }] }))
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([
+ { kind: 'group', id: 'first' },
+ { kind: 'group', id: 'second' },
+ ])
+ const query = new URL(String(mockFetch.mock.calls[2][0])).searchParams
+ expect(query.get('accessType')).toBe('admin')
+ expect(query.get('start')).toBe('1')
+ expect(query.get('limit')).toBe('200')
+ })
+
+ it.each(['denied', 'empty-continuation', 'missing-id'])(
+ 'fails closed on %s access group enumeration',
+ async (failure) => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'access-class', id: 'ALL_PRODUCT_ADMINS' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [{ id: 'first' }],
+ size: 1,
+ _links: { next: '/rest/api/group?start=1' },
+ })
+ )
+ .mockResolvedValueOnce(
+ failure === 'denied'
+ ? jsonResponse({}, 403)
+ : failure === 'empty-continuation'
+ ? jsonResponse({ results: [], _links: { next: '/rest/api/group?start=1' } })
+ : jsonResponse({ results: [{ name: 'not-an-id' }] })
+ )
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow()
+ }
+ )
+
+ it('legitimately grants nobody when an access class has no groups', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'access-class', id: 'ALL_PRODUCT_ADMINS' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [] }))
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([])
+ })
+
+ it('bounds an access group provider that never terminates', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'access-class', id: 'ALL_PRODUCT_ADMINS' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+ .mockImplementation(async () =>
+ jsonResponse({ results: [{ id: 'repeated' }], _links: { next: '/rest/api/group?start=1' } })
+ )
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
+ 'exceeded 100 pages'
+ )
+ expect(mockFetch).toHaveBeenCalledTimes(101)
+ })
+
+ it('follows the cursor rather than reporting the first page as the whole space', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'user', id: 'acc-1' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ _links: { next: '/wiki/api/v2/spaces/1/permissions?cursor=abc' },
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ {
+ principal: { type: 'user', id: 'acc-2' },
+ operation: { key: 'read', targetType: 'space' },
+ },
+ ],
+ })
+ )
+
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toHaveLength(2)
+ expect(String(mockFetch.mock.calls[1][0])).toContain('cursor=abc')
+ })
+
+ it('throws rather than returning a space it could not read in full', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'nope' }, 403))
+
+ await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow('403')
+ })
+})
+
+describe('getReadRestriction', () => {
+ /**
+ * The distinction the whole ancestor walk rests on: empty means the page is
+ * unrestricted and inherits, not that it is restricted to nobody.
+ */
+ it('reports an unrestricted page as inheriting, not as restricted to nobody', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({ restrictions: { user: { results: [] }, group: { results: [] } } })
+ )
+
+ await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toBeNull()
+ })
+
+ it('keeps provider account IDs without relying on disclosed email', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ restrictions: {
+ user: { results: [{ accountId: 'acc-1', email: 'alice@corp.com' }] },
+ group: { results: [{ id: 'grp-1' }] },
+ },
+ })
+ )
+
+ await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toEqual([
+ { kind: 'user', id: 'acc-1' },
+ { kind: 'group', id: 'grp-1' },
+ ])
+ })
+
+ it.each([
+ {},
+ { restrictions: {} },
+ { restrictions: { user: { results: [] }, group: {} } },
+ { restrictions: { user: {}, group: { results: [] } } },
+ { restrictions: { user: { results: [] }, group: { results: {} } } },
+ ])(
+ 'rejects incomplete restriction data rather than inheriting space access: %j',
+ async (body) => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(body))
+ await expect(getReadRestriction(CLOUD, 'token', 'page-1')).rejects.toThrow(
+ 'expanded read-restriction collection'
+ )
+ }
+ )
+
+ it('rejects an incomplete continuation after reading some restrictions', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ restrictions: {
+ user: { results: Array.from({ length: 250 }, (_, i) => ({ accountId: `user-${i}` })) },
+ group: { results: [] },
+ },
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({}))
+ await expect(getReadRestriction(CLOUD, 'token', 'page-1')).rejects.toThrow(
+ 'expanded read-restriction collection'
+ )
+ })
+
+ it('keeps a withheld address as absent rather than inventing one', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ restrictions: {
+ user: { results: [{ accountId: 'acc-1', email: null }] },
+ group: { results: [] },
+ },
+ })
+ )
+
+ await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toEqual([
+ { kind: 'user', id: 'acc-1' },
+ ])
+ })
+})
+
+describe('listAncestorIds', () => {
+ /** Each ancestor restriction remains a separate required grant. */
+ it('returns ancestors closest parent first', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({ results: [{ id: 'root' }, { id: 'section' }, { id: 'parent' }] })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [] }))
+
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual([
+ 'parent',
+ 'section',
+ 'root',
+ ])
+ expect(String(mockFetch.mock.calls[0][0])).toContain('/api/v2/pages/page-1/ancestors')
+ })
+
+ it('continues from the first ancestor despite a short batch without next links', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ { id: 'section', type: 'page' },
+ { id: 'parent', type: 'page' },
+ ],
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'root', type: 'page' }] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [] }))
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual([
+ 'parent',
+ 'section',
+ 'root',
+ ])
+ expect(mockFetch.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
+ '/ex/confluence/cloud-1/wiki/api/v2/pages/page-1/ancestors',
+ '/ex/confluence/cloud-1/wiki/api/v2/pages/section/ancestors',
+ '/ex/confluence/cloud-1/wiki/api/v2/pages/root/ancestors',
+ ])
+ })
+
+ it('continues through a folder using its own ancestor endpoint', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'folder', type: 'folder' }] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'root', type: 'page' }] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [] }))
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual(['folder', 'root'])
+ expect(String(mockFetch.mock.calls[1][0])).toContain('/folders/folder/ancestors?')
+ })
+
+ it('refuses cyclic ancestors instead of returning a partial grant chain', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'parent' }] }))
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'page-1' }] }))
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).rejects.toThrow('cyclic')
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('fails closed when a later ancestor page is unreadable', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse({ results: [{ id: 'parent' }] }))
+ .mockResolvedValueOnce(jsonResponse({}, 403))
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).rejects.toThrow('403')
+ })
+
+ it('bounds a provider that never reaches a root', async () => {
+ let page = 0
+ mockFetch.mockImplementation(async () =>
+ jsonResponse({ results: [{ id: `ancestor-${page++}` }] })
+ )
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).rejects.toThrow('exceeded 100 pages')
+ expect(mockFetch).toHaveBeenCalledTimes(100)
+ })
+
+ it('reports a top-level page as having no ancestors', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] }))
+
+ await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual([])
+ })
+})
+
+describe('listGroupMemberTokens', () => {
+ const GROUP = { id: 'grp-1' }
+
+ it('uses opaque account IDs even when every email is hidden', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ results: [
+ { accountId: '712020:Alice', accountType: 'atlassian', email: null },
+ { accountId: 'app-subject', accountType: 'app' },
+ { accountId: '712020:Alice', email: null },
+ ],
+ })
+ )
+ await expect(listGroupMemberTokens(CLOUD, 'token', GROUP)).resolves.toEqual({
+ group: GROUP,
+ memberTokens: ['s:confluence:-:712020:Alice', 's:confluence:-:app-subject'],
+ complete: true,
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const request = new URL(String(mockFetch.mock.calls[0][0]))
+ expect(request.pathname).toContain('/group/grp-1/membersByGroupId')
+ expect(request.searchParams.get('limit')).toBe('200')
+ })
+
+ it('drains all member pages and preserves case-sensitive identities', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({ results: [{ accountId: 'Alice' }], size: 1, _links: { next: '/next' } })
+ )
+ .mockResolvedValueOnce(jsonResponse({ results: [{ accountId: 'alice' }] }))
+ await expect(listGroupMemberTokens(CLOUD, 'token', GROUP)).resolves.toEqual({
+ group: GROUP,
+ memberTokens: ['s:confluence:-:Alice', 's:confluence:-:alice'],
+ complete: true,
+ })
+ expect(new URL(String(mockFetch.mock.calls[1][0])).searchParams.get('start')).toBe('1')
+ })
+
+ it('fails instead of freshening a partial membership on provider failure', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse({ results: [{ accountId: 'alice' }], _links: { next: '/next' } })
+ )
+ .mockResolvedValueOnce(jsonResponse({}, 403))
+ await expect(listGroupMemberTokens(CLOUD, 'token', GROUP)).rejects.toThrow('403')
+ })
+
+ it('refuses incomplete source identities instead of guessing from email', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ results: [{ email: 'alice@example.com' }] }))
+ await expect(listGroupMemberTokens(CLOUD, 'token', GROUP)).rejects.toThrow('account id')
+ })
+
+ it('allows a confirmed empty group to revoke all former memberships', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] }))
+ await expect(listGroupMemberTokens(CLOUD, 'token', GROUP)).resolves.toEqual({
+ group: GROUP,
+ memberTokens: [],
+ complete: true,
+ })
+ })
+})
diff --git a/apps/sim/connectors/confluence/permissions.ts b/apps/sim/connectors/confluence/permissions.ts
new file mode 100644
index 00000000000..14f3c34b05b
--- /dev/null
+++ b/apps/sim/connectors/confluence/permissions.ts
@@ -0,0 +1,360 @@
+import { createLogger } from '@sim/logger'
+import {
+ type ConfluencePrincipal,
+ type ConfluenceRestriction,
+ confluenceSubjectToken,
+} from '@/lib/knowledge/access/confluence-permissions'
+import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
+import { extractCursor } from '@/connectors/confluence/cursor'
+import type {
+ ConnectorDirectory,
+ ConnectorDirectoryGroup,
+ ConnectorDirectoryMembership,
+} from '@/connectors/types'
+
+const logger = createLogger('ConfluencePermissions')
+
+const PAGE_SIZE = 250
+const GROUP_PAGE_SIZE = 200
+
+/** Bounds provider pagination, including malformed continuation responses. */
+const MAX_PAGES = 100
+
+function apiBase(cloudId: string): string {
+ return `https://api.atlassian.com/ex/confluence/${cloudId}/wiki`
+}
+
+/**
+ * A GET with the same transient-error retry every other Confluence call gets.
+ * With `allowNotFound`, a 404 resolves to null instead of throwing.
+ */
+async function getJson(url: string, accessToken: string): Promise
+async function getJson(
+ url: string,
+ accessToken: string,
+ options: { allowNotFound: true }
+): Promise
+async function getJson(
+ url: string,
+ accessToken: string,
+ options?: { allowNotFound: true }
+): Promise {
+ const response = await fetchWithRetry(url, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
+ })
+ if (response.status === 404 && options?.allowNotFound) return null
+ if (!response.ok) {
+ throw new Error(`Confluence request failed: ${response.status} ${response.statusText}`)
+ }
+ return (await response.json()) as T
+}
+
+/**
+ * Drains a v2 collection by following `_links.next`, the only termination
+ * Confluence documents. The requested page size is a ceiling the server may
+ * lower, so a page shorter than it proves nothing.
+ */
+async function drainV2(url: string, accessToken: string, what: string): Promise {
+ const items: T[] = []
+ let cursor: string | undefined
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const query = new URLSearchParams({ limit: String(PAGE_SIZE) })
+ if (cursor) query.set('cursor', cursor)
+ const body = await getJson<{ results?: T[]; _links?: { next?: string } }>(
+ `${url}?${query.toString()}`,
+ accessToken
+ )
+ items.push(...(body.results ?? []))
+ cursor = extractCursor(body._links?.next)
+ if (!cursor) return items
+ }
+ throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages`)
+}
+
+/**
+ * Drains a v1 offset collection. The v1 envelope echoes `size` and `limit`
+ * and links the next page; a page is the last when no next link follows it.
+ */
+async function drainV1(url: string, accessToken: string, what: string): Promise {
+ const items: T[] = []
+ const requestUrl = new URL(url)
+ requestUrl.searchParams.set('limit', String(GROUP_PAGE_SIZE))
+ let start = 0
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ requestUrl.searchParams.set('start', String(start))
+ const body = await getJson<{
+ results?: T[]
+ size?: number
+ _links?: { next?: string }
+ }>(requestUrl.toString(), accessToken)
+ const results = body.results ?? []
+ items.push(...results)
+ if (!body._links?.next) return items
+ if (results.length === 0) throw new Error(`Confluence ${what} returned an empty continuation`)
+ start += body.size || results.length
+ }
+ throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages`)
+}
+
+interface SpacePermissionEntry {
+ principal?: { type?: string; id?: string }
+ operation?: { key?: string; targetType?: string }
+}
+
+interface SpaceRoleAssignment {
+ principal?: { principalType?: string; principalId?: string }
+}
+
+/**
+ * Space roles include read access. Their assignments may be flattened into
+ * permission entries or returned separately. Licensed-user and product-admin
+ * classes resolve through Confluence's accessType group filter, never names.
+ */
+export async function listSpaceReadPrincipals(
+ cloudId: string,
+ accessToken: string,
+ spaceId: string
+): Promise {
+ const entries = await drainV2(
+ `${apiBase(cloudId)}/api/v2/spaces/${encodeURIComponent(spaceId)}/permissions`,
+ accessToken,
+ 'space permissions'
+ )
+
+ const principals: ConfluencePrincipal[] = []
+ const accessTypes = new Set<'user' | 'admin'>()
+ let grantedToRole = false
+ let unmapped = 0
+ const addPrincipal = (type: string | undefined, id: string | undefined) => {
+ if (!id) {
+ unmapped += 1
+ } else if (type === 'user' || type === 'group') {
+ principals.push({ kind: type, id })
+ } else if (type === 'access-class') {
+ const accessClass = id.toLowerCase().replaceAll('_', '-')
+ if (accessClass === 'all-licensed-users') {
+ accessTypes.add('user')
+ /** Confluence app admins also have licensed app access. */
+ accessTypes.add('admin')
+ } else if (accessClass === 'all-product-admins') accessTypes.add('admin')
+ else unmapped += 1
+ } else {
+ unmapped += 1
+ }
+ }
+ for (const entry of entries) {
+ if (entry.operation?.key !== 'read' || entry.operation.targetType !== 'space') continue
+ const id = entry.principal?.id
+ const type = entry.principal?.type?.toLowerCase().replaceAll('_', '-')
+ if (type === 'role') {
+ grantedToRole = true
+ continue
+ }
+ addPrincipal(type, id)
+ }
+
+ if (grantedToRole) {
+ const assignments = await drainV2(
+ `${apiBase(cloudId)}/api/v2/spaces/${encodeURIComponent(spaceId)}/role-assignments`,
+ accessToken,
+ 'space role assignments'
+ )
+ for (const assignment of assignments) {
+ const id = assignment.principal?.principalId
+ const type = assignment.principal?.principalType?.toLowerCase().replaceAll('_', '-')
+ addPrincipal(type, id)
+ }
+ }
+
+ for (const accessType of accessTypes) {
+ const groups = await drainV1<{ id?: string }>(
+ `${apiBase(cloudId)}/rest/api/group?accessType=${accessType}`,
+ accessToken,
+ `${accessType} access groups`
+ )
+ for (const group of groups) {
+ if (!group.id) throw new Error('Confluence access group is missing its id')
+ principals.push({ kind: 'group', id: group.id })
+ }
+ }
+
+ if (unmapped > 0) {
+ logger.info(
+ 'Confluence space grants to anonymous or unsupported principals were not mirrored',
+ {
+ cloudId,
+ spaceId,
+ unmapped,
+ }
+ )
+ }
+ return [
+ ...new Map(
+ principals.map((principal) => [`${principal.kind}:${principal.id}`, principal])
+ ).values(),
+ ]
+}
+
+interface RestrictionResponse {
+ restrictions?: {
+ user?: { results?: { accountId?: string }[]; size?: number }
+ group?: { results?: { id?: string }[]; size?: number }
+ }
+}
+
+/**
+ * A page's own read restriction, or `null` when it has none.
+ *
+ * Confluence reports an unrestricted page as empty user and group lists, and
+ * offers no way to restrict a page to nobody — restricting always names at
+ * least the person doing it. So empty means inherit, and the distinction the
+ * ACL mapper draws between `null` and `[]` is defensive rather than reachable
+ * from the product.
+ *
+ * The user and group lists page independently under one `start`; a
+ * restriction naming more people than one page holds is read until both lists
+ * come back short.
+ */
+export async function getReadRestriction(
+ cloudId: string,
+ accessToken: string,
+ contentId: string
+): Promise {
+ const principals: ConfluencePrincipal[] = []
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const body = await getJson(
+ `${apiBase(cloudId)}/rest/api/content/${encodeURIComponent(contentId)}/restriction/byOperation/read?expand=restrictions.user,restrictions.group&start=${page * PAGE_SIZE}&limit=${PAGE_SIZE}`,
+ accessToken
+ )
+ const users = body?.restrictions?.user?.results
+ const groups = body?.restrictions?.group?.results
+ if (!Array.isArray(users) || !Array.isArray(groups)) {
+ throw new Error('Confluence omitted an expanded read-restriction collection')
+ }
+ for (const user of users) {
+ if (!user.accountId) throw new Error('Confluence read restriction is missing an account id')
+ principals.push({ kind: 'user', id: user.accountId })
+ }
+ for (const group of groups) {
+ if (!group.id) throw new Error('Confluence read restriction is missing a group id')
+ principals.push({ kind: 'group', id: group.id })
+ }
+ if (users.length < PAGE_SIZE && groups.length < PAGE_SIZE) {
+ return principals.length === 0 ? null : principals
+ }
+ }
+ throw new Error(`Confluence restriction on ${contentId} exceeded ${MAX_PAGES} pages`)
+}
+
+/**
+ * Ancestor responses have no cursor: continue from the first returned ancestor
+ * until its own ancestor list is empty. A short response can still be truncated.
+ * Return closest parent first for the restriction chain.
+ */
+export async function listAncestorIds(
+ cloudId: string,
+ accessToken: string,
+ pageId: string
+): Promise {
+ const collections: Record = {
+ page: 'pages',
+ folder: 'folders',
+ database: 'databases',
+ embed: 'embeds',
+ whiteboard: 'whiteboards',
+ }
+ const ids: string[] = []
+ const seen = new Set([pageId])
+ let currentId = pageId
+ let collection = 'pages'
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const body = await getJson<{ results?: { id?: string; type?: string }[] }>(
+ `${apiBase(cloudId)}/api/v2/${collection}/${encodeURIComponent(currentId)}/ancestors?limit=${PAGE_SIZE}`,
+ accessToken
+ )
+ if (!Array.isArray(body.results)) throw new Error('Confluence omitted the ancestor list')
+ if (body.results.length === 0) return ids
+ for (const ancestor of [...body.results].reverse()) {
+ if (!ancestor.id || seen.has(ancestor.id)) {
+ throw new Error('Confluence returned an invalid or cyclic ancestor chain')
+ }
+ seen.add(ancestor.id)
+ ids.push(ancestor.id)
+ }
+ const first = body.results[0]
+ currentId = first.id!
+ const nextCollection = collections[first.type ?? 'page']
+ if (!nextCollection) throw new Error('Confluence returned an unsupported ancestor type')
+ collection = nextCollection
+ }
+ throw new Error(`Confluence ancestors exceeded ${MAX_PAGES} pages`)
+}
+
+/** The space a piece of content lives in, for content the listing did not describe. */
+export async function describeContent(
+ cloudId: string,
+ accessToken: string,
+ contentId: string
+): Promise<{ spaceId: string; contentType: 'page' | 'blogpost' } | null> {
+ for (const contentType of ['page', 'blogpost'] as const) {
+ const collection = `${contentType}s`
+ const body = await getJson<{ spaceId?: string | number }>(
+ `${apiBase(cloudId)}/api/v2/${collection}/${encodeURIComponent(contentId)}`,
+ accessToken,
+ { allowNotFound: true }
+ )
+ if (body?.spaceId !== undefined) return { spaceId: String(body.spaceId), contentType }
+ }
+ return null
+}
+
+/** Every group on the site, by the id its permissions and restrictions name. */
+async function listSiteGroups(
+ cloudId: string,
+ accessToken: string
+): Promise {
+ const raw = await drainV1<{ id?: string }>(
+ `${apiBase(cloudId)}/rest/api/group`,
+ accessToken,
+ 'group listing'
+ )
+ const groups: ConnectorDirectoryGroup[] = []
+ for (const group of raw) {
+ if (group.id) groups.push({ id: group.id })
+ }
+ return groups
+}
+
+/** Account IDs remain usable when Confluence profile privacy hides emails. */
+export async function listGroupMemberTokens(
+ cloudId: string,
+ accessToken: string,
+ group: ConnectorDirectoryGroup
+): Promise {
+ const members = await drainV1<{ accountId?: string }>(
+ `${apiBase(cloudId)}/rest/api/group/${encodeURIComponent(group.id)}/membersByGroupId`,
+ accessToken,
+ 'group membership'
+ )
+ const tokens = new Set()
+ for (const member of members) {
+ if (!member.accountId) throw new Error('Confluence group member is missing an account id')
+ tokens.add(confluenceSubjectToken(member.accountId))
+ }
+ return { group, memberTokens: [...tokens], complete: true }
+}
+
+/** The Confluence site as a directory, keyed by its cloud id. */
+export function openConfluenceDirectory(
+ providerId: string,
+ cloudId: string,
+ accessToken: string
+): ConnectorDirectory {
+ return {
+ providerId,
+ tenantId: cloudId,
+ listGroups: () => listSiteGroups(cloudId, accessToken),
+ listGroupMembers: (group) => listGroupMemberTokens(cloudId, accessToken, group),
+ }
+}
diff --git a/apps/sim/connectors/github/github.test.ts b/apps/sim/connectors/github/github.test.ts
index c85e909206b..b275dbc2eb8 100644
--- a/apps/sim/connectors/github/github.test.ts
+++ b/apps/sim/connectors/github/github.test.ts
@@ -3,6 +3,243 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { githubConnector } from '@/connectors/github/github'
+import { PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils'
+
+const source = { repository: 'owner/repo', branch: 'main' }
+
+function treeFile(path: string, sha = path, size = 20) {
+ return { path, sha, size, mode: '100644', type: 'blob' }
+}
+
+function treeResponse(tree: ReturnType[], truncated = false, sha = 'tree-sha') {
+ return new Response(JSON.stringify({ sha, tree, truncated }), { status: 200 })
+}
+
+describe('githubConnector member listing', () => {
+ afterEach(() => vi.unstubAllGlobals())
+
+ it('resolves a member source default branch once and reuses it during hydration', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ default_branch: 'master' })))
+ .mockResolvedValueOnce(treeResponse([treeFile('readme.md', 'sha')]))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ sha: 'sha', size: 4, content: 'dGV4dA==', encoding: 'base64' })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const context: Record = { ...PER_MEMBER_LISTING_CONTEXT }
+ const config = { repository: 'owner/repo' }
+ const listing = await githubConnector.listDocuments('member-token', config, undefined, context)
+ const hydrated = await githubConnector.getDocument('member-token', config, 'readme.md', context)
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ 'https://api.github.com/repos/owner/repo',
+ 'https://api.github.com/repos/owner/repo/git/trees/master?recursive=1',
+ 'https://api.github.com/repos/owner/repo/contents/readme.md?ref=master',
+ ])
+ expect(listing.documents[0]?.metadata?.branch).toBe('master')
+ expect(hydrated?.metadata?.branch).toBe('master')
+ expect(hydrated?.contentHash).toBe(listing.documents[0]?.contentHash)
+ })
+
+ it('preserves the default main branch for existing general KB sources', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(treeResponse([]))
+ vi.stubGlobal('fetch', fetchMock)
+ await githubConnector.listDocuments('pat', { repository: 'owner/repo' })
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ 'https://api.github.com/repos/owner/repo/git/trees/main?recursive=1'
+ )
+ })
+
+ it('uses an explicitly configured member branch without a repository metadata lookup', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(treeResponse([]))
+ vi.stubGlobal('fetch', fetchMock)
+ await githubConnector.listDocuments(
+ 'member-token',
+ { repository: 'owner/repo', branch: 'release/docs' },
+ undefined,
+ { ...PER_MEMBER_LISTING_CONTEXT }
+ )
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ 'https://api.github.com/repos/owner/repo/git/trees/release%2Fdocs?recursive=1'
+ )
+ })
+
+ it('validates a member source against its actual default branch', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ default_branch: 'develop' })))
+ .mockResolvedValueOnce(new Response('{}'))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(
+ githubConnector.validateConfig(
+ 'member-token',
+ { repository: 'owner/repo' },
+ {
+ ...PER_MEMBER_LISTING_CONTEXT,
+ }
+ )
+ ).resolves.toEqual({ valid: true })
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
+ 'https://api.github.com/repos/owner/repo/branches/develop'
+ )
+ })
+
+ it('lists only metadata under the caller token and retains the hydration hash', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([treeFile('docs/readme.md', 'blob-sha')]))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ sha: 'blob-sha', size: 20, content: 'dGV4dA==', encoding: 'base64' })
+ )
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const context = {}
+ const result = await githubConnector.listDocuments('member-token', source, undefined, context)
+ expect(result.documents[0]).toMatchObject({
+ externalId: 'docs/readme.md',
+ content: '',
+ contentDeferred: true,
+ contentHash: 'git-sha:blob-sha',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(fetchMock.mock.calls[0]![1].headers.Authorization).toBe('Bearer member-token')
+ const hydrated = await githubConnector.getDocument(
+ 'member-token',
+ source,
+ 'docs/readme.md',
+ context
+ )
+ expect(hydrated?.contentHash).toBe(result.documents[0]?.contentHash)
+ expect(hydrated?.content).toBe('text')
+ })
+
+ it('pages the same tree without refetching a moving branch', async () => {
+ const files = Array.from({ length: 201 }, (_, index) => treeFile(`file-${index}.md`))
+ const fetchMock = vi.fn().mockResolvedValue(treeResponse(files))
+ vi.stubGlobal('fetch', fetchMock)
+ const context: Record = {}
+ const first = await githubConnector.listDocuments('token', source, undefined, context)
+ const second = await githubConnector.listDocuments('token', source, first.nextCursor, context)
+ expect(first.documents).toHaveLength(200)
+ expect(first.hasMore).toBe(true)
+ expect(second.documents.map((document) => document.externalId)).toEqual(['file-200.md'])
+ expect(second.hasMore).toBe(false)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it.each([401, 403, 404])(
+ 'classifies repository rejection %i for member access',
+ async (status) => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status })))
+ const error = await githubConnector.listDocuments('token', source).catch((error) => error)
+ expect(githubConnector.isCredentialInvalidError?.(error)).toBe(status === 401)
+ expect(githubConnector.isListingScopeUnavailableError?.(error)).toBe(status !== 401)
+ }
+ )
+
+ it('keeps an SSO denial distinct from invalid credentials', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(null, { status: 403, headers: { 'x-github-sso': 'required' } })
+ )
+ )
+ const error = await githubConnector.listDocuments('token', source).catch((error) => error)
+ expect(githubConnector.isListingScopeUnavailableError?.(error)).toBe(true)
+ expect(githubConnector.isCredentialInvalidError?.(error)).toBe(false)
+ })
+
+ it('does not revoke member access for a rate-limit 403', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(new Response(null, { status: 403, headers: { 'retry-after': '3600' } }))
+ )
+ const error = await githubConnector.listDocuments('token', source).catch((error) => error)
+ expect(githubConnector.isListingScopeUnavailableError?.(error)).toBe(false)
+ expect(githubConnector.isCredentialInvalidError?.(error)).toBe(false)
+ })
+
+ it('does not revoke access for a secondary throttle without rate-limit headers', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ message: 'You have exceeded a secondary rate limit.' }), {
+ status: 403,
+ })
+ )
+ )
+ const error = await githubConnector.listDocuments('token', source).catch((error) => error)
+ expect(githubConnector.isListingScopeUnavailableError?.(error)).toBe(false)
+ expect(error).toMatchObject({ rateLimited: true, retryAfterMs: 60_000 })
+ })
+
+ it('prevents deletion reconciliation after GitHub truncates the tree', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(treeResponse([treeFile('one.md')], true)))
+ const context: Record = {}
+ await githubConnector.listDocuments('token', source, undefined, context)
+ expect(context.listingCapped).toBe(true)
+ })
+
+ it('prevents reconciliation after a general KB file cap truncates the listing', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(treeResponse([treeFile('one.md'), treeFile('two.md')]))
+ )
+ const context: Record = {}
+ const result = await githubConnector.listDocuments(
+ 'token',
+ { ...source, maxFiles: '1' },
+ undefined,
+ context
+ )
+ expect(result.documents).toHaveLength(1)
+ expect(context.listingCapped).toBe(true)
+ })
+
+ it('allows reconciliation after intentional scope filtering', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ treeResponse([treeFile('docs/one.md'), treeFile('docs/two.txt'), treeFile('src/code.ts')])
+ )
+ )
+ const context: Record = {}
+ const result = await githubConnector.listDocuments(
+ 'token',
+ { ...source, pathPrefix: 'docs/', extensions: 'md' },
+ undefined,
+ context
+ )
+ expect(result.documents.map((document) => document.externalId)).toEqual(['docs/one.md'])
+ expect(context.listingCapped).toBeUndefined()
+ })
+
+ it('rejects malformed successful listings instead of treating them as an empty repository', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')))
+ await expect(githubConnector.listDocuments('token', source)).rejects.toThrow()
+ })
+
+ it.each(['owner/repo?redirect=x', 'owner/../other', 'owner/repo/tree/main', 'owner/.'])(
+ 'rejects invalid repository input %s before sending a token',
+ async (repository) => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(githubConnector.validateConfig('token', { repository })).resolves.toMatchObject({
+ valid: false,
+ })
+ expect(fetchMock).not.toHaveBeenCalled()
+ }
+ )
+})
describe('githubConnector.getDocument', () => {
afterEach(() => {
@@ -12,6 +249,7 @@ describe('githubConnector.getDocument', () => {
it('uses the object media type and hydrates large file content through the blob API', async () => {
const fetchMock = vi
.fn()
+ .mockResolvedValueOnce(treeResponse([treeFile('docs/large.md', 'blob-sha')]))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
@@ -34,11 +272,11 @@ describe('githubConnector.getDocument', () => {
'docs/large.md'
)
- expect(fetchMock).toHaveBeenCalledTimes(2)
- expect(fetchMock.mock.calls[0][1]).toMatchObject({
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(fetchMock.mock.calls[1][1]).toMatchObject({
headers: expect.objectContaining({ Accept: 'application/vnd.github.object+json' }),
})
- expect(fetchMock.mock.calls[1][1]).toMatchObject({
+ expect(fetchMock.mock.calls[2][1]).toMatchObject({
headers: expect.objectContaining({ Accept: 'application/vnd.github.raw+json' }),
})
expect(document).toMatchObject({
@@ -50,7 +288,13 @@ describe('githubConnector.getDocument', () => {
})
it('returns null only when a listed path is no longer present', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([]))
+ .mockResolvedValueOnce(new Response(null, { status: 404 }))
+ )
await expect(
githubConnector.getDocument('token', { repository: 'owner/repo' }, 'deleted.md')
@@ -60,6 +304,7 @@ describe('githubConnector.getDocument', () => {
it('records a blob that exceeds the byte cap as a visible skipped document', async () => {
const fetchMock = vi
.fn()
+ .mockResolvedValueOnce(treeResponse([treeFile('oversized.md', 'blob-sha')]))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
@@ -91,6 +336,7 @@ describe('githubConnector.getDocument', () => {
it('rejects a bodyless blob response instead of misreporting it as oversized', async () => {
const fetchMock = vi
.fn()
+ .mockResolvedValueOnce(treeResponse([treeFile('missing-body.md', 'blob-sha')]))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
@@ -110,11 +356,220 @@ describe('githubConnector.getDocument', () => {
).rejects.toThrow('GitHub git blob blob-sha returned no body')
})
+ it('retains null hydration for a repository that became unavailable before the tree request', async () => {
+ const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 }))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(githubConnector.getDocument('token', source, 'docs/readme.md')).resolves.toBeNull()
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
it('surfaces a non-rate-limit 403 as a document failure', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 403 })))
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([treeFile('private.md')]))
+ .mockResolvedValueOnce(new Response(null, { status: 403 }))
+ )
await expect(
githubConnector.getDocument('token', { repository: 'owner/repo' }, 'private.md')
).rejects.toThrow('Failed to fetch file private.md: 403')
})
})
+
+describe('githubConnector symlinks', () => {
+ afterEach(() => vi.unstubAllGlobals())
+
+ const link = { ...treeFile('docs/link.md', 'link-sha'), mode: '120000' }
+ const target = treeFile('docs/target.md', 'target-sha')
+
+ function contents(content: string) {
+ return Response.json({
+ type: 'file',
+ sha: link.sha,
+ size: Buffer.byteLength(content),
+ encoding: 'base64',
+ content: Buffer.from(content).toString('base64'),
+ })
+ }
+
+ it('versions symlink targets while keeping unchanged trees and regular files stable', async () => {
+ const stable = treeFile('docs/stable.md', 'stable-sha')
+ const fetchMock = vi.fn()
+ for (const [revision, text] of [
+ ['one', 'one'],
+ ['two', 'two'],
+ ] as const) {
+ fetchMock
+ .mockResolvedValueOnce(treeResponse([link, stable, target], false, `tree-${revision}`))
+ .mockResolvedValueOnce(contents(text))
+ .mockResolvedValueOnce(new Response('target.md'))
+ .mockResolvedValueOnce(new Response(text))
+ }
+ fetchMock.mockResolvedValueOnce(treeResponse([link, stable, target], false, 'tree-two'))
+ vi.stubGlobal('fetch', fetchMock)
+ const firstContext = {}
+ const first = await githubConnector.listDocuments('token', source, undefined, firstContext)
+ const firstContent = await githubConnector.getDocument('token', source, link.path, firstContext)
+ const secondContext = {}
+ const second = await githubConnector.listDocuments('token', source, undefined, secondContext)
+ const secondContent = await githubConnector.getDocument(
+ 'token',
+ source,
+ link.path,
+ secondContext
+ )
+ const unchanged = await githubConnector.listDocuments('token', source, undefined, {})
+
+ expect(firstContent?.content).toBe('one')
+ expect(secondContent?.content).toBe('two')
+ expect(firstContent?.contentHash).toBe(first.documents[0].contentHash)
+ expect(secondContent?.contentHash).toBe(second.documents[0].contentHash)
+ expect(first.documents[0].contentHash).not.toBe(second.documents[0].contentHash)
+ expect(unchanged.documents.map((doc) => doc.contentHash)).toEqual(
+ second.documents.map((doc) => doc.contentHash)
+ )
+ expect(first.documents[1].contentHash).toBe(second.documents[1].contentHash)
+ expect(fetchMock).toHaveBeenCalledTimes(9)
+ })
+
+ it.each(['target.md', '../../outside.md', '/etc/passwd', 'https://example.com/file.md'])(
+ 'skips a deleted, escaping, or external symlink target: %s',
+ async (targetPath) => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link], false, 'target-deleted-tree'))
+ .mockResolvedValueOnce(Response.json({ type: 'symlink', sha: link.sha, size: 9 }))
+ .mockResolvedValueOnce(new Response(targetPath))
+ vi.stubGlobal('fetch', fetchMock)
+ const context = {}
+ const listing = await githubConnector.listDocuments('token', source, undefined, context)
+ const hydrated = await githubConnector.getDocument('token', source, link.path, context)
+ expect(hydrated).toMatchObject({
+ content: '',
+ contentDeferred: false,
+ contentHash: listing.documents[0].contentHash,
+ skippedReason: 'Symbolic link target is not a repository file',
+ skippedExistingDisposition: 'replace',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ }
+ )
+
+ it('reads the full target blob when GitHub Contents silently truncates a link at 1 MiB', async () => {
+ const fullContent = 'x'.repeat(1024 * 1024 + 8192)
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link, { ...target, size: fullContent.length }]))
+ .mockResolvedValueOnce(contents(fullContent.slice(0, 1024 * 1024)))
+ .mockResolvedValueOnce(new Response('target.md'))
+ .mockResolvedValueOnce(new Response(fullContent))
+ vi.stubGlobal('fetch', fetchMock)
+ const context = {}
+ const listing = await githubConnector.listDocuments('token', source, undefined, context)
+ const hydrated = await githubConnector.getDocument('token', source, link.path, context)
+ expect(hydrated?.content.length).toBe(fullContent.length)
+ expect(hydrated?.content.endsWith(fullContent.slice(-8192))).toBe(true)
+ expect(hydrated?.contentHash).toBe(listing.documents[0].contentHash)
+ expect(hydrated?.metadata?.size).toBe(fullContent.length)
+ expect(fetchMock.mock.calls.slice(2).map(([url]) => url)).toEqual([
+ 'https://api.github.com/repos/owner/repo/git/blobs/link-sha',
+ 'https://api.github.com/repos/owner/repo/git/blobs/target-sha',
+ ])
+ })
+
+ it('follows an in-repository link chain outside the configured listing prefix', async () => {
+ const nextLink = { ...treeFile('intermediate.md', 'next-link-sha'), mode: '120000' }
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link, nextLink, target]))
+ .mockResolvedValueOnce(contents('complete target'))
+ .mockResolvedValueOnce(new Response('../intermediate.md'))
+ .mockResolvedValueOnce(new Response('docs/target.md'))
+ .mockResolvedValueOnce(new Response('complete target'))
+ )
+ await expect(
+ githubConnector.getDocument('token', { ...source, pathPrefix: 'docs/' }, link.path)
+ ).resolves.toMatchObject({ content: 'complete target' })
+ })
+
+ it('bounds cycles without repeatedly fetching the same link', async () => {
+ const nextLink = { ...treeFile('docs/next.md', 'next-link-sha'), mode: '120000' }
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link, nextLink]))
+ .mockResolvedValueOnce(Response.json({ type: 'symlink', sha: link.sha, size: 7 }))
+ .mockResolvedValueOnce(new Response('next.md'))
+ .mockResolvedValueOnce(new Response('link.md'))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(githubConnector.getDocument('token', source, link.path)).resolves.toMatchObject({
+ content: '',
+ skippedReason: 'Symbolic link target is not a repository file',
+ skippedExistingDisposition: 'replace',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(4)
+ })
+
+ it('caps a long acyclic link chain at forty target reads', async () => {
+ const links = Array.from({ length: 41 }, (_, index) => ({
+ ...treeFile(`link-${index}.md`, `link-sha-${index}`),
+ mode: '120000',
+ }))
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse(links))
+ .mockResolvedValueOnce(Response.json({ type: 'symlink', sha: links[0].sha, size: 9 }))
+ for (let index = 1; index <= 40; index++)
+ fetchMock.mockResolvedValueOnce(new Response(`link-${index}.md`))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(
+ githubConnector.getDocument('token', source, links[0].path)
+ ).resolves.toMatchObject({
+ skippedReason: 'Symbolic link target is not a repository file',
+ skippedExistingDisposition: 'replace',
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(42)
+ })
+
+ it('fails hydration when a truncated snapshot may have omitted the target', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link], true))
+ .mockResolvedValueOnce(contents('possibly valid target'))
+ .mockResolvedValueOnce(new Response('target.md'))
+ )
+ await expect(githubConnector.getDocument('token', source, link.path)).rejects.toThrow(
+ 'GitHub tree was truncated before the symbolic link target could be resolved'
+ )
+ })
+
+ it('preserves binary detection and byte limits for actual symlink target blobs', async () => {
+ for (const [body, length, reason] of [
+ ['binary\0contents', '15', 'Binary file was not indexed'],
+ [
+ 'oversized',
+ String(100 * 1024 * 1024 + 1),
+ 'File exceeds the 100MB size limit and was not indexed',
+ ],
+ ]) {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(treeResponse([link, target]))
+ .mockResolvedValueOnce(contents('incomplete contents'))
+ .mockResolvedValueOnce(new Response('target.md'))
+ .mockResolvedValueOnce(new Response(body, { headers: { 'content-length': length } }))
+ )
+ await expect(githubConnector.getDocument('token', source, link.path)).resolves.toMatchObject({
+ content: '',
+ skippedReason: reason,
+ })
+ }
+ })
+})
diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts
index e26dc938e64..0eb0e8991e3 100644
--- a/apps/sim/connectors/github/github.ts
+++ b/apps/sim/connectors/github/github.ts
@@ -1,11 +1,19 @@
+import { posix } from 'node:path'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
-import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
+import { z } from 'zod'
+import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
+import {
+ fetchWithRetry,
+ type RetryOptions,
+ VALIDATE_RETRY_OPTIONS,
+} from '@/lib/knowledge/documents/utils'
import { githubConnectorMeta } from '@/connectors/github/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
CONNECTOR_MAX_FILE_BYTES,
ConnectorFileTooLargeError,
+ isPerMemberListing,
markSkipped,
parseTagDate,
readBodyWithLimit,
@@ -28,6 +36,8 @@ const BATCH_SIZE = 200
const GIT_SHA_PREFIX = 'git-sha:'
const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES
const BINARY_SNIFF_BYTES = 8000
+const MAX_SYMLINK_DEPTH = 40
+const MAX_SYMLINK_TARGET_BYTES = 4096
/**
* Recorded on binary blobs so they surface once as a skipped row instead of being
* dropped silently — a dropped file stays an `add` forever and its blob is
@@ -51,9 +61,19 @@ function isBinaryBuffer(buf: Buffer): boolean {
* Parses the repository string into owner and repo.
*/
function parseRepo(repository: string): { owner: string; repo: string } {
- const cleaned = repository.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
+ const cleaned = repository
+ .trim()
+ .replace(/^https?:\/\/github\.com\//i, '')
+ .replace(/\/$/, '')
+ .replace(/\.git$/, '')
const parts = cleaned.split('/')
- if (parts.length < 2 || !parts[0] || !parts[1]) {
+ if (
+ parts.length !== 2 ||
+ !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(parts[0] ?? '') ||
+ !/^[a-z\d_.-]+$/i.test(parts[1] ?? '') ||
+ parts[1] === '.' ||
+ parts[1] === '..'
+ ) {
throw new Error(`Invalid repository format: "${repository}". Use "owner/repo".`)
}
return { owner: parts[0], repo: parts[1] }
@@ -91,12 +111,97 @@ function matchesExtension(filePath: string, extSet: Set | null): boolean
return extSet.has(fileName.slice(lastDot).toLowerCase())
}
-interface TreeItem {
- path: string
- mode: string
- type: string
+const treeItemSchema = z.object({
+ path: z.string().min(1),
+ mode: z.string().min(1),
+ type: z.enum(['blob', 'tree', 'commit']),
+ sha: z.string().min(1),
+ size: z.number().nonnegative().optional(),
+})
+const treeSchema = z.object({
+ sha: z.string().min(1),
+ tree: z.array(treeItemSchema).max(100_000),
+ truncated: z.boolean(),
+})
+const repositorySchema = z.object({ default_branch: z.string().min(1) })
+type TreeItem = z.output
+
+interface TreeSnapshot {
sha: string
- size?: number
+ items: Map
+ truncated: boolean
+}
+
+class GitHubApiError extends Error {
+ readonly retryAfterMs: number | undefined
+
+ constructor(
+ message: string,
+ readonly status: number,
+ readonly rateLimited = false
+ ) {
+ super(`${message}: ${status}`)
+ this.name = 'GitHubApiError'
+ this.retryAfterMs = rateLimited ? 60_000 : undefined
+ }
+}
+
+/** Secondary throttles may carry only a JSON message and must never withdraw member access. */
+async function repositoryRequestError(
+ message: string,
+ response: Response
+): Promise {
+ if (response.status === 403) {
+ const body = await readResponseJsonWithLimit<{ message?: unknown }>(response, {
+ maxBytes: 64 * 1024,
+ label: 'GitHub repository error',
+ }).catch(() => undefined)
+ if (typeof body?.message === 'string' && /rate limit|abuse detection/i.test(body.message)) {
+ return new GitHubApiError(message, response.status, true)
+ }
+ } else {
+ await response.body?.cancel()
+ }
+ return new GitHubApiError(message, response.status)
+}
+
+/** Member sources follow the repository default; existing workspace sources retain main. */
+async function resolveBranch(
+ accessToken: string,
+ owner: string,
+ repo: string,
+ sourceConfig: Record,
+ syncContext?: Record,
+ retryOptions?: RetryOptions
+): Promise {
+ const configuredBranch = typeof sourceConfig.branch === 'string' ? sourceConfig.branch.trim() : ''
+ if (configuredBranch) return configuredBranch
+ if (!isPerMemberListing(syncContext)) return 'main'
+ if (typeof syncContext?.githubBranch === 'string') return syncContext.githubBranch
+
+ const response = await fetchWithRetry(
+ `${GITHUB_API_URL}/repos/${owner}/${repo}`,
+ {
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${accessToken}`,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'Sim',
+ },
+ },
+ retryOptions
+ )
+ if (!response.ok) {
+ throw await repositoryRequestError('Failed to access GitHub repository', response)
+ }
+ const repository = repositorySchema.parse(
+ await readResponseJsonWithLimit(response, {
+ maxBytes: 1024 * 1024,
+ label: 'GitHub repository response',
+ })
+ )
+ if (syncContext) syncContext.githubBranch = repository.default_branch
+ return repository.default_branch
}
/**
@@ -111,8 +216,11 @@ async function fetchTree(
accessToken: string,
owner: string,
repo: string,
- branch: string
-): Promise<{ items: TreeItem[]; truncated: boolean }> {
+ branch: string,
+ syncContext?: Record
+): Promise {
+ const cached = syncContext?.githubTreeSnapshot as TreeSnapshot | undefined
+ if (cached) return cached
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`
const response = await fetchWithRetry(url, {
@@ -121,69 +229,113 @@ async function fetchTree(
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'Sim',
},
})
if (!response.ok) {
- const errorText = await response.text()
- logger.error('Failed to fetch GitHub tree', { status: response.status, error: errorText })
- throw new Error(`Failed to fetch repository tree: ${response.status}`)
+ throw await repositoryRequestError('Failed to fetch repository tree', response)
}
- const data = await response.json()
+ const data = treeSchema.parse(
+ await readResponseJsonWithLimit(response, {
+ maxBytes: 8 * 1024 * 1024,
+ label: 'GitHub repository tree response',
+ })
+ )
const truncated = Boolean(data.truncated)
if (truncated) {
logger.warn('GitHub tree was truncated — some files may be missing', { owner, repo, branch })
}
- return {
- items: (data.tree || []).filter((item: TreeItem) => item.type === 'blob'),
+ const snapshot: TreeSnapshot = {
+ sha: data.sha,
+ items: new Map(data.tree.map((item) => [item.path, item])),
truncated,
}
+ if (syncContext) syncContext.githubTreeSnapshot = snapshot
+ return snapshot
}
-/**
- * Fetches blob content via the Git Blobs API. Used as a fallback when the
- * `/contents/` endpoint cannot return the file body (files larger than 1 MB
- * return `content: ""` and `encoding: "none"`). Supports blobs up to 100 MB.
- */
+/** Streams a Git blob with the same binary and byte bounds used for ordinary files. */
async function fetchBlobContent(
accessToken: string,
owner: string,
repo: string,
- sha: string
+ sha: string,
+ maxBytes = MAX_FILE_SIZE
): Promise {
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs/${encodeURIComponent(sha)}`
+ const label = `git blob ${sha}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/vnd.github.raw+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'Sim',
},
})
if (!response.ok) {
- throw new Error(`Failed to fetch git blob ${sha}: ${response.status}`)
+ throw await repositoryRequestError(`Failed to fetch ${label}`, response)
}
if (!response.body) {
const contentLength = Number.parseInt(response.headers.get('content-length') ?? '', 10)
- if (Number.isFinite(contentLength) && contentLength > MAX_FILE_SIZE) {
- throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
+ throw new ConnectorFileTooLargeError(maxBytes)
}
- throw new Error(`GitHub git blob ${sha} returned no body`)
+ throw new Error(`GitHub ${label} returned no body`)
}
- const buffer = await readBodyWithLimit(response, MAX_FILE_SIZE)
+ const buffer = await readBodyWithLimit(response, maxBytes)
if (!buffer) {
- throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
+ throw new ConnectorFileTooLargeError(maxBytes)
}
if (isBinaryBuffer(buffer)) return null
return buffer.toString('utf8')
}
+/** Resolves links within one snapshot; Contents can truncate dereferenced targets at 1 MiB. */
+async function resolveSymlinkTarget(
+ accessToken: string,
+ owner: string,
+ repo: string,
+ link: TreeItem,
+ snapshot: TreeSnapshot
+): Promise {
+ const visited = new Set()
+ let item = link
+ for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth++) {
+ if (item.mode !== '120000') return item.type === 'blob' ? item : null
+ if (visited.has(item.path) || (item.size ?? 0) > MAX_SYMLINK_TARGET_BYTES) return null
+ visited.add(item.path)
+ let target: string | null
+ try {
+ target = await fetchBlobContent(accessToken, owner, repo, item.sha, MAX_SYMLINK_TARGET_BYTES)
+ } catch (error) {
+ if (error instanceof ConnectorFileTooLargeError) return null
+ throw error
+ }
+ if (!target || posix.isAbsolute(target)) return null
+ const targetPath = posix.normalize(posix.join(posix.dirname(item.path), target))
+ if (targetPath === '..' || targetPath.startsWith('../')) return null
+ const next = snapshot.items.get(targetPath)
+ if (!next) {
+ if (snapshot.truncated) {
+ throw new Error(
+ 'GitHub tree was truncated before the symbolic link target could be resolved'
+ )
+ }
+ return null
+ }
+ item = next
+ }
+ return item.mode !== '120000' && item.type === 'blob' ? item : null
+}
+
/**
* Creates a lightweight stub ExternalDocument from a tree item.
* Uses the Git blob SHA as contentHash for change detection, avoiding
@@ -194,7 +346,8 @@ function treeItemToStub(
owner: string,
repo: string,
branch: string,
- item: { path: string; sha: string; size?: number }
+ item: { path: string; sha: string; size?: number; mode?: string },
+ treeSha: string
): ExternalDocument {
return {
externalId: item.path,
@@ -203,7 +356,8 @@ function treeItemToStub(
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch.split('/').map(encodeURIComponent).join('/')}/${item.path.split('/').map(encodeURIComponent).join('/')}`,
- contentHash: `${GIT_SHA_PREFIX}${item.sha}`,
+ /** Contents dereferences symlinks but retains their SHA even when the target changes. */
+ contentHash: `${GIT_SHA_PREFIX}${item.sha}${item.mode === '120000' ? `:${treeSha}` : ''}`,
metadata: {
path: item.path,
sha: item.sha,
@@ -217,6 +371,13 @@ function treeItemToStub(
export const githubConnector: ConnectorConfig = {
...githubConnectorMeta,
+ isCredentialInvalidError: (error) => error instanceof GitHubApiError && error.status === 401,
+ /** Provider throttles preserve membership; a genuine scope denial withdraws it. */
+ isListingScopeUnavailableError: (error) =>
+ error instanceof GitHubApiError &&
+ !error.rateLimited &&
+ (error.status === 403 || error.status === 404),
+
listDocuments: async (
accessToken: string,
sourceConfig: Record,
@@ -224,27 +385,26 @@ export const githubConnector: ConnectorConfig = {
syncContext?: Record
): Promise