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 cbab5289619..14787762bad 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 792ec073437..587bee6d685 100644
--- a/apps/docs/content/docs/cli/reference.mdx
+++ b/apps/docs/content/docs/cli/reference.mdx
@@ -466,6 +466,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/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..332469a3e17
--- /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.
+
+These steps use your organization's **Integrations** page. 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 **Integrations**, click **Add source**, and choose **Confluence**. Click **Set up** or **Continue setup** if prompted. 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..7797dd5f4b0
--- /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 configures the source once. 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 source, and select **Connect account**. If it is missing, ask an organization admin to add 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. Keep the original tab open; **Open again** resumes the connection if a popup was blocked or closed.
+
+
+
+
+### Start searching
+
+The source row shows indexing status and how many documents are available to you. Open **Home → Search** and search for something you can already open in the source. Use **Assistant** to ask a question 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..3361d8caf5e
--- /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.
+
+These steps use your organization's **Integrations** page. 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 **Integrations**, choose **Add source**, then **GitHub**.
+
+
+
+
+### 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..407b0cbaa5f
--- /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.
+
+
+
+These steps use your organization's **Integrations** page. 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 **Integrations → Add source → GitLab**. 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..5234ba313fd
--- /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.
+
+These steps use your organization's **Integrations** page. 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 **Integrations → Add source → Gmail**. 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. They do not configure the source again. If Gmail has not been added yet, ask an organization admin to add it.
+
+## 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. If the tab was blocked or closed, allow pop-ups and click **Open 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..87474320938
--- /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.
+
+These steps use your organization's **Integrations** page. 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 **Integrations → Add source → Google Calendar**. 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..a634804cdde
--- /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.
+
+These steps use your organization's **Integrations** page. 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 **Integrations → Add source → Google Drive** 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..692a386ba3e
--- /dev/null
+++ b/apps/docs/content/docs/search/index.mdx
@@ -0,0 +1,97 @@
+---
+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 **Integrations → Add source** and select **Set up** beside a source. Use its **Setup guide** for the provider's prerequisites.
+
+
+
+
+### 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 **Home → Search** to find documents or use **Assistant** to ask questions 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 have one method. Google Drive and Confluence also offer an administrator connection when enabled for the organization.
+
+| 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. |
+
+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 the source's **Manage** action. Other members connect or reconnect their own accounts. See [Connect your account](/search/connect-your-account) for the teammate walkthrough.
+
+## Search, Assistant, and MCP
+
+**Home → Search** finds documents directly. **Assistant** 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..8881596368f
--- /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.
+
+These steps use your organization's **Integrations** page. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## Before you start
+
+- You must be a **Sim organization admin** to add the source. Teammates can connect after it exists.
+- 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 **Integrations** in your organization, click **Add source**, and choose **Jira**. Click **Set up** or **Continue setup** if prompted. 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 **Add source** button | Ask a Sim organization admin to add 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..7de7275be2b
--- /dev/null
+++ b/apps/docs/content/docs/search/slack.mdx
@@ -0,0 +1,130 @@
+---
+title: Slack
+description: Set up a workspace Slack app and connect members for channel search
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+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.
+
+
+
+These steps use your organization's **Integrations** page. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**.
+
+## 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. Keep the Slack and Sim accounts' email addresses aligned.
+
+## Set up the Slack app
+
+Skip to **Connect the source** if your Sim organization's Slack app is already configured for member accounts.
+
+
+
+
+### Start from Integrations
+
+Open **Integrations → Add source → Slack**, then **Set up Slack**. Complete the Slack app setup in the modal; when you finish, Sim returns you to the source configuration. In workspace Search, this setup opens **Connected accounts** settings and returns you to Search afterward.
+
+If there is no app yet, select **Set up Slack app**. You can also select an existing custom Slack bot belonging to this Sim organization.
+
+
+
+
+### Create the custom app
+
+
+
+*Workspace setup shown. Organization setup asks for the app name and optional description; Search member access is configured automatically.*
+
+The wizard guides you through these steps:
+
+1. Name the app and optionally add a description.
+2. Copy the generated manifest. On the [Slack Apps page](https://api.slack.com/apps), choose **Create New App → From a manifest**, select the workspace, and paste it.
+3. Copy the **Signing Secret** from the Slack app's **Basic Information** page into Sim.
+4. Install the app to the Slack workspace. Copy its **Bot User OAuth Token** (`xoxb-…`) into Sim and finish the wizard.
+
+In Slack, **Basic Information → App Credentials** is where you find the **Signing Secret**, **Client ID**, and **Client Secret**. Use the values from the app you just created.
+
+
+
+*Official Slack example: [Basic Information](https://docs.slack.dev/tools/bolt-python/creating-an-app/#create-a-new-app). The example app is not your Sim app.*
+
+Open **OAuth & Permissions** and select **Install to Workspace**. After approval, copy **Bot User OAuth Token**, not a user token or an app-level token.
+
+
+
+*Official Slack example: [OAuth token location](https://docs.slack.dev/tools/bolt-python/creating-an-app/#tokens-and-installing-apps). Use your installed app's token in Sim.*
+
+Use the manifest generated for your Sim deployment: it contains your callback and event URLs. Keep token rotation disabled for this custom-bot flow.
+
+
+ In workspace setup, keep **Managed user authorization** enabled, select **Member access → Search documents**, and review **Additional permissions**. Organization setup hides these choices and slash commands. The shared manifest still includes separate bot permissions; Search member authorization does not remove them.
+
+
+
+
+
+### Enable member connections
+
+Click **Done** to return to **Set up Slack**. Select the app you just created, then paste the **Client ID** and **Client Secret** from that same Slack app's **Basic Information** page. In workspace setup, also select **Access → Search documents**.
+
+Select **Verify and add** and complete Slack authorization in the popup. Sim verifies that the client credentials and bot belong to the same app and Slack workspace. Allow popups if the verification window does not open.
+
+Organization setup returns to the source form after verification. From workspace **Connected accounts** settings, choose **Continue Search setup**.
+
+
+
+
+## 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 installing the bot 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
+
+Search member authorization requires these user scopes:
+
+| Purpose | User scopes |
+|---|---|
+| Public channels and messages | `channels:read`, `channels:history` |
+| Private channels and messages | `groups:read`, `groups:history` |
+| Member identity | `users:read`, `users:read.email` |
+
+The generated manifest registers two redirect URLs under your Sim origin:
+
+```text
+https:///api/credential-groups/slack-managed-users/callback
+https:///api/credential-groups/oauth/slack/callback
+```
+
+To check an existing app, open **OAuth & Permissions → Scopes → User Token Scopes** and compare it with the table above. Check **Redirect URLs** on the same page against both URLs above. Keep **Token Rotation** disabled. If the settings differ, update the app using Sim's generated manifest before verifying the connection again.
+
+Bot scopes come from the shared manifest and are separate from these user scopes. The workspace-only **Workflow tools** option requests broader member permissions and is unnecessary for Search. When changing an existing app's member access policy, update its manifest and have members reconnect.
+
+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; saving a bot token alone is insufficient. |
+| Redirect mismatch | Check both redirect URLs against the manifest generated by Sim. |
+| App or workspace mismatch | Use the Client ID, Client Secret, and bot token from the same Slack app installation. |
+| Missing scopes | Compare User Token Scopes with the table above, update the Slack manifest, 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/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json
index 7644014d99c..9ab63f8e163 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)/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 659ba385fd2..f0cd4ce5648 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -53,11 +53,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,
@@ -349,7 +346,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()
})
@@ -430,7 +427,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`
)
})
@@ -470,9 +467,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 () => {
@@ -480,7 +475,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 8b9ab03f09c..4575e3b1b3c 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'
@@ -157,18 +158,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
@@ -176,11 +179,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') {
@@ -195,9 +198,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,
@@ -212,22 +216,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
}
@@ -239,7 +245,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,
@@ -295,7 +301,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)
@@ -310,6 +316,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 aa7c85b250f..6458e7dcb11 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,
@@ -66,6 +76,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,
@@ -75,6 +86,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 bd7f5465685..95bc2017634 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..45a47a2565c 100644
--- a/apps/sim/app/api/credential-groups/oauth-callback.ts
+++ b/apps/sim/app/api/credential-groups/oauth-callback.ts
@@ -3,7 +3,7 @@ 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 { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import {
@@ -11,7 +11,11 @@ import {
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')
@@ -49,24 +53,24 @@ 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' })
+ return failureRedirect('failed')
}
- const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
- if (!principal) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'unavailable',
- })
- }
+ const principal = credentialGroupOAuthAttemptPrincipal(attempt)
try {
await completePublicCredentialGroupOAuth.execute({
@@ -74,9 +78,12 @@ export async function handleCredentialGroupOAuthCallback({
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 +99,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..3413974291f 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
@@ -12,7 +12,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 +27,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 +59,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 +68,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 +80,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 +167,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 +175,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 +204,49 @@ 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()
+ })
})
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/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..7d72946d574 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts
@@ -30,7 +30,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 +68,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 +77,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 +161,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({
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index f76addcbb80..8259cc38dc8 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -9,7 +9,7 @@ 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,
@@ -97,12 +97,7 @@ async function completeManagedMcpCallback(params: {
})
}
try {
- const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
- if (!principal) {
- return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
- oauth: 'unavailable',
- })
- }
+ const principal = credentialGroupOAuthAttemptPrincipal(attempt)
const result = await completePublicCredentialGroupMcpOAuth.execute({
principal,
input: { attempt, code: params.code },
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 2f17fb5ea34..ed6f09d4352 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
@@ -326,6 +326,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,
@@ -427,6 +428,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 e01f727f591..658cb0bc163 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -236,7 +236,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..25cc53fab41
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts
@@ -0,0 +1,76 @@
+/** @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 = {
+ slackBotCredentialId: '11111111-1111-4111-8111-111111111111',
+ 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/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/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/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 f4f09bbaaaa..00f826830c6 100644
--- a/apps/sim/app/api/v2/chat/route.ts
+++ b/apps/sim/app/api/v2/chat/route.ts
@@ -334,7 +334,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.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]/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 3a0303158ab..905fb499ae4 100644
--- a/apps/sim/app/api/workspaces/invitations/batch/route.ts
+++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts
@@ -1,135 +1,43 @@
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-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: getErrorMessage(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/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..7063e295305
--- /dev/null
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
@@ -0,0 +1,250 @@
+/** @vitest-environment jsdom */
+import type { ReactNode } from 'react'
+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', () => ({
+ 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()
+ 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..31fbba0249b 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
@@ -1,14 +1,19 @@
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 { 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 { 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 +49,40 @@ function PageShell({ children }: PageShellProps) {
)
}
-function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) {
+interface UnavailableInvitationProps {
+ rateLimited?: boolean
+}
+
+function UnavailableInvitation({ rateLimited = false }: 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
)
@@ -95,16 +122,25 @@ export default async function CredentialGroupEnrollmentPage({
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) return
+ const resolvedSearchParams = await searchParams
+ 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 (asOrchestrationError(error)?.code === 'not_found') return null
+ if (returnToSearch && error instanceof CredentialGroupProviderConfigurationError)
+ return { enrollment: null }
throw error
})
if (!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 +148,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 +193,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 +219,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 e3671e8792e..891cb5ed954 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -102,19 +102,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
@@ -140,10 +143,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]/loading.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx
new file mode 100644
index 00000000000..9f2691c8a8b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx
@@ -0,0 +1,5 @@
+import { MothershipChatSkeleton } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton'
+
+export default function OrganizationChatLoading() {
+ return
+}
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..03f13ff094d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
@@ -0,0 +1,28 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import { notFound } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
+import OrganizationChatLoading from '@/app/o/[organizationId]/chat/[chatId]/loading'
+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 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..c388c7a818a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
@@ -0,0 +1,6 @@
+export {
+ OrganizationPage,
+ OrganizationPageLoading,
+ type OrganizationPageTab,
+} 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..c4625e4302d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
@@ -0,0 +1,209 @@
+'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 { noop } from '@sim/utils/helpers'
+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. */
+const 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[]
+ 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(props: OrganizationPageProps) {
+ const filters = useOrganizationPageFilters()
+ return
+}
+
+/** Uses the same page chrome during both navigation and a suspended URL read. */
+export function OrganizationPageLoading({
+ title,
+ description,
+}: Pick) {
+ return (
+
+ )
+}
+
+interface OrganizationPageViewProps extends OrganizationPageProps {
+ filters: {
+ tab: string | null
+ search: string
+ setTab: (value: string | null) => void
+ setSearch: (value: string) => void
+ }
+ loading?: boolean
+}
+
+function OrganizationPageView({
+ title,
+ description,
+ tabs,
+ action,
+ children,
+ filters,
+ loading = false,
+}: OrganizationPageViewProps) {
+ 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 } = filters
+ 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..03bff26c923
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
@@ -0,0 +1,177 @@
+'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 } 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 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, 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..61a7a80685d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
@@ -0,0 +1,4 @@
+export { ChatsSection } from './chats-section'
+export { OrganizationFooter } from './organization-footer'
+export { OrganizationHeader } from './organization-header'
+export { WorkspacesRailFlyout } from './workspaces-rail-flyout'
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..58af5e63d1c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
@@ -0,0 +1,238 @@
+'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 { getAccountSettingsHref } from '@/components/settings/navigation'
+import { getDesktopUpdates } from '@/lib/desktop'
+import { getUserColor } from '@/lib/workspaces/colors'
+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 { 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..dfe38737ba2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import {
+ Chip,
+ ChipChevronDown,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@sim/emcn'
+import { Building, PanelLeft, Settings } from '@sim/emcn/icons'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import { OrganizationMenuItems } from '@/components/organization-menu-items/organization-menu-items'
+import { getOrganizationSettingsHref } from '@/components/settings/navigation'
+import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
+import { WORKSPACES_PATH } from '@/lib/navigation/paths'
+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
+ * the organization menu; 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) {
+ if (isCollapsed) {
+ return (
+
+ }
+ />
+
+ )
+ }
+
+ return (
+
+
+
+
+ }
+ rightAdornment={ }
+ >
+ {organization.name}
+
+
+
+
+
+
+
+ Organization settings
+
+
+
+
+
+ Switch workspace
+
+
+
+
+
+ )
+}
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/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..1d54500825e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
@@ -0,0 +1,33 @@
+import { Home, Integration, Workspaces } from '@sim/emcn/icons'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+
+type OrganizationNavRoute = 'home' | 'integrations' | 'workspaces'
+
+interface OrganizationNavEntry {
+ id: string
+ label: string
+ icon: SidebarNavItemData['icon']
+ route: OrganizationNavRoute
+}
+
+/** The nav item whose collapsed rail chip also opens a flyout of the organization's workspaces. */
+export const WORKSPACES_NAV_ID = 'workspaces'
+
+/**
+ * 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: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
+ { id: 'workspaces', label: 'Workspaces', icon: Workspaces, route: 'workspaces' },
+]
+
+export function buildOrganizationNavItems(organizationId: string): SidebarNavItemData[] {
+ const routes = organizationRoutes(organizationId)
+ return ORGANIZATION_NAV_ENTRIES.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..50fc40938ee
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
@@ -0,0 +1,348 @@
+'use client'
+
+import { 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,
+ WorkspacesRailFlyout,
+} from '@/app/o/[organizationId]/components/organization-sidebar/components'
+import {
+ useCollapsedTooltips,
+ useOrganizationChats,
+} from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import {
+ buildOrganizationNavItems,
+ WORKSPACES_NAV_ID,
+} 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 {
+ CollapsedSidebarMenu,
+ 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 {
+ useHoverMenu,
+ 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]'
+
+/**
+ * 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 } = useOrganizationContext()
+ const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
+ const { handlePointerDown } = useSidebarResize()
+ const showCollapsedTooltips = useCollapsedTooltips(isCollapsed)
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
+ const { chats, isLoading: chatsLoading } = useOrganizationChats(organization.id)
+ const workspacesHover = useHoverMenu()
+
+ const isMac = isMacPlatform()
+ const navItems = buildOrganizationNavItems(organization.id)
+ 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)
+ /* The Workspaces chip grows a hover flyout of the organization's workspaces
+ while the rail is collapsed. The flyout replaces the collapsed tooltip
+ rather than stacking on it: both open on the same hover. Built inline —
+ Radix mounts menu content on open, so the flyout's query does not run
+ until the user actually hovers the chip. */
+ if (isCollapsed && item.id === WORKSPACES_NAV_ID) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ handleHrefContextMenu(e, item.href as string)}
+ />
+
+ )
+ })}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {/* 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..ea2e807d321
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import { Chip, ChipTextarea } from '@sim/emcn'
+import { ArrowUp, Search, Square } from '@sim/emcn/icons'
+
+interface ComposerProps {
+ value: string
+ mode: 'search' | 'assistant'
+ isSending: boolean
+ onChange: (value: string) => void
+ onModeChange: (mode: 'search' | 'assistant') => void
+ onSubmit: () => void
+ onStop: () => void
+}
+
+/** Organization questions and document searches share one composer. */
+export function Composer({
+ value,
+ mode,
+ isSending,
+ onChange,
+ onModeChange,
+ onSubmit,
+ onStop,
+}: ComposerProps) {
+ return (
+
+ )
+}
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/loading.tsx b/apps/sim/app/o/[organizationId]/home/loading.tsx
new file mode 100644
index 00000000000..a0a4414c833
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/loading.tsx
@@ -0,0 +1,19 @@
+import { ChipTextarea } from '@sim/emcn'
+
+export default function OrganizationHomeLoading() {
+ return (
+
+
+
+ What would you like to find?
+
+
+
+
+ )
+}
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..93b93827663
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
@@ -0,0 +1,109 @@
+/** @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(),
+ setParams: vi.fn(),
+ markRead: vi.fn(),
+ send: vi.fn(),
+ consume: vi.fn(),
+}))
+vi.mock('nuqs', () => ({
+ parseAsString: { withDefault: () => ({}) },
+ parseAsStringLiteral: () => ({ withDefault: () => ({}) }),
+ useQueryStates: () => [{ mode: 'assistant', q: '' }, mocks.setParams],
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/search-params', () => ({ searchFilterParsers: {} }))
+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('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({
+ MothershipChat: mocks.renderer,
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/components/knowledge-search-results', () => ({
+ KnowledgeSearchResults: () => null,
+}))
+
+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' },
+ viewer: { isAdmin: false },
+ })
+ 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('renders a usable composer when the disabled history query is pending', async () => {
+ await act(async () => root.render( ))
+ expect(container.textContent).toContain('Question composer')
+ expect(container.textContent).toContain('Connect your accounts')
+ 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(mocks.consume).not.toHaveBeenCalled()
+ })
+ 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('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..046a8a73a78
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx
@@ -0,0 +1,159 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { ChipLink } from '@sim/emcn'
+import { useQueryStates } from 'nuqs'
+import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
+import { useSession } from '@/lib/auth/auth-client'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
+import { Composer } from '@/app/o/[organizationId]/home/components/composer'
+import { organizationHomeParsers } from '@/app/o/[organizationId]/home/search-params'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results'
+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'
+import { useDebounce } from '@/hooks/use-debounce'
+import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
+
+interface OrganizationHomeProps {
+ userName?: string
+ chatId?: string
+}
+
+/** Search and private Assistant chats for the routed organization. */
+export function OrganizationHome({ userName, chatId }: OrganizationHomeProps) {
+ const { organization, viewer } = useOrganizationContext()
+ const { data: session } = useSession()
+ const [{ mode, q }, setParams] = useQueryStates(organizationHomeParsers, {
+ history: 'replace',
+ clearOnDefault: true,
+ })
+ const setSearch = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options))
+ const debouncedQuery = useDebounce(q, SEARCH_DEBOUNCE_MS)
+ const [draft, setDraft] = useState('')
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+ const chat = useChat({ organizationId: organization.id }, chatId)
+ const { sendMessage } = chat
+ const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id })
+
+ 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])
+
+ function changeMode(nextMode: 'search' | 'assistant') {
+ void setParams({ mode: nextMode, q: '', source: null, updated: null })
+ }
+
+ function submit() {
+ const message = (mode === 'search' ? q : draft).trim()
+ if (!message) return
+ if (mode === 'search') {
+ void setParams({ q: message })
+ return
+ }
+ setDraft('')
+ void sendMessage(message, undefined, undefined, { requestMode: 'assistant' })
+ }
+
+ async function summarize(message: string, assistantSearch: WorkspaceSearchFilters) {
+ await setParams({ mode: 'assistant', q: '', source: null, updated: null })
+ setDraft('')
+ void sendMessage(message, undefined, undefined, { requestMode: 'assistant', assistantSearch })
+ }
+
+ const composer = (
+ (mode === 'search' ? setSearch(value) : setDraft(value))}
+ onModeChange={changeMode}
+ onSubmit={submit}
+ onStop={() => {
+ void chat.stopGeneration()
+ }}
+ />
+ )
+ const searchResults =
+ mode === 'search' && q.trim() && debouncedQuery.trim() ? (
+
+ ) : null
+ const hasChat = Boolean(chatId || chat.messages.length)
+
+ return (
+
+ {chat.error && (
+
+ {chat.error}
+
+ )}
+ {hasChat ? (
+
{
+ void sendMessage(message, undefined, undefined, { requestMode: 'assistant' })
+ }}
+ onStopGeneration={() => {
+ 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) {
+ changeMode('assistant')
+ setDraft(queued.content)
+ }
+ return queued
+ }}
+ onCancelQueueEdit={chat.cancelQueueEdit}
+ userId={session?.user?.id}
+ chatId={chat.resolvedChatId}
+ composer={composer}
+ searchResults={searchResults}
+ />
+ ) : (
+
+
+
+ What would you like to find
+ {userName?.split(' ')[0] ? `, ${userName.split(' ')[0]}` : ''}?
+
+ {composer}
+ {searchResults}
+
+
+ {viewer.isAdmin ? 'Manage sources' : 'Connect your accounts'}
+
+
+
+
+ )}
+
+ )
+}
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..e886f628d49
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import { getSession } from '@/lib/auth'
+import OrganizationHomeLoading from '@/app/o/[organizationId]/home/loading'
+import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
+
+export const metadata: Metadata = {
+ title: 'Home',
+}
+
+export default async function OrganizationHomePage() {
+ const session = await getSession()
+
+ return (
+ }>
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/search-params.ts b/apps/sim/app/o/[organizationId]/home/search-params.ts
new file mode 100644
index 00000000000..e1538a50604
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/search-params.ts
@@ -0,0 +1,8 @@
+import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
+import { searchFilterParsers } from '@/app/workspace/[workspaceId]/home/search-params'
+
+export const organizationHomeParsers = {
+ mode: parseAsStringLiteral(['assistant', 'search'] as const).withDefault('assistant'),
+ q: parseAsString.withDefault(''),
+ ...searchFilterParsers,
+} as const
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..8104faf2d8a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx
@@ -0,0 +1,176 @@
+/** @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(),
+ filters: vi.fn(),
+ setSource: vi.fn(),
+ connect: vi.fn(),
+ setup: vi.fn(),
+}))
+
+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/o/[organizationId]/integrations/slack-account-setup', () => ({
+ OrganizationSlackAccountSetup: () => null,
+}))
+vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({
+ SearchSourceSetup: mocks.setup,
+}))
+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,
+ 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.sources.mockReturnValue({ data: [memberSource, centralSource], isPending: false })
+ mocks.filters.mockReturnValue({ search: '', setSearch: vi.fn() })
+ mocks.setup.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( ))
+ }
+
+ 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')
+ expect(mocks.setup).toHaveBeenCalledWith(
+ expect.objectContaining({ scope, canAdmin: false }),
+ undefined
+ )
+ })
+
+ it('gives admins source setup and management while retaining their own enrollment action', async () => {
+ mocks.context.mockReturnValue({
+ organization: { id: scope.organizationId },
+ viewer: { isAdmin: true },
+ searchAccess: { memberScoped: true, sourceMirrored: true },
+ })
+ await render()
+ expect(buttons('Add source')).toHaveLength(1)
+ expect(buttons('Manage')).toHaveLength(1)
+ expect(buttons('Connect account')).toHaveLength(1)
+ await act(async () => buttons('Add source')[0].click())
+ expect(mocks.setSource).toHaveBeenCalledWith('')
+ await act(async () => buttons('Manage')[0].click())
+ expect(mocks.setSource).toHaveBeenCalledWith('central-source', { history: 'push' })
+ })
+
+ 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..9c9314d533b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx
@@ -0,0 +1,140 @@
+'use client'
+
+import { useMemo } from 'react'
+import { Chip } from '@sim/emcn'
+import { Plus } from '@sim/emcn/icons'
+import { useQueryState } from 'nuqs'
+import type { ResourceScope } from '@/lib/core/resource-scope'
+import { connectorDisplayName } 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 { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/integrations/slack-account-setup'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+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 } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import { searchSourceKeys, useSearchSources } from '@/hooks/queries/kb/connectors'
+import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
+import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
+
+/** One source list combines organization setup, source health, and each member's next action. */
+export function OrganizationIntegrations() {
+ useOAuthReturnRouter()
+ useDesktopOAuthConnectListener()
+ const { organization, viewer, searchAccess } = useOrganizationContext()
+ const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
+ const sources = useSearchSources(scope)
+ const { search, setSearch } = useOrganizationPageFilters()
+ 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 query = search.trim().toLowerCase()
+ const visibleSources =
+ sources.data?.filter((source) =>
+ `${connectorDisplayName(source.connectorType)} ${source.sourceDescription}`
+ .toLowerCase()
+ .includes(query)
+ ) ?? []
+
+ return (
+ {
+ setSearch('')
+ void setSelectedType('')
+ }}
+ >
+ Add source
+
+ ) : undefined
+ }
+ >
+
+ {sources.isError ? (
+
void sources.refetch()}
+ variant='inline'
+ />
+ ) : sources.isPending ? (
+ Loading sources…
+ ) : visibleSources.length > 0 ? (
+ visibleSources.map((source) => (
+ enrollment.connect(source.knowledgeBaseId, source.connectorId)}
+ onManage={() => void setManagedSource(source.connectorId, { history: 'push' })}
+ />
+ ))
+ ) : (
+
+ {query
+ ? 'No matching sources.'
+ : viewer.isAdmin
+ ? searchAccess.memberScoped || searchAccess.sourceMirrored
+ ? 'Add a source to start indexing documents for Search.'
+ : 'Search sources are not enabled for this organization.'
+ : 'Your organization hasn’t added any sources yet. Ask an organization admin to get started.'}
+
+ )}
+ {enrollment.error && (
+ {enrollment.error}
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/loading.tsx b/apps/sim/app/o/[organizationId]/integrations/loading.tsx
new file mode 100644
index 00000000000..9ef76c61051
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/loading.tsx
@@ -0,0 +1,10 @@
+import { OrganizationPageLoading } from '@/app/o/[organizationId]/components/organization-page'
+
+export default function Loading() {
+ return (
+
+ )
+}
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..1b6bf7cd907
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations'
+import Loading from '@/app/o/[organizationId]/integrations/loading'
+
+export const metadata: Metadata = {
+ title: 'Integrations',
+}
+
+export default function OrganizationIntegrationsPage() {
+ return (
+ }>
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/integrations/search-params.ts
new file mode 100644
index 00000000000..3399bce228b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/search-params.ts
@@ -0,0 +1,6 @@
+import { parseAsStringLiteral } from 'nuqs/server'
+
+export const connectedAccountsParam = {
+ key: 'connectedAccounts',
+ parser: parseAsStringLiteral(['slack']),
+} as const
diff --git a/apps/sim/app/o/[organizationId]/integrations/slack-account-setup.test.tsx b/apps/sim/app/o/[organizationId]/integrations/slack-account-setup.test.tsx
new file mode 100644
index 00000000000..3a5a7a00146
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/slack-account-setup.test.tsx
@@ -0,0 +1,124 @@
+/** @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(),
+ credentials: 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('@/hooks/queries/scoped-credentials', () => ({ useScopedCredentials: mocks.credentials }))
+vi.mock('@/ee/credential-groups/components/slack-managed-users-modal', () => ({
+ SlackManagedUsersModal: mocks.modal,
+}))
+
+import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/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.credentials.mockReturnValue({ data: [], 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')
+ expect(mocks.credentials).toHaveBeenCalledWith({
+ organizationId: 'org-a',
+ type: 'service_account',
+ providerId: 'slack-custom-bot',
+ enabled: true,
+ })
+ })
+
+ 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')
+ 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]/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/integrations/slack-account-setup.tsx
new file mode 100644
index 00000000000..5cb56074c40
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/slack-account-setup.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { useEffect } from 'react'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+} from '@sim/emcn'
+import { useQueryState } from 'nuqs'
+import { connectedAccountsParam } from '@/app/o/[organizationId]/integrations/search-params'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+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'
+import { useScopedCredentials } from '@/hooks/queries/scoped-credentials'
+
+/** 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 bots = useScopedCredentials({
+ organizationId: organization.id,
+ type: 'service_account',
+ providerId: 'slack-custom-bot',
+ enabled: open,
+ })
+ 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]/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..5b29c1bff06
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/layout.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * @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 } = vi.hoisted(() => ({
+ mockGetOrganizationSurfaceContext: vi.fn(),
+ mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children),
+}))
+
+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 },
+ 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(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..214b0471b00
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -0,0 +1,61 @@
+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 { 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 [context, cookieStore] = await Promise.all([
+ getOrganizationSurfaceContext(organizationId, session.user.id),
+ cookies(),
+ ])
+ 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..ad93fa4fcc2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+
+export default async function OrganizationPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ redirect(organizationRoutes(organizationId).home)
+}
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]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
new file mode 100644
index 00000000000..6765189db8b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from 'react'
+import { notFound } from 'next/navigation'
+import { ORGANIZATION_SETTINGS_ITEMS, toSettingsHeaderMeta } from '@/components/settings/navigation'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { resolveOrganizationSettingsSection } 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 = resolveOrganizationSettingsSection(section)
+ const item = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved)
+ if (!item) 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..765e09b0aa7
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
@@ -0,0 +1,52 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import {
+ getOrganizationSettingsHref,
+ ORGANIZATION_SETTINGS_ITEMS,
+} from '@/components/settings/navigation'
+import { getSession } from '@/lib/auth'
+import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings'
+import { resolveOrganizationSettingsSection } 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 = resolveOrganizationSettingsSection(section)
+ return {
+ title: ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved)?.label ?? 'Settings',
+ }
+}
+
+export default async function OrganizationSettingsSectionPage({
+ params,
+}: OrganizationSettingsSectionPageProps) {
+ const { organizationId, section } = await params
+ const resolved = resolveOrganizationSettingsSection(section)
+ if (!resolved) notFound()
+ const session = await getSession()
+ if (!session?.user) {
+ redirect(
+ buildAuthCrossLink('/login', {
+ callbackUrl: getOrganizationSettingsHref(organizationId, resolved),
+ isInviteFlow: false,
+ })
+ )
+ }
+ if (
+ !(await authorizeOrganizationSettingsSection({
+ organizationId,
+ userId: session.user.id,
+ section: resolved,
+ }))
+ ) {
+ notFound()
+ }
+ return
+}
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..2797cd9c695
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
@@ -0,0 +1,88 @@
+'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 { OrganizationSearchMcp } from '@/app/o/[organizationId]/settings/components/organization-search-mcp'
+
+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 === '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/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..1077afac295
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
@@ -0,0 +1,66 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ ORGANIZATION_SETTINGS_ITEMS,
+ type OrganizationSettingsFeatures,
+} from '@/components/settings/navigation'
+import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation'
+import {
+ organizationSettingsNavigation,
+ resolveOrganizationSettingsSection,
+} from '@/app/o/[organizationId]/settings/navigation'
+
+const enterprise: OrganizationSettingsFeatures = {
+ billingEnabled: true,
+ hasEnterprisePlan: true,
+ hosted: true,
+ selfHosted: {},
+}
+
+describe('organization settings navigation', () => {
+ it('exposes MCP setup and the read-only roster to an ordinary organization member', () => {
+ expect(organizationSettingsNavigation(false, enterprise).map(({ id }) => id)).toEqual([
+ 'search-mcp',
+ 'members',
+ ])
+ })
+
+ it('offers every org settings section to an entitled organization administrator', () => {
+ expect(organizationSettingsNavigation(true, enterprise)).toEqual(ORGANIZATION_SETTINGS_ITEMS)
+ })
+
+ it('keeps members and billing reachable without an enterprise plan', () => {
+ expect(
+ organizationSettingsNavigation(true, { ...enterprise, hasEnterprisePlan: false }).map(
+ ({ id }) => id
+ )
+ ).toEqual(['search-mcp', 'members', 'billing'])
+ })
+
+ it('honors individual self-hosted feature flags and hides billing when disabled', () => {
+ expect(
+ organizationSettingsNavigation(true, {
+ ...enterprise,
+ hosted: false,
+ billingEnabled: false,
+ selfHosted: { sso: true },
+ }).map(({ id }) => id)
+ ).toEqual(['search-mcp', 'members', 'sso'])
+ })
+
+ 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').map(({ id }) => id)).toEqual([
+ 'home',
+ 'integrations',
+ 'workspaces',
+ ])
+ })
+})
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..2d794e0eabf
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts
@@ -0,0 +1,33 @@
+import {
+ isOrganizationSettingsSectionAvailable,
+ ORGANIZATION_SETTINGS_ITEMS,
+ type OrganizationSettingsFeatures,
+ type OrganizationSettingsSection,
+ parseSettingsPathSection,
+ resolveOrganizationSectionAccess,
+} from '@/components/settings/navigation'
+
+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' },
+ })
+}
+
+export function organizationSettingsNavigation(
+ isAdmin: boolean,
+ features: OrganizationSettingsFeatures
+) {
+ return ORGANIZATION_SETTINGS_ITEMS.filter(
+ (item) =>
+ resolveOrganizationSectionAccess({
+ section: item.id,
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: isAdmin,
+ }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, features)
+ )
+}
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..a859fcec0b8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+import {
+ getOrganizationSettingsFeatures,
+ getOrganizationSettingsHref,
+ 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 } from '@/lib/navigation/paths'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import {
+ organizationSettingsNavigation,
+ resolveOrganizationSettingsSection,
+} 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 } = 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
+ )
+
+ return (
+ getOrganizationSettingsHref(organization.id, section)}
+ backHref={organizationRoutes(organization.id).home}
+ />
+ )
+}
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..c0d5c917a48
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation'
+import { getOrganizationSettingsHref } from '@/components/settings/navigation'
+
+interface OrganizationSettingsPageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationSettingsPage({ params }: OrganizationSettingsPageProps) {
+ const { organizationId } = await params
+ redirect(getOrganizationSettingsHref(organizationId, 'members'))
+}
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/[organizationId]/workspaces/loading.tsx b/apps/sim/app/o/[organizationId]/workspaces/loading.tsx
new file mode 100644
index 00000000000..a0f0a1b54fb
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/workspaces/loading.tsx
@@ -0,0 +1,5 @@
+import { OrganizationPageLoading } from '@/app/o/[organizationId]/components/organization-page'
+
+export default function Loading() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/workspaces/page.tsx b/apps/sim/app/o/[organizationId]/workspaces/page.tsx
new file mode 100644
index 00000000000..62338c219be
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/workspaces/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import Loading from '@/app/o/[organizationId]/workspaces/loading'
+import { OrganizationWorkspaces } from '@/app/o/[organizationId]/workspaces/workspaces'
+
+export const metadata: Metadata = {
+ title: 'Workspaces',
+}
+
+export default function OrganizationWorkspacesPage() {
+ return (
+ }>
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/workspaces/workspaces.tsx b/apps/sim/app/o/[organizationId]/workspaces/workspaces.tsx
new file mode 100644
index 00000000000..927cd7f70c8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/workspaces/workspaces.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { Building } from '@sim/emcn/icons'
+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 {
+ 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 { useWorkspacesQuery } from '@/hooks/queries/workspace'
+
+const TABS = [
+ { id: 'all', label: 'All' },
+ { id: 'admin', label: 'Admin' },
+ { id: 'write', label: 'Write' },
+ { id: 'read', label: 'Read' },
+] as const
+
+export function OrganizationWorkspaces() {
+ const { organization } = useOrganizationContext()
+ const { tab, search } = useOrganizationPageFilters()
+ const workspaces = useWorkspacesQuery()
+ const query = search.trim().toLowerCase()
+ const visible =
+ workspaces.data?.filter(
+ (workspace) =>
+ workspace.organizationId === organization.id &&
+ (!tab || tab === 'all' || workspace.permissions === tab) &&
+ workspace.name.toLowerCase().includes(query)
+ ) ?? []
+ return (
+
+
+ {workspaces.isError ? (
+ void workspaces.refetch()}
+ variant='inline'
+ />
+ ) : workspaces.isPending ? (
+ Loading workspaces…
+ ) : visible.length ? (
+ visible.map((workspace) => (
+ }
+ title={workspace.name}
+ href={`/workspace/${workspace.id}/home`}
+ clickLabel={`Open ${workspace.name}`}
+ navigable
+ />
+ ))
+ ) : (
+
+ {query || (tab && tab !== 'all')
+ ? 'No matching workspaces.'
+ : 'You don’t have access to any workspaces in this organization yet.'}
+
+ )}
+
+
+ )
+}
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..5fa70eacd62 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
- 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
+type KnowledgeSearchResultsProps = (
+ | { workspaceId: string; scope?: never }
+ | { scope: ResourceScope; workspaceId?: never }
+) & {
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,59 +132,48 @@ interface KnowledgeSearchResultsProps {
*/
export function KnowledgeSearchResults({
workspaceId,
+ scope: suppliedScope,
query,
onSummarize,
- onAnswer,
}: KnowledgeSearchResultsProps) {
- const {
- data: knowledgeBases = [],
- isPending: basesPending,
- error: basesError,
- } = useKnowledgeBasesQuery(workspaceId)
- const knowledgeBaseIds = searchedKnowledgeBases(knowledgeBases, workspaceId).map((kb) => kb.id)
+ const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
+ const { data: index, isPending: basesPending, error: basesError } = 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)
+ } = 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) {
@@ -228,13 +181,21 @@ export function KnowledgeSearchResults({
}
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 +214,6 @@ export function KnowledgeSearchResults({
{' · searched as you'}
{indexingNote && {indexingNote} }
- onAnswer(query)}>
- Answer with Sim
-
{showFilters && (
@@ -289,27 +247,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..436f3bafa4d 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,7 +206,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
disabled={isPending}
onConnect={() => connectSearchSource(workspaceId, connector, connection)}
/>
- )
+ ))
})}
{error &&
{error}
}
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..79cd31aa63b 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
@@ -22,6 +22,7 @@ interface SourceSetupModalProps {
* a space. Everyone after the first person clicks straight through.
*/
export function SourceSetupModal({ connector, onClose, onConnect }: SourceSetupModalProps) {
+ const docsUrl = connector.meta.searchDocsUrl
const fields = connector.setupFields
const [values, setValues] = useState
>({})
const complete = fields.every((field) => values[field.id]?.trim())
@@ -75,6 +76,16 @@ export function SourceSetupModal({ connector, onClose, onConnect }: SourceSetupM
window.open(docsUrl, '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : undefined
+ }
primaryAction={{ label: 'Connect', onClick: submit, disabled: !complete }}
/>
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..da7048562a3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx
@@ -0,0 +1,403 @@
+/**
+ * @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/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('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..625c299b9f9 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,76 @@ 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']
+ initialSyncIntervalMinutes?: number
+ onCreated?: (connectorType: string) => void
+ setupDraftKey?: string
}
type Step = 'select-type' | 'configure'
@@ -77,54 +94,147 @@ export function AddConnectorModal({
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
+ isSearchIndex = false,
initialConnectorType,
+ initialAccessMode = 'workspace',
+ 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:
+ 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 +242,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 +268,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 +344,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 +399,7 @@ export function AddConnectorModal({
{
knowledgeBaseId,
connectorType: selectedType,
+ accessMode: access.accessMode,
...(isApiKeyMode
? apiKeyValue.trim()
? { apiKey: apiKeyValue }
@@ -259,16 +407,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 +425,39 @@ 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' ? (
- {
+ if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey)
setStep('select-type')
onConnectorTypeChange?.('')
}}
- >
-
-
+ />
{`Configure ${connectorConfig?.name}`}
) : (
@@ -316,7 +466,13 @@ export function AddConnectorModal({
{step === 'select-type' ? (
@@ -337,184 +493,299 @@ export function AddConnectorModal({
/>
))}
{filteredEntries.length === 0 && (
-
+
{CONNECTOR_ENTRIES.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
-
+
)}
) : connectorConfig ? (
<>
- {!isApiKeyMode && memberAccessAvailable && (
+ {integrationAvailabilityError && (
+
+ void refetchIntegrationAvailability()}
+ variant='inline'
+ />
+
+ )}
+ {(memberAccessAvailable || mirroredAccessAvailable || slackSetupRequired) && (
)}
- {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'
+ {!slackSetupRequired && (
+ <>
+ {connectorConfig.auth.mode === 'oauth' && apiKeyConfig && !isMembersMode && (
+
+ {
+ setUseApiKey(value === 'apiKey')
+ setApiKeyValue('')
+ setSelectedCredentialId(null)
+ }}
+ />
+
+ )}
+ {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),
+ })
+ ),
+ ...(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}
/>
-
- ) : (
-
- ({
- 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}
- />
-
- )}
-
-
- 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))
- }}
- >
-
e.stopPropagation()}
- onCheckedChange={(checked) => {
- setDisabledTagIds((prev) => {
- const next = new Set(prev)
- if (checked) {
- next.delete(tagDef.id)
- } else {
- next.add(tagDef.id)
- }
- return next
- })
- }}
- />
-
- {tagDef.displayName}
-
-
- ({tagDef.fieldType})
-
+ {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 +796,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 +820,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]/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..289675255ba
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx
@@ -0,0 +1,114 @@
+'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
+ 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.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 (!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 &&
+ (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..f98833556ec
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx
@@ -0,0 +1,1744 @@
+/**
+ * @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('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(
+
+ )
+ expect(document.body.textContent).toContain('Crawl as')
+ expect(document.body.textContent).not.toContain('Crawl as*')
+ 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).toHaveBeenCalledOnce()
+ })
+
+ 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..361bf6036f2
--- /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 { canConnectPersonally, getConnectorAccessAvailability } 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 }
+)
+
+const SOURCE_TYPES = Object.entries(CONNECTOR_META_REGISTRY)
+ .filter(([, meta]) => meta.search && (meta.mirrorsSourceAcls || canConnectPersonally(meta)))
+ .sort(([, left], [, right]) => left.name.localeCompare(right.name))
+
+interface SearchSourceSetupProps {
+ workspaceId?: string
+ scope?: ResourceScope
+ canAdmin: boolean
+ memberAccessAvailable: boolean
+ mirroredAccessAvailable: boolean
+}
+
+/** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */
+export function SearchSourceSetup({
+ workspaceId,
+ scope: explicitScope,
+ canAdmin,
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+}: 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) => {
+ 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)}
+ 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 = 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 = 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: central ? 'admin' : 'members',
+ },
+ {
+ 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..63a57ac054d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx
@@ -0,0 +1,71 @@
+'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).home : `/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..9d6ef47d288 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts
@@ -15,7 +15,7 @@ vi.mock('@/lib/users/application/read-current-user', () => ({
}))
vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
- listCredentialGroupSettings: { execute: mockExecute },
+ getWorkspaceAccountsSettings: { execute: mockExecute },
}))
vi.mock('@/lib/api/server/routes/internal-json-route', () => ({
@@ -84,7 +84,7 @@ describe('credential-groups prefetch', () => {
updatedAt: '2026-01-01T00:00:00.000Z',
}
mockExecute.mockResolvedValue({
- credentialGroups: [{ ...credentialGroup, internal: true }],
+ credentialGroup: { ...credentialGroup, internal: true },
availableProviders: ['gmail'],
})
const queryClient = new QueryClient()
@@ -97,13 +97,8 @@ describe('credential-groups prefetch', () => {
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],
+ expect(queryClient.getQueryData(credentialGroupKeys.workspace('w1'))).toEqual({
+ credentialGroup,
availableProviders: ['gmail'],
})
})
@@ -116,7 +111,7 @@ describe('credential-groups prefetch', () => {
workspaceId: 'w1',
})
- expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
+ expect(queryClient.getQueryData(credentialGroupKeys.workspace('w1'))).toBeUndefined()
})
it('leaves the cache empty when session authentication fails', async () => {
@@ -128,6 +123,6 @@ describe('credential-groups prefetch', () => {
})
expect(mockExecute).not.toHaveBeenCalled()
- expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
+ expect(queryClient.getQueryData(credentialGroupKeys.workspace('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..861683c3267 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
@@ -1,36 +1,30 @@
import type { QueryClient } from '@tanstack/react-query'
-import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'
+import { getWorkspaceAccountsContract } 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 { getWorkspaceAccountsSettings } 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,
+ WORKSPACE_ACCOUNTS_STALE_TIME,
} from '@/hooks/queries/utils/credential-group-queries'
-/** Prefetches credential groups through the route's authorization and response boundaries. */
-async function prefetchCredentialGroups(
+/** Prefetches workspace accounts through the route's authorization and response boundaries. */
+async function prefetchWorkspaceAccounts(
queryClient: QueryClient,
{ workspaceId }: SettingsSectionPrefetchContext
) {
return queryClient.prefetchQuery({
- queryKey: credentialGroupKeys.list(workspaceId),
+ queryKey: credentialGroupKeys.workspace(workspaceId),
queryFn: async () => {
const principal = await internalSessionAuth.authenticate()
- const result = await listCredentialGroupSettings.execute({
+ const result = await getWorkspaceAccountsSettings.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)
+ return getWorkspaceAccountsContract.response.schema.parse(result)
},
- staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
+ staleTime: WORKSPACE_ACCOUNTS_STALE_TIME,
})
}
@@ -52,5 +46,5 @@ export const SECTION_PREFETCHERS: Partial<
general: (queryClient) => prefetchCurrentUserSettings(queryClient),
billing: (queryClient) => prefetchCurrentUserSettings(queryClient),
admin: (queryClient) => prefetchCurrentUserSettings(queryClient),
- 'credential-groups': prefetchCredentialGroups,
+ 'credential-groups': prefetchWorkspaceAccounts,
}
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..57f81dd07dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -164,7 +164,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{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..79d9232bcfa 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
@@ -33,7 +33,7 @@ describe('unified settings navigation', () => {
{ 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: 'credential-groups', label: 'Connected accounts', 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' },
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 (