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

@@ -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. + +Confluence Search source configuration showing connection method, account, domain, and spaces + + + + +### 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 authentication selector with API token selected + +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. + +Google Drive source row with Connect account + + + + +### 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. + +GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled + +*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**. + +GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only + +Expand **Account permissions** and set **Email addresses → Access: Read-only**. + +GitHub account permissions with only Email addresses selected for Read-only access + +| 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. + +GitLab source setup in Sim Search + +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 example of the Access Tokens page with the Add new token button + +*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. | + +GitLab source configuration scrolled to repository and issue filters, item limit, and document details + +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. + + + + +Gmail Search source configuration + +## 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 +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +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. + + + + +Google Calendar Search source configuration + + + `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 +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +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. + +Google Drive Search service-account connection and source options + + + + +### 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. + +Google Cloud Console form for creating a service account + +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). + +Google Cloud Create private key dialog with JSON selected + + + + +### 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**. + +Google Workspace Admin Console Add a new client ID dialog with Client ID and OAuth scopes fields + +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. + +Add Google Service Account credential modal in Sim + + + + +### 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 +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +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. + + + + +Sim Search source catalog with Set up actions + +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. + +Jira Search source setup with member accounts, an example site, and a project key + + + + +### 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 of Jira's Permission helper with User and Issue fields and Browse Projects selected + +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. + + Atlassian OAuth Authorization form with an example Sim Jira callback URL + + 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. + +Slack source setup in Sim Search + +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 Slack app wizard with Search documents member access + +*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. + +Slack app settings showing Basic Information and App Credentials + +*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. + +Slack OAuth and Permissions page showing the Bot User OAuth Token location + +*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 && (
@@ -486,10 +488,13 @@ function SignupFormContent({ githubAvailable={githubAvailable} googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} - callbackURL={redirectUrl || '/workspace'} + callbackURL={redirectUrl || DEFAULT_POST_AUTH_ROUTE} > {ssoEnabled && !hasOnlySSO && ( - + )} )} diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 96bd55f1f88..c2fbe5d4dbb 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -6,7 +6,7 @@ import { normalizeEmail } from '@sim/utils/string' import { useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { validateCallbackUrl } from '@/lib/core/security/input-validation' -import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' +import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' const logger = createLogger('useVerification') @@ -16,7 +16,7 @@ const logger = createLogger('useVerification') * * Both redirect sites run in the same commit as the effect that reads session * storage, so a cached value is still `null` when they fire and the stored - * destination is silently replaced by `/workspace`. Reading here removes that + * destination is silently replaced by the default entry. Reading here removes that * race. `redirectAfter` wins over the stored URL; anything failing callback * validation is discarded, and an unsafe stored value is evicted. */ @@ -112,7 +112,8 @@ export function useVerification({ logger.warn('Failed to refetch session after verification', e) } - const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace' + const destination = + resolveRedirectUrl(searchParams.get('redirectAfter')) ?? DEFAULT_POST_AUTH_ROUTE sessionStorage.removeItem('verificationEmail') sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) @@ -217,7 +218,7 @@ export function useVerification({ if (destination) { window.location.href = destination } else { - router.push('/workspace') + router.push(DEFAULT_POST_AUTH_ROUTE) } } diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx index 8d14e3a1ee9..31aa3527fb9 100644 --- a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -2,6 +2,7 @@ import { Button } from '@sim/emcn' import { useRouter } from 'next/navigation' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' interface ChatErrorStateProps { error: string @@ -19,10 +20,10 @@ export function ChatErrorState({ error }: ChatErrorStateProps) {

{error}

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({ )} -
-

- Connect your accounts -

-

- {enrollment.inviterName ? ( - <> - {enrollment.inviterName}{' '} - invited you - - ) : ( - 'You have been invited' - )}{' '} - to connect accounts for{' '} - {enrollment.workspaceName}. -

-
+
- {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={ + + } + /> + ) + })}
- - - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - - + {returnToSearch ? ( +
+ {docsUrl && ( + + Setup guide + + )} + + Return to Search + +
+ ) : ( +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
+ )}
) } + +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} +
+
+
+ +
+
+ {children} +
+
+
+ ) +} 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 && ( +
+ + ) +} + +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 = ( + + + + + + + + + + + + + + + + ) + + /** + * 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 ( +
+ + + {/* 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 ( +
{ + event.preventDefault() + onSubmit() + }} + > + onChange(event.target.value)} + rows={3} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault() + onSubmit() + } + }} + /> +
+
+ onModeChange('assistant')}> + Assistant + + onModeChange('search')}> + Search + +
+ {isSending && mode === 'assistant' ? ( + + Stop + + ) : ( + + {mode === 'search' ? 'Search' : 'Send'} + + )} +
+ + ) +} 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} +

+ ))} + +
+
+ ) +} 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 ( +
+
+ + +

Streamable HTTP

+
+
+ + +

+ 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 }[] }) => ( - + <> + {secondaryActions?.map((action) => ( + + ))} + + ), ChipModalHeader: ({ children }: { children?: ReactNode }) =>
{children}
, 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' && ( + + )} + {options?.map((option) => ( + {option.label} + ))} +
+ ), + ChipModalFooter: ({ + primaryAction, + }: { + primaryAction: { label: string; onClick: () => void; disabled: boolean } + }) => ( + + ), 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 > -
- +
+ + {sidebar} +
{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}} -
{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 + }) => ( +
+ +
+ ), + }) +) + +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 ( + <> + + {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 }) => ( -