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 8fdf6b767b2..16763637d19 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -103,6 +103,7 @@ Update Credential (OAuth login or personal API key required) | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 61f299f4b36..9c39df61fc4 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -467,6 +467,7 @@ sim credentials update [options] | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | diff --git a/apps/docs/content/docs/integrations/slack.mdx b/apps/docs/content/docs/integrations/slack.mdx index 0c653a656e8..cad0e409c6b 100644 --- a/apps/docs/content/docs/integrations/slack.mdx +++ b/apps/docs/content/docs/integrations/slack.mdx @@ -956,7 +956,7 @@ Rename the Slack agent session associated with a thread. ### Slack List Channels -List up to 10,000 accessible Slack conversations across as many cursor pages as Slack supplies, capped at 200 provider pages. Credential-group user tokens also return one-to-one and group direct messages. +List up to 10,000 accessible public and private Slack channels across as many cursor pages as Slack supplies, capped at 200 provider pages. #### Input @@ -964,7 +964,7 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as | --------- | ---- | -------- | ----------- | | `authMethod` | string | No | Authentication method: oauth or bot_token | | `botToken` | string | No | Bot token for Custom Bot | -| `includePrivate` | boolean | No | Include private channels the bot is a member of \(default: true\) | +| `includePrivate` | boolean | No | Include private channels the connected account can access \(default: true\) | | `excludeArchived` | boolean | No | Exclude archived channels \(default: true\) | | `limit` | number | No | Conversations to request per Slack page \(default: 100, max: 200\) | | `cursor` | string | No | Pagination cursor from a previous response.nextCursor to resume from | @@ -974,7 +974,7 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as | Parameter | Type | Description | | --------- | ---- | ----------- | -| `channels` | array | Up to 10,000 accessible public and private channels, plus direct and group DMs for credential-group user tokens | +| `channels` | array | Up to 10,000 accessible public and private channels | | ↳ `id` | string | Conversation ID \(for example, C123, D123, or G123\) | | ↳ `name` | string | Channel or group-DM name; omitted for one-to-one direct messages | | ↳ `is_channel` | boolean | Whether this is a channel | @@ -998,8 +998,8 @@ List up to 10,000 accessible Slack conversations across as many cursor pages as | ↳ `is_user_deleted` | boolean | Whether the other participant in a direct message is deactivated | | ↳ `is_open` | boolean | Whether a direct or group-direct-message conversation is open | | ↳ `priority` | number | Slack sidebar sort priority | -| `ids` | array | Conversation IDs for every returned channel or DM | -| `names` | array | Names of returned channels and group DMs; one-to-one DMs have no name | +| `ids` | array | Conversation IDs for every returned channel | +| `names` | array | Names of returned channels | | `count` | number | Total number of conversations returned across all fetched pages, up to 10,000 | | `hasMore` | boolean | Whether more Slack conversation pages remain beyond the fetched window | | `nextCursor` | string | Cursor to fetch the next page; null when there are no more pages | diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index d659d835704..7a9c3ac9235 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -8,6 +8,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' +For workspace Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases. + Connectors continuously sync documents from external services into your knowledge base, so you never have to upload files manually. New content is added, changed content is re-processed, and deleted content is removed — all automatically. ## Available Connectors diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index fd86838d469..649510a1483 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -10,6 +10,7 @@ "workflows", "agents", "---Workspace---", + "search", "knowledgebase", "tables", "files", diff --git a/apps/docs/content/docs/platform/connected-accounts.mdx b/apps/docs/content/docs/platform/connected-accounts.mdx new file mode 100644 index 00000000000..1840650f209 --- /dev/null +++ b/apps/docs/content/docs/platform/connected-accounts.mdx @@ -0,0 +1,117 @@ +--- +title: Connected accounts +description: Collect accounts in one organization credential group and control which workspaces can use them. +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +**Connected accounts** collects people's accounts in one shared pool for your organization, also called a **credential group**. An organization admin chooses the providers, invites people to connect, and allows specific workspaces to use the pool. Workflows in those workspaces can find an account by email through the [Credential block](/workflows/blocks/credential). + +Each organization has at most one pool. Creating it does not give any workspace access; the workspace allowlist starts empty. + +## Availability + +Connected accounts must be enabled for your organization. Sim Cloud also requires an active Enterprise plan. Organization owners and admins manage the pool, subject to the organization's permission settings. A workspace admin who is not an organization admin cannot change the pool or its workspace access. + +For self-hosted deployments using environment-based feature flags, set `CREDENTIAL_GROUPS=true`. Availability is organization-scoped; personal workspaces cannot use an organization pool. + +The setup instructions below describe **Settings → Connected accounts**, shown when Knowledge Member Access is disabled. When `KNOWLEDGE_MEMBER_ACCESS=true` and connected accounts is available, organization settings shows the Search **Integrations** page instead. Only the selected page is available, including through direct links. Switching pages does not remove existing connections or workspace access. + +## Set up connected accounts + +Open your organization’s **Settings → Connected accounts**. Select **Set up connected accounts** if this is your first time. + +The page has three tabs: + +| Tab | What you manage | +| --- | --- | +| **Providers** | Services people can connect and their shared configuration | +| **People** | Connection requests, each person's connected accounts, and revocation | +| **Workspace access** | Workspaces allowed to use the organization's accounts | + +### 1. Add providers + +In **Providers**, select **Add provider** and search the catalog. Complete any required configuration before adding the provider. Added providers appear in the list; use **Configure** to edit their settings or **Remove** in the row menu to remove them. + +| Provider | Organization setup | +| --- | --- | +| OAuth providers, such as Gmail or Google Drive | Add the provider from the available catalog. Each invited person authorizes their account. | +| Slack | Supply the Slack App ID, Slack workspace ID, OAuth client ID, and client secret, then complete app verification. The organization uses one app and Slack workspace. Existing workspace Slack bots remain separate. | +| Fireflies and Granola | Add the provider. Sim supplies the MCP endpoint and handles OAuth client registration. People only complete their own authorization. | +| Databricks | Enter a name, the tenant's MCP URL, a registered OAuth client ID, and a client secret if required by that client. | + +For Databricks, **Add** validates and saves the configuration before the provider appears in the list. Cancelling the form leaves nothing added. Use an official Databricks HTTPS MCP endpoint, such as `https://your-workspace.cloud.databricks.com/api/2.0/mcp/sql`, and the OAuth client registered for your deployment. People connecting later use this organization configuration. + +When editing Databricks, leaving the client secret blank preserves the saved secret. Changing the MCP URL or OAuth client requires people to reconnect. + +Adding a provider makes account connections available without enabling indexing. Search source setup is managed on **Settings → Integrations** when Search is enabled. Connected accounts has no indexing controls or status indicators. Managed MCP providers currently support live tool calls only. + +### 2. Invite people + +For Search-enabled organizations, open **Settings → Integrations → People**. Otherwise, use **Settings → Connected accounts → People**. Set up a provider for personal account connections before inviting people; approving a Search integration alone is not enough. + +1. Select **Request connections**. +2. Enter their email addresses and select **Send requests**. +3. Each person opens the invitation, signs in to Sim with the verified invitation email, and authorizes the providers they want to connect. +4. They select **Submit** to finish the connection form. + +Invitees can contribute accounts without joining your organization. The invitation grants access to their connection form; it does not grant access to your workspaces or workflows. + +Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Organization Account** with that email and **Gmail** finds Alex's active Gmail contribution. + +#### How the email is associated with a Sim user + +On first use, the invitation email must match the signed-in user's verified Sim email. Sim then binds the invitation to that user's account. Another person cannot take it over by opening the link, and a later email change does not transfer it to a different Sim user. The invitation email remains the workflow lookup key. + +OAuth account providers also verify that the provider email matches the invitation email. Managed MCP connections are associated with the verified Sim user who completes authorization; they do not independently verify the MCP provider's account email. Keep using the invitation email when looking up those connections. + +In **People**, open a person's actions menu and select **Resend** when they need another invitation. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own. + +### 3. Allow workspaces + +Open **Workspace access** and select **Add workspaces** to choose one or more workspaces. The list shows workspaces that have access; use a row's **Remove** action to withdraw it. Adding or removing a workspace applies immediately. Only workspaces in this organization can be allowed. + + +An allowed workspace gives every authorized manual and deployed workflow in that workspace access to **every active account in the pool**. This includes scheduled, webhook, and public deployments that pass their normal workflow authorization. There is no separate workflow allowlist or restriction to the running user's own contributions. + + +For example, allowing the Support workspace lets its authorized workflows use Alex's Gmail contribution even when someone else runs the workflow. Allowing the workspace does not grant anyone permission to edit or run a workflow they could not otherwise access. + +Removing a workspace stops subsequent use of the pool. It does not recall provider requests already in flight or remove data a workflow has already received. Chat continues to use each person's own connections; workspace access does not give Chat access to other people's accounts. + +## Use accounts in workflows + +Use the **Credential** block's organization operations in an allowed workspace: + +- **Find Organization Account** selects an OAuth account by invitation email and provider. +- **List Organization Accounts** returns a page of OAuth accounts, optionally filtered by email and providers. +- **Find Organization MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider. +- **List Organization MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider. + +The organization is determined by the workflow's workspace. You do not enter a credential group ID or organization ID in the block. + +The outputs are account references, without tokens. Use an OAuth `credentialId` in the corresponding integration block's credential field. For managed MCP, `credentialId` identifies the person's connection; `mcpServerId` identifies shared configuration and cannot select that person's authorization by itself. + +See the [Credential block reference](/workflows/blocks/credential#organization-accounts) for inputs, outputs, pagination, and connection-event triggers. + +## Reconnect or stop sharing + +People can open **Settings → Account → Connected accounts** to view accounts they contributed, including contributions to organizations they have not joined. **Reconnect** starts authorization again. **Disconnect** stops the organization from using that account in subsequent calls. + +For administrators, the controls have different scopes: + +| Action | Effect | +| --- | --- | +| Remove a provider | Stops use of that provider's contributions in the pool | +| Revoke a person | Stops use of all that person's contributions to the organization | +| Remove a workspace from the allowlist | Stops that workspace's workflows from using the pool | + +## Current limits and troubleshooting + +- **One pool per organization:** there are no additional groups, per-workflow grants, or per-account workspace allowlists. +- **One Databricks configuration:** multiple tenant endpoints cannot be added as separate Databricks providers in the same pool. +- **Organization operations are missing:** confirm the feature is enabled for the organization and the workflow's workspace is allowed. +- **An invitation rejects your sign-in:** use the verified Sim account matching the invitation email. For OAuth providers, connect the provider account with that same email. +- **A Find operation fails:** it requires exactly one active matching connection. Check the invitation email, provider, connection status, and workspace access. It never chooses an arbitrary account when there are zero or multiple matches. +- **A list seems incomplete:** organization list operations return up to 100 connections per page. Follow `nextCursor` while `hasMore` is true. +- **A legacy Credential Group block fails:** replace it with the appropriate Credential block operation or trigger, update its output references, and redeploy the workflow. diff --git a/apps/docs/content/docs/platform/meta.json b/apps/docs/content/docs/platform/meta.json index c4d59da4bdc..4e7ffeec952 100644 --- a/apps/docs/content/docs/platform/meta.json +++ b/apps/docs/content/docs/platform/meta.json @@ -1,4 +1,11 @@ { "title": "Platform", - "pages": ["workspaces", "organization", "permissions", "credentials", "costs"] + "pages": [ + "workspaces", + "organization", + "permissions", + "connected-accounts", + "credentials", + "costs" + ] } diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index c5b962f6fab..5b8bfc8f435 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -49,6 +49,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or | Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search | | Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs | | Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors | +| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync | | Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | | Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired | | OAuth token cleanup | `/api/cron/cleanup-oauth-tokens` | `0 * * * *` | Deletes access and refresh tokens after the retention tail | diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index 2e73a5e9de7..d8a11cc0e2a 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -114,6 +114,18 @@ One app registration in [Entra ID](https://entra.microsoft.com) covers all of th The same variables also power "Sign in with Microsoft". +### GitHub Search + +Register a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with repository **Contents: read-only**, **Metadata: read-only**, and account **Email addresses: read-only** permissions. Keep user access token expiration enabled so Sim can rotate access and refresh tokens. + +| Environment variables | Provider ID | +|---|---| +| `GITHUB_APP_CLIENT_ID`
`GITHUB_APP_CLIENT_SECRET` | `github-repositories` | + +Register `https:///api/auth/oauth2/callback/github-repositories` as the callback. These App OAuth client credentials are separate from `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` used for Sim sign-in. Sim does not require an App private key. + +A repository or organization administrator installs the App on the repositories to search. Each member connects their own GitHub account, with a verified email matching their Sim account. Search indexes repository files that both the member and the installed App can access. GitHub workflow blocks and existing knowledge-base token connections continue to use personal access tokens. + ### Everything else | Service | Environment variables | Provider ID | diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx new file mode 100644 index 00000000000..1937bddd578 --- /dev/null +++ b/apps/docs/content/docs/search/confluence.mdx @@ -0,0 +1,197 @@ +--- +title: Confluence +description: Connect Confluence Cloud spaces and set up each teammate's search access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin configures the source, and each teammate connects their Confluence account. + +Search indexes each page's own text, including supported local callouts and code blocks. It does not expand Include Page, Excerpt Include, or third-party macros into that page. Referenced pages can be indexed separately with their own access rules. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Choose a connection method + +| Method | Who supplies the content? | What teammates do | +| --- | --- | --- | +| **Admin or service account** | One account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. | +| **Member accounts** | Sim syncs content separately through connected members' accounts. | Connect their own Confluence account to establish which pages they can access. | + +Use **Admin or service account** when you have a dedicated account that can read the intended spaces and their permissions. Use **Member accounts** when each person should supply their own connection. Available methods depend on your organization's enabled features. + +**Everyone still connects in both methods.** With a central account, teammates supply their identity; they do not configure another central crawl or choose spaces again. + +## Before you start + +- Be a **Sim organization admin** to add the source. +- Use a Confluence Cloud site such as `your-team.atlassian.net`. This connector does not connect to Server or Data Center. +- Each teammate needs a verified Sim email matching their active Atlassian account's email. +- For a central crawl, grant its account access to Confluence, the chosen spaces, and any restricted pages you want indexed. Admin status alone does not bypass page restrictions. It also needs permission to read space permissions and the user/group directory. + +On hosted Sim, personal connections authorize the existing Sim app. Teammates do not create OAuth apps or service-account tokens. Self-hosted deployments need the [shared OAuth configuration](#self-hosted-operator-setup) even when a service account supplies the content. + +## Set up the source + + + + +### Choose Confluence + +Open **Settings → Integrations → Providers**, approve **Confluence**, then select **Set up**. Select your **Connection method**. + + + + +### Select an account + +For **Admin or service account**, open **Account** and select an existing account, choose **Connect Confluence account** for OAuth, or add a service account using the [steps below](#using-a-service-account). + +For **Member accounts**, **Browse with** supplies an account for the space picker only. Select or connect an account, or switch **Spaces** to manual input to enter space keys without a browsing account. Browsing does not connect that account to Search or share its access with teammates. + + + + +### Select the spaces + +Enter **Confluence Domain**, then choose one or more **Spaces**. The picker shows spaces accessible to the selected account. Use the switch beside the field to enter comma-separated **Space Keys**, such as `ENG, PRODUCT`. + +Keep **Content Type** at its default for pages, or choose blog posts or both. Leave **Filter by Label** empty unless you want a smaller scope. **Document details (optional)** contains metadata tag settings. + +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..aaadf503cad --- /dev/null +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -0,0 +1,72 @@ +--- +title: Connect your account +description: Join your team's Search sources and finish connecting your own accounts +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Your admin approves the provider and can configure shared source filters. You connect your own account so Sim can establish what you are allowed to search. + + + + +### Join your organization + +Accept your Sim organization invitation or sign in through your organization's SSO. Use a verified Sim email that matches your account at the source. Organization Search does not require workspace access. + + + + +### Open Integrations + +Open **Integrations**, find the provider or source, and select **Connect account**. Your first connection may ask for required source settings, such as repository or project names. If the provider is missing, ask an organization admin to approve it. + +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. If the popup was blocked or closed, allow popups and select **Connect account** again. While authorization is pending, use **Open again**. + + + + +### Start searching + +The source row shows indexing status and how many documents are available to you. Open **Search** in the organization sidebar and search for something you can already open in the source. Use **Home** to ask the assistant about your connected documents. The first sync may take time, especially for large accounts. + + + + +For a source configured inside a workspace, join that workspace and use its **Search** page instead. Organization and workspace sources are separate. + +## Do I always need to connect? + +| Source setup | Your next step | +| --- | --- | +| Member accounts | Connect your own account, including when you are the admin. | +| Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. | +| Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. | +| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. | + +Connecting one Google service does not connect all of them. Gmail, Calendar, and Drive each have their own Search connection. + +## If you get stuck + +| Status | What to do | +| --- | --- | +| **Connect account** | Complete the connection in the new tab. | +| **Reconnect** | Authorize the same source account again. | +| **Finish connecting in the other tab** | Finish authorization, or use **Open again**. Allow popups for Sim. | +| No results | Check the source's filters and sync status with your admin. Confirm you can open the document at the source. | +| Needs admin attention | Ask your admin to inspect **Manage** for the source error. | + + + Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you. + diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx new file mode 100644 index 00000000000..a6ca8d92fa2 --- /dev/null +++ b/apps/docs/content/docs/search/github.mdx @@ -0,0 +1,153 @@ +--- +title: GitHub +description: Search repository files through each member's GitHub account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +GitHub Search indexes text files from a repository on `github.com`. An organization admin chooses the repository, then each person connects their GitHub account. Installing the GitHub App alone does not connect your teammates. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Before you start + +Your Sim deployment needs a GitHub App configured as described below. The repository must contain at least one commit; initialize an empty repository with a README before adding it. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. + +## Configure the GitHub App + +This step belongs to the Sim deployment administrator. If the App is already configured, continue to [Add a repository](#add-a-repository). + + + + +### Register the App + +For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. + +Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. + +Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: + +```text +https:///api/auth/oauth2/callback/github-repositories +``` + +| GitHub setting | Value for Sim Search | +|---|---| +| Allow wildcard matching | Disabled | +| Expire user authorization tokens | Enabled | +| Request user authorization (OAuth) during installation | Disabled | +| Enable Device Flow | Disabled | +| Post installation → Setup URL | Empty | +| Webhook → Active | Disabled | + +Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. + +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 **Settings → Integrations → Providers**, approve **GitHub**, then select **Set up**. + + + + +### Choose what to index + +Enter the repository and keep **Sync documents with → Connected members** for the usual setup. + +| Field | What to enter | +|---|---| +| Repository | `owner/repo`. Add another source for another repository. | +| Branch | Optional. Leave blank to follow the repository's default branch. | +| Path Filter | Optional prefix such as `docs/`. | +| File Extensions | Optional comma-separated list, such as `.md, .txt, .mdx`. | + +**Document details** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source. + +You can instead select an existing account under **Sync documents with** to supply file contents centrally. Teammates still connect their own accounts to establish which files they may find. + + + + +### Connect your account + +On the GitHub source row, select **Connect account** and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). + +With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Admins can open **Manage** on the source to inspect sync progress and errors. + + + + +## Troubleshooting + +| Problem | Next step | +|---|---| +| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. | +| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). | +| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. | +| Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. | +| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. | +| Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | +| Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | +| Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | +| Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | + + + GitHub Search covers repository text files up to 100 MB, including symbolic links to files within the same repository. Path and extension filters apply to the link's path. Broken or external links, binaries, and submodules are not indexed. Issues, pull requests, separate wikis, GitHub Enterprise Server, and `ghe.com` domains are not supported by this connector. Personal access tokens remain available for general knowledge-base connectors, with that knowledge base's access rules. + diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx new file mode 100644 index 00000000000..e8aeaa52d1a --- /dev/null +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -0,0 +1,94 @@ +--- +title: GitLab +description: Index a self-managed GitLab project with its source permissions +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed GitLab email. + +GitLab source setup in Sim Search + +Admin setup uses your organization's **Settings → Integrations** page. Teammates do not need a personal GitLab connection. For workspace Search, use **Search → Add source** instead. + +## Before you start + +Use a self-managed GitLab instance running version **17.4 or later**. Setup needs both a Sim organization admin and an active GitLab **instance administrator**. A project Maintainer or group Owner is insufficient. + + + This Search path requires GitLab's administrator directory and settings APIs. GitLab.com projects do not support this setup. General knowledge-base GitLab connectors can still use project-readable tokens, but their knowledge-base access rules are different from Search's source permissions. + + +## Add a project + + + + +### Create the administrator token + +Sign in to your self-managed GitLab instance as an instance administrator, then open **Avatar → Edit profile → Access → Personal access tokens**. On current releases choose **Generate token → Legacy token**; older versions show **Add new token** or the token form directly. + +Official GitLab 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 **Settings → Integrations → Providers**, approve **GitLab**, then select **Set up**. Paste the token and enter your instance host explicitly. + +| Field | What to enter | +|---|---| +| Host | Your self-managed domain, such as `gitlab.example.com`. | +| Project | `group/project` or the numeric project ID. Add another source for another project. | +| Content | Defaults to **Wiki & Issues**. Choose **Code, Wiki, Issues & Merge Requests** to include all supported types. | +| Branch | Optional branch or tag for repository files; blank uses the project's default branch. | +| Path Filter / File Extensions | Optional limits for repository files. | +| Issue State / Labels / Milestone | Optional filters for issues. | +| Max Items | Optional positive limit. Leave blank for all matching items. | + +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..107422340b6 --- /dev/null +++ b/apps/docs/content/docs/search/gmail.mdx @@ -0,0 +1,113 @@ +--- +title: Gmail +description: Connect each teammate's Gmail account to search their email in Sim +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search email threads from your own Gmail account. An organization admin enables the source; each teammate connects their own account. An admin's connection does not make their mailbox available to the team. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Set up the source + +These steps require a Sim organization admin. + + + + +### Add Gmail + +Open **Settings → Integrations → Providers**, approve **Gmail**, then select **Set up**. Gmail uses **Member accounts**; there is no domain-wide or service-account crawl in Search. + + + + +### Choose what to include + +Keep the defaults to search all dates and labels, excluding Promotions, Social, Spam, and Trash. Add filters below if your team needs a narrower source. + + + + +### Create the source + +Click **Add source**. Gmail appears in the **Sources** list. Each person, including the admin, then connects their own account. + + + + +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. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand. + +## Source options + +An admin can change these under **Manage** on the Gmail source. Filters apply separately to each connected mailbox. + +| Option | Behavior | +| --- | --- | +| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member setup. | +| Date Range | All time by default. Choose the last 7, 30, or 90 days, 6 months, or year. | +| Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. | +| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. | + +**Document details** contains optional metadata tags. Sync frequency and the general knowledge-base **Max Threads** setting are hidden in Search. + +## What gets indexed + +Sim indexes the message text Gmail returns for each matching thread, plus subjects, senders, dates, and labels. Filters select threads; messages within a selected thread are not filtered again. HTML email is converted to text. Results link back to Gmail. + +File attachments and image contents are not indexed. Thread discovery uses Gmail's default exclusion of Spam and Trash. A filter such as `has:attachment` selects the email thread; it does not index the attachment. Gmail API filtering also differs from Gmail's interface for aliases and thread-wide searches. See Google's [thread listing reference](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads/list) and [filtering guide](https://developers.google.com/workspace/gmail/api/guides/filtering). + +Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search. + +## Troubleshooting + +| What you see | What to do | +| --- | --- | +| A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. | +| No searchable documents | Check the source's labels, date range, category exclusions, and search filter. Allow the first sync to finish. | +| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect account** again. | +| Reconnect | Click **Reconnect** and authorize the same account again. | +| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | + +## Self-hosted operator setup + +Users do not need to create Google Cloud credentials. The deployment operator configures one Google OAuth client for the instance: + +1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it. +2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience. +3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + +```text +https:///api/auth/oauth2/callback/google-email +``` + +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..4509563522f --- /dev/null +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -0,0 +1,118 @@ +--- +title: Google Calendar +description: Search calendar events using each teammate's own Google access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search meetings and event details available to your Google account. An organization admin enables the source; every teammate connects their own account. Google controls which calendar and event details each person can read. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Set up the source + +These steps require a Sim organization admin. + + + + +### Add Google Calendar + +Open **Settings → Integrations → Providers**, approve **Google Calendar**, then select **Set up**. Search uses **Member accounts**; an admin or service account cannot connect on behalf of everyone. + + + + +### Choose the calendars + +Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select them using **Browse with**, or switch to **Calendar IDs** and enter their IDs. + +**Browse with** only helps you choose calendars. It does not connect your account for Search or grant teammates access. + + + + +### Create the source + +Keep the default date range for the previous and next 30 days, then click **Add source**. Each person, including the admin, connects their own account from the **Sources** list. + + + + +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..0918de1e56c --- /dev/null +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -0,0 +1,165 @@ +--- +title: Google Drive +description: Connect Drive files through member accounts or a delegated service account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search Google Docs, Sheets, Slides, and supported files in Drive. A Sim organization admin chooses the folders and connection method once. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Choose your setup + +| Method | Use it when | What teammates do | +| --- | --- | --- | +| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts after the source is created. | +| **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Sign in to Sim with matching verified email addresses; no personal Drive connection is needed for this source. | + + + A central crawl indexes only files the configured **Crawl as** account can access. Domain-wide delegation does not make this connector crawl every employee's Drive. Share the intended content with the indexing account, or use member accounts for each person's accessible files. + + +## Set up member accounts + + + + +### Add Google Drive + +Open **Settings → Integrations → Providers**, approve **Google Drive**, then select **Set up** and choose **Member accounts**. + + + + +### Choose the files + +Leave **Folders** empty to include supported files each connected member can access, or select folders to narrow the source. **Browse with** helps you pick folders; you can also switch to **Folder IDs** and enter comma-separated IDs from their Drive URLs. + +Keep **Sync documents with → Connected members** unless you have a dedicated indexing account. Selecting an indexing account does not replace each person's access verification. If that indexing account is a delegated service account, use **Crawl as** to choose the Google Workspace user whose files it should fetch. + + + + +### Create and connect + +Select **Add source**, then **Connect account** on the source row. Use the Google account matching your verified Sim email. Teammates follow the same [connection steps](/search/connect-your-account) after joining the organization. + + + + +## Set up a central service account + +This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. + +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..a03d0366433 --- /dev/null +++ b/apps/docs/content/docs/search/index.mdx @@ -0,0 +1,100 @@ +--- +title: Search +description: Connect your team's sources and search the documents each person can access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search brings your connected sources into one place. An organization admin adds a source and chooses what to include. Teammates then [connect their accounts](/search/connect-your-account) when the source requires it. Each person searches with their own access. + +## Add your first source + + + + +### Choose a source + +As an organization admin, open **Settings → Integrations → Providers**. Approve the provider for Sim Search, then select **Set up** beside it. Use its **Setup guide** for the provider's prerequisites. + +Approval lets members connect; it does not connect an account or start indexing. Slack also requires an admin to configure the organization's Slack app first. + + + + +### Configure it once + +Choose the folders, repositories, calendars, spaces, or channels to include. Start with the defaults unless you need to narrow the scope. **Document details** contains optional metadata. + +Select **Connect & Sync** for an administrator connection, or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. + + + + +### Connect and search + +In **Integrations**, select **Connect account** if prompted—even if you created the source. Complete authorization in the new tab, then return to the source list. Open **Search** in the organization sidebar to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. + + + + +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 use member accounts. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. + +| Method | What the admin does | What teammates do | +| --- | --- | --- | +| **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. | +| **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | +| **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | + +Some member sources offer **Sync documents with**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. **Browse with** only helps an admin pick source options—it does not enroll that account for Search. + + + An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. It also does not automatically discover every employee's data: the indexing account must be able to read the configured content. + + +## Connector guides + +| Source | Content | Connection in Search | +| --- | --- | --- | +| [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects | +| [GitHub](/search/github) | Repository text files | GitHub App installation plus each member's authorization | +| [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection | +| [Gmail](/search/gmail) | Email thread text | Each member's Gmail account | +| [Google Calendar](/search/google-calendar) | Calendar events | Each member's Google Calendar account | +| [Google Drive](/search/google-drive) | Supported Drive files | Delegated service account or member accounts | +| [Jira](/search/jira) | Issues | Each member's Jira account | +| [Slack](/search/slack) | Channel messages and threads | Slack app installation plus each member's authorization | + +## Bring your team + +Invite people through the organization's **Settings → Members**, or use your organization's [SSO provisioning](/platform/enterprise/sso). Share the organization's **Home** or **Integrations** URL. People need their own Sim account and organization membership; they do not need access to a workspace. Connecting an external account alone does not grant organization membership. + +Organization admins manage source configuration and sync status through **Manage** under **Settings → Integrations → Providers**. Other members connect or reconnect their own accounts. See [Connect your account](/search/connect-your-account) for the teammate walkthrough. + +## Search, Assistant, and MCP + +**Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin. + +To search from an MCP-compatible app, open **Settings → Search MCP**. Generate a personal Sim API key there, or use an existing personal key with the displayed connection details. MCP applies your current organization membership and document access. + +## Existing workspace Search + +Workspace Search remains separate. Workspace admins add sources through **Search → Add source**; the member-account action is **Create & Invite**. Teammates need workspace access and connect from its source list. Organization Search does not automatically include workspace sources or grant access to workspace content. + +## Check that it works + +1. Let the first sync finish, then search for a distinctive phrase in a document you can open in the source. +2. Open the result's source link and confirm the document is the expected one. +3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them. +4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh. + +Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. Admins can inspect errors and progress under **Manage**. + +These guides cover permission-aware Search sources. For a general knowledge base used by workflows, see [Knowledge-base connectors](/knowledgebase/connectors); its workspace access settings are a separate choice. diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx new file mode 100644 index 00000000000..0c16e6ecabe --- /dev/null +++ b/apps/docs/content/docs/search/jira.mdx @@ -0,0 +1,141 @@ +--- +title: Jira +description: Connect Jira Cloud projects to Search using each teammate's account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search issue titles, descriptions, and metadata from selected Jira Cloud projects. An organization admin sets up the source; each teammate connects their own Jira account to search the issues they can access. + +This Search connector uses **Member accounts**. It does not offer a central admin crawl. Comments, attachment contents, dashboards, and saved filters are not indexed. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Before you start + +- A **Sim organization admin** must approve Jira. An admin can configure the source beforehand, or the first member connection can supply the required site and project settings. +- Use an Atlassian Cloud site such as `your-team.atlassian.net`. Jira Server and Data Center are not supported by this connector. +- Each person needs a verified Sim email matching the email on their active Atlassian account, plus access to the selected Jira site and projects. Jira's **Browse Projects** and issue security permissions still determine which issues they can search. + +On hosted Sim, teammates authorize the existing Sim app. They do not create an Atlassian app or API token. Deployment owners running their own Sim instance configure the [shared OAuth app](#self-hosted-operator-setup) once. + + +Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to read issues, `read:me` to identify the connected person, and `offline_access` to refresh the connection. The authorization screen also includes permissions for other Jira features, including writes. Review the requested permissions before authorizing. + + +## Set up the source + + + + +### Choose Jira + +Open **Settings → Integrations → Providers**, approve **Jira**, then select **Set up**. The connection method is **Member accounts**. + + + + +### Choose the projects + +Under **Browse with**, select an account or choose **Connect Jira account** and complete Atlassian authorization. Enter **Jira Domain**, then choose one or more **Projects**. + +If you already know the project keys, use the switch beside **Projects** to select manual input and enter keys such as `ENG, SUPPORT`. Manual input lets you configure the source without connecting a browsing account first. + +**Browse with** only populates the project picker. It does not enroll you or share that account's issue access with teammates. + +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 provider setup controls | Ask a Sim organization admin to approve and set up Jira. | +| Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. | +| Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. | +| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. | +| Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. | +| **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. | +| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. | + +### Check access in Jira + +First, open a missing issue in Jira using the same account you connected to Sim. If you cannot open it there, ask a Jira admin to check its project permissions and issue security. + +For company-managed projects, an admin can open **Settings → System → Admin Helper → Permission Helper**, enter the affected user and issue key, and check **Browse Projects**. The result explains which permission condition failed. Fix access in Jira, then let the next Sim sync finish. See Atlassian's [Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/) and [illustrated permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). + +Atlassian illustration 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..61ce37a9c81 --- /dev/null +++ b/apps/docs/content/docs/search/slack.mdx @@ -0,0 +1,116 @@ +--- +title: Slack +description: Set up a workspace Slack app and connect members for channel search +--- + +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Slack Search indexes channel messages and threads. A Sim organization admin configures your Slack app once, then each teammate authorizes their own Slack account. Their results are limited to the selected public channels and private channels they can access. DMs and group DMs are not indexed. + +Slack source setup in Sim Search + +## Before you start + +You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account. + +This guide covers **indexing Slack messages for Search and MCP**. It does not install a Sim assistant that answers inside Slack. + +## Set up the organization's Slack app + +Skip to **Connect the source** if the organization already has a verified Slack app for member accounts. + + + + +### Open provider settings + +Open **Settings → Integrations → Providers**. Approve **Slack**, select **Set up**, then **Set up Slack**. Approval alone does not configure the app or start indexing. + + + + +### Configure an app in Slack + +On the [Slack Apps page](https://api.slack.com/apps), create an app for the target workspace, or use an app dedicated to your Sim organization. Under **OAuth & Permissions**, add the user scopes and both redirect URLs listed below. Keep **Token Rotation** disabled. + +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). Use your own app's credentials.* + +In Sim, enter these four fields: + +| Sim field | Where to find it | +|---|---| +| Slack App ID | Slack app **Basic Information → App Credentials** (`A…`). | +| Slack workspace ID | The workspace segment of the Slack web URL, `app.slack.com/client/T…/…`. | +| Client ID | The same app's **Basic Information → App Credentials**. | +| Client Secret | The same app's **Basic Information → App Credentials**. | + +Organization setup uses personal user authorization. It does not ask for a bot token or signing secret. + + + + +### Verify and continue + +Select **Verify and add**, then authorize the app in the Slack popup. Sim verifies the app, workspace, client credentials, and required scopes. Allow popups if the window does not open. + +After verification, Sim returns to the source form. If Slack requires administrator approval, complete that approval before continuing. + + + + +## Connect the source + +Keep **Sync documents with → Connected members** for the usual setup. Configure only the limits you need: + +| Field | Behavior | +|---|---| +| Channels | Leave blank for all accessible public and private channels, or choose channel names/IDs. | +| Excluded Channels | Names or IDs to omit; exclusions override included channels. | +| Archived Channels | Included by default. | +| Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. | + +Select **Add source**. On the source row, each person selects **Connect account** and approves the configured Slack app. Creating the source or verifying the Slack app does not authorize teammates automatically. + +You can instead select an existing account under **Sync documents with** to supply message content centrally. Members still connect their own accounts to establish access. The selected account must itself be able to read the selected channels. + +Admins can use **Manage** to inspect sync progress. Search reads the indexed content, so source changes appear after background syncing. Slack retention and API limits determine how much history is available. + +## Permissions reference + +The organization account pool supports Search and workspace workflow tools. Its current authorization requests the following **User Token Scopes**, including write permissions. Search itself only indexes channel messages and threads; workspace use is controlled separately in the organization account settings. + +| Purpose | User scopes | +|---|---| +| Public channels | `channels:read`, `channels:history`, `channels:write` | +| Private channels | `groups:read`, `groups:history`, `groups:write` | +| Messages and conversations | `chat:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write` | +| Files and canvases | `files:read`, `files:write`, `canvases:read`, `canvases:write` | +| Reactions | `reactions:read`, `reactions:write` | +| Identity and profile | `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write` | + +Add both redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: + +```text +https:///api/credential-groups/slack-managed-users/callback +https:///api/credential-groups/oauth/slack/callback +``` + +Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table above. If scopes change, update the Slack app and have members reconnect. Do not change a shared production app's credentials to configure a separate test installation. + +For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests the six read-only channel and identity scopes instead. Its bot installation is separate from the organization setup described here. + +See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/). + +## Troubleshooting + +| Problem | Next step | +|---|---| +| Setup keeps asking for a Slack app | Finish **Verify and add** in the Slack setup; approval alone is insufficient. | +| Redirect mismatch | Check both redirect URLs above against your Sim origin. | +| App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. | +| Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. | +| Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. | +| Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. | diff --git a/apps/docs/content/docs/workflows/blocks/credential.mdx b/apps/docs/content/docs/workflows/blocks/credential.mdx index ae28df764a2..a6044fea0a0 100644 --- a/apps/docs/content/docs/workflows/blocks/credential.mdx +++ b/apps/docs/content/docs/workflows/blocks/credential.mdx @@ -1,6 +1,6 @@ --- title: Credential -description: The Credential block outputs an OAuth credential's ID reference for downstream blocks to use. +description: Select workspace OAuth credentials or find organization OAuth and MCP account references for downstream blocks. --- import { Callout } from 'fumadocs-ui/components/callout' @@ -14,7 +14,7 @@ import { } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Credential block** hands a downstream block an OAuth credential to use, without exposing the secret. It has two operations: **Select Credential** outputs one credential's ID reference for other blocks to use; **List Credentials** returns all the workspace's OAuth credentials (optionally filtered by provider) as an array to iterate over. +The **Credential block** passes account references to downstream blocks without exposing tokens. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the organization operations find or list contributed OAuth accounts and managed MCP connections. @@ -30,6 +30,10 @@ The **Credential block** hands a downstream block an OAuth credential to use, wi |---|---| | **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks | | **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop | +| **Find Organization Account** | Find exactly one active OAuth contribution by invitation email and provider | +| **List Organization Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers | +| **Find Organization MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider | +| **List Organization MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider | ### Credential (Select operation) @@ -73,6 +77,68 @@ Filter the returned OAuth credentials by provider. Select one or more providers +## Organization accounts + +An organization owner or admin must first [set up connected accounts](/platform/connected-accounts) and allow this workflow's workspace. The block uses the organization that owns the workspace; there is no credential group or organization selector. + +Every authorized workflow in an allowed workspace can use every active contribution in the organization's pool. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply. + +### Inputs + +| Operation | Required fields | Optional fields | +| --- | --- | --- | +| **Find Organization Account** | Email, Provider | — | +| **List Organization Accounts** | — | Email, Providers, Limit, Cursor | +| **Find Organization MCP Connection** | Email, MCP provider | — | +| **List Organization MCP Connections** | — | Email, MCP provider, Limit, Cursor | + +For list operations, **Limit** accepts 1–100 and defaults to 100. **Cursor** accepts the previous page's `nextCursor`. + +**Email** refers to the address used for the person's invitation. Sim associates that invitation with their verified Sim user. See [email association](/platform/connected-accounts#how-the-email-is-associated-with-a-sim-user) for how OAuth and managed MCP identity checks differ. + +Find operations fail unless there is exactly one active matching connection. List operations return an empty array when there are no matches and omit inactive or revoked connections. + +### OAuth outputs + +**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode. + +**List Organization Accounts** returns these account references in `credentials`, along with `count`, `hasMore`, and `nextCursor`. `count` is the number returned on this page. Feed `credentials` into a ForEach loop and use `` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically. + +For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `` in a Gmail block to act using Alex's contribution. + +### Managed MCP outputs + +**Find Organization MCP Connection** returns: + +| Output | Type | Description | +| --- | --- | --- | +| `credentialId` | `string` | The person's managed MCP connection ID; use this connection for MCP calls | +| `email` | `string` | Invitation email used to find the connection | +| `displayName` | `string` | Connection display name | +| `mcpServerId` | `string` | Shared MCP configuration ID | +| `mcpServerName` | `string` | Configured MCP server name | +| `toolNames` | `json` | Tool names available to this connection | + +**List Organization MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page. + + +For a managed MCP account, use the returned **`credentialId`** to select the person's connection in the MCP Tool block. **`mcpServerId`** identifies the shared provider configuration; it does not identify a person's authorization. No OAuth token or client secret is returned by the Credential block. + + +## Connection-event triggers + +Switch the Credential block to trigger mode to start a workflow when an account connects or a connection form is submitted. Select an **Event** and deploy the workflow in an allowed workspace. + +| Event | When it runs | +| --- | --- | +| **Credential Added** | A person adds a new account contribution | +| **Credential Reconnected** | A person reconnects an existing contribution | +| **Account Connections Submitted** | A person submits the connection form | + +Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Added and reconnected events also include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account. + +Each deployed workflow that selects the event in an allowed workspace can receive it. Removing workspace access stops subsequent event delivery. Legacy **Credential Group** blocks must be replaced with the Credential block; they are not automatically converted. + ## Examples ### Share one credential across blocks @@ -123,17 +189,19 @@ The same reference works for any OAuth block. In a Gmail or Slack block's creden ## Best Practices - **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `` instead of selecting the account five times -- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs +- **Inspect references, not tokens**: The block returns credential IDs and account metadata rather than tokens. Organization outputs can include people's email addresses - **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable - **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference - **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop - **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." }, + { question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference in your Function block's code. The function receives the ID string. It does not automatically resolve credential IDs to tokens; use a compatible integration or MCP block to perform an authenticated action." }, { question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." }, ]} /> diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d3a1fa9f366..533dcf0f5f3 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -8210,6 +8210,7 @@ "providerId": { "type": "string", "enum": [ + "github-repositories", "google-email", "google-drive", "google-docs", @@ -9492,6 +9493,11 @@ "minLength": 1, "maxLength": 2048 }, + "atlassianProduct": { + "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", + "type": "string", + "enum": ["jira", "confluence"] + }, "signingSecret": { "description": "Write-only webhook signing secret.", "writeOnly": true, diff --git a/apps/docs/public/static/search/add-source.jpg b/apps/docs/public/static/search/add-source.jpg new file mode 100644 index 00000000000..d2c1b6cbf2d Binary files /dev/null and b/apps/docs/public/static/search/add-source.jpg differ diff --git a/apps/docs/public/static/search/atlassian-oauth-callback.png b/apps/docs/public/static/search/atlassian-oauth-callback.png new file mode 100644 index 00000000000..23257a56eaf Binary files /dev/null and b/apps/docs/public/static/search/atlassian-oauth-callback.png differ diff --git a/apps/docs/public/static/search/confluence-setup.jpg b/apps/docs/public/static/search/confluence-setup.jpg new file mode 100644 index 00000000000..e2fe50864e4 Binary files /dev/null and b/apps/docs/public/static/search/confluence-setup.jpg differ diff --git a/apps/docs/public/static/search/connect-account.png b/apps/docs/public/static/search/connect-account.png new file mode 100644 index 00000000000..6f4bb5a06a0 Binary files /dev/null and b/apps/docs/public/static/search/connect-account.png differ diff --git a/apps/docs/public/static/search/github-app-callback.jpg b/apps/docs/public/static/search/github-app-callback.jpg new file mode 100644 index 00000000000..5ae4a20392b Binary files /dev/null and b/apps/docs/public/static/search/github-app-callback.jpg differ diff --git a/apps/docs/public/static/search/github-app-email-permission.jpg b/apps/docs/public/static/search/github-app-email-permission.jpg new file mode 100644 index 00000000000..a7086ca4739 Binary files /dev/null and b/apps/docs/public/static/search/github-app-email-permission.jpg differ diff --git a/apps/docs/public/static/search/github-app-repository-permissions.jpg b/apps/docs/public/static/search/github-app-repository-permissions.jpg new file mode 100644 index 00000000000..0e95461689e Binary files /dev/null and b/apps/docs/public/static/search/github-app-repository-permissions.jpg differ diff --git a/apps/docs/public/static/search/gitlab-options.jpg b/apps/docs/public/static/search/gitlab-options.jpg new file mode 100644 index 00000000000..96371a64fe8 Binary files /dev/null and b/apps/docs/public/static/search/gitlab-options.jpg differ diff --git a/apps/docs/public/static/search/gitlab-setup.jpg b/apps/docs/public/static/search/gitlab-setup.jpg new file mode 100644 index 00000000000..eceb8eab76e Binary files /dev/null and b/apps/docs/public/static/search/gitlab-setup.jpg differ diff --git a/apps/docs/public/static/search/gmail-setup.jpg b/apps/docs/public/static/search/gmail-setup.jpg new file mode 100644 index 00000000000..cdc89f465fc Binary files /dev/null and b/apps/docs/public/static/search/gmail-setup.jpg differ diff --git a/apps/docs/public/static/search/google-calendar-setup.jpg b/apps/docs/public/static/search/google-calendar-setup.jpg new file mode 100644 index 00000000000..f369fb01237 Binary files /dev/null and b/apps/docs/public/static/search/google-calendar-setup.jpg differ diff --git a/apps/docs/public/static/search/google-create-private-key.png b/apps/docs/public/static/search/google-create-private-key.png new file mode 100644 index 00000000000..ab21a326288 Binary files /dev/null and b/apps/docs/public/static/search/google-create-private-key.png differ diff --git a/apps/docs/public/static/search/google-create-service-account.png b/apps/docs/public/static/search/google-create-service-account.png new file mode 100644 index 00000000000..ec988ffcc24 Binary files /dev/null and b/apps/docs/public/static/search/google-create-service-account.png differ diff --git a/apps/docs/public/static/search/google-domain-delegation.png b/apps/docs/public/static/search/google-domain-delegation.png new file mode 100644 index 00000000000..b68680d0b82 Binary files /dev/null and b/apps/docs/public/static/search/google-domain-delegation.png differ diff --git a/apps/docs/public/static/search/google-drive-setup.jpg b/apps/docs/public/static/search/google-drive-setup.jpg new file mode 100644 index 00000000000..9e4e0af001d Binary files /dev/null and b/apps/docs/public/static/search/google-drive-setup.jpg differ diff --git a/apps/docs/public/static/search/google-oauth-web-client.png b/apps/docs/public/static/search/google-oauth-web-client.png new file mode 100644 index 00000000000..d7cba7d65c4 Binary files /dev/null and b/apps/docs/public/static/search/google-oauth-web-client.png differ diff --git a/apps/docs/public/static/search/google-service-account.jpg b/apps/docs/public/static/search/google-service-account.jpg new file mode 100644 index 00000000000..07c72b357fe Binary files /dev/null and b/apps/docs/public/static/search/google-service-account.jpg differ diff --git a/apps/docs/public/static/search/jira-setup.jpg b/apps/docs/public/static/search/jira-setup.jpg new file mode 100644 index 00000000000..75af1015724 Binary files /dev/null and b/apps/docs/public/static/search/jira-setup.jpg differ diff --git a/apps/docs/public/static/search/slack-app.jpg b/apps/docs/public/static/search/slack-app.jpg new file mode 100644 index 00000000000..ba66b20cb52 Binary files /dev/null and b/apps/docs/public/static/search/slack-app.jpg differ diff --git a/apps/docs/public/static/search/slack-setup.jpg b/apps/docs/public/static/search/slack-setup.jpg new file mode 100644 index 00000000000..9b701cd4427 Binary files /dev/null and b/apps/docs/public/static/search/slack-setup.jpg differ diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts index 93a94dd6eae..5361878dec2 100644 --- a/apps/sim/app/(auth)/auth-redirect.test.ts +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -32,7 +32,7 @@ describe('resolvePostSignupDestination', () => { it('never routes to verify when no mail provider is configured', () => { expect( resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' }) - ).toEqual({ kind: 'workspace' }) + ).toEqual({ kind: 'entry' }) }) it('preserves the callback URL when verification is not enforceable', () => { diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index e567b85b210..c487f5a1c99 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -1,3 +1,5 @@ +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' + /** * Where the user goes once authentication finishes, carried across the login → * signup → verify hops. Written only after `validateCallbackUrl` accepts it, and @@ -8,19 +10,22 @@ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' /** Route the verify hop lives at, entered only from signup. */ export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true' -/** Default post-auth destination when no callback URL was carried in. */ -export const DEFAULT_POST_AUTH_ROUTE = '/workspace' +/** + * Default post-auth destination when no callback URL was carried in: the app + * entry, which resolves to the viewer's organization or their workspaces. + */ +export const DEFAULT_POST_AUTH_ROUTE = APP_ENTRY_PATH /** * Where a successful email signup goes next. * - `verify`: the verification hop, which owns the post-auth redirect from there * - `redirect`: the validated callback URL the visitor arrived with - * - `workspace`: the default destination + * - `entry`: the default destination, {@link DEFAULT_POST_AUTH_ROUTE} */ export type PostSignupDestination = | { kind: 'verify' } | { kind: 'redirect'; url: string } - | { kind: 'workspace' } + | { kind: 'entry' } interface PostSignupDestinationParams { /** The server-derived effective flag — verification enabled AND deliverable. */ @@ -40,7 +45,7 @@ export function resolvePostSignupDestination({ redirectUrl, }: PostSignupDestinationParams): PostSignupDestination { if (emailVerificationEnabled) return { kind: 'verify' } - return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } + return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'entry' } } /** The raw redirect-carrying params, as read from a URL on client or server. */ diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index c200d86bd11..37df7815ed7 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { client } from '@/lib/auth/auth-client' +import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' const logger = createLogger('SocialLoginButtons') @@ -22,7 +23,7 @@ export function SocialLoginButtons({ githubAvailable, googleAvailable, microsoftAvailable, - callbackURL = '/workspace', + callbackURL = DEFAULT_POST_AUTH_ROUTE, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index cfe0b1403b8..c2dafcb06ca 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -22,7 +22,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' -import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -108,7 +108,7 @@ export default function LoginPage({ invalidCallbackRef.current = true logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam }) } - const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace' + const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : DEFAULT_POST_AUTH_ROUTE const isInviteFlow = searchParams?.get('invite_flow') === 'true' const signupHref = buildAuthCrossLink('/signup', { callbackUrl: isValidCallbackUrl ? callbackUrl : null, diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts index 1ecbb6e398f..d66a51b7d60 100644 --- a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts +++ b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts @@ -150,7 +150,7 @@ describe('OAuth login bridge', () => { }) ) expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe('https://sim.test/workspace') + expect(response.headers.get('location')).toBe('https://sim.test/home') } }) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 61ad48a8328..7488520915a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -408,7 +408,9 @@ function SignupFormContent({
- {hasOnlySSO && } + {hasOnlySSO && ( + + )} {emailEnabled && (
@@ -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 9c7c35370d7..358cd1f3753 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -56,11 +56,8 @@ vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ execute: mocks.createConnection, }, })) -vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ - launchCredentialConnection: { - operation: { id: 'credentials.connections.launch' }, - execute: mocks.launchConnection, - }, +vi.mock('@/lib/credentials/application/launch-scoped-credential-connection', () => ({ + launchScopedCredentialConnection: mocks.launchConnection, })) vi.mock('@/lib/oauth/utils', () => ({ getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes, @@ -440,7 +437,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`) expect(mocks.requireClient).toHaveBeenCalledWith('google-email') expect(mocks.createConnection).not.toHaveBeenCalled() }) @@ -521,7 +518,7 @@ describe('OAuth2 authorize route', () => { ) expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` + `${BASE_URL}/home?error=credential_provider_mismatch` ) }) @@ -561,9 +558,7 @@ describe('OAuth2 authorize route', () => { }) ) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=workspace_access_denied`) }) it('redirects a draft launch infrastructure failure through the browser error contract', async () => { @@ -571,7 +566,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(request({ draftId: 'draft-1' })) - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`) }) it('routes custom providers through the exact application draft', async () => { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 546271edaba..c0775f1e1cd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -15,8 +15,9 @@ import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' -import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' +import { launchScopedCredentialConnection } from '@/lib/credentials/application/launch-scoped-credential-connection' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants' import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' @@ -171,18 +172,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query try { + let organizationId: string | undefined let fromConnectionDraft = false let connectionDraftId: string | undefined let encryptedQuickBooksClientConfig: string | null | undefined if (draftId) { try { - const { draft } = await launchCredentialConnection.execute({ + const { draft } = await launchScopedCredentialConnection({ principal, input: { draftId }, request, }) providerId = draft.providerId - workspaceId = draft.workspaceId + workspaceId = draft.workspaceId ?? undefined + organizationId = draft.organizationId ?? undefined credentialId = draft.credentialId ?? undefined connectionDraftId = draft.id encryptedQuickBooksClientConfig = draft.oauthConfig @@ -190,11 +193,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } catch (error) { if (!(error instanceof OrchestrationError)) throw error logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) } } - if (!providerId || !workspaceId) { + if (!providerId || (!workspaceId && !organizationId)) { throw new Error('Validated OAuth authorization request is missing its target') } if (providerId !== 'quickbooks') { @@ -209,9 +212,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : connectionCompleteUrl.toString() : requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback - : `${baseUrl}/workspace` + : `${baseUrl}${APP_ENTRY_PATH}` if (!fromConnectionDraft) { + if (!workspaceId) throw new Error('Workspace OAuth launch is missing its owner') try { const connection = await createCredentialConnection.execute({ principal, @@ -226,22 +230,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { connectionDraftId = connection.draftId } catch (error) { if (error instanceof CredentialConnectionProviderMismatchError) { - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + return NextResponse.redirect( + `${baseUrl}${APP_ENTRY_PATH}?error=credential_provider_mismatch` + ) } if ( credentialId && error instanceof ForbiddenOperationError && error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' ) { - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=credential_access_denied`) } if (error instanceof OrchestrationError && error.code === 'not_found') { return NextResponse.redirect( - `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + `${baseUrl}${APP_ENTRY_PATH}?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` ) } if (error instanceof OrchestrationError && error.code === 'forbidden') { - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=workspace_access_denied`) } throw error } @@ -253,7 +259,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (providerId === 'quickbooks') { if (!encryptedQuickBooksClientConfig) { - const { draft } = await launchCredentialConnection.execute({ + const { draft } = await launchScopedCredentialConnection({ principal, input: { draftId: connectionDraftId }, request, @@ -309,7 +315,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId, status: linkResponse.status, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`) } const response = NextResponse.redirect(payload.url) @@ -324,6 +330,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return response } catch (error) { logger.error('Failed to initiate OAuth2 authorization', { providerId, error }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`) } }) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 19284a950fc..afca4590c1f 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -18,6 +18,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { safeAccountInsert } from '@/lib/oauth/credential-service' import { parseInstagramLongLivedToken, @@ -52,7 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`) + ) } const parsed = await parseRequest(instagramCallbackContract, request, {}) @@ -68,7 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error_description, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_access_denied`) ) } @@ -79,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { hasCookieState: Boolean(cookieState), }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_state_mismatch`) ) } @@ -90,7 +93,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!code) { logger.error('No authorization code received from Instagram') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_code`) ) } @@ -123,7 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_token_error`) ) } @@ -136,7 +139,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!shortLived) { logger.error('Instagram short-lived token response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_token`) ) } @@ -160,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_exchange_error`) ) } @@ -174,7 +177,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!longLived) { logger.error('Instagram long-lived token response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_long_lived`) ) } @@ -199,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`) ) } @@ -212,7 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!profile) { logger.error('Instagram profile response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`) ) } @@ -222,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!igUserId) { logger.error('Instagram profile response missing user_id', { profile }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_user_id`) ) } @@ -311,7 +314,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = - returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + returnUrlCookie && isSameOrigin(returnUrlCookie) + ? returnUrlCookie + : `${baseUrl}${APP_ENTRY_PATH}` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('instagram_connected', 'true') @@ -322,6 +327,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'instagram_config_error' : 'instagram_callback_error' - return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts index 51a36fd3c98..1dd57e3d27a 100644 --- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts @@ -140,7 +140,7 @@ describe('QuickBooks OAuth callback', () => { expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled() expect(response.headers.get('location')).toBe( - 'https://sim.test/workspace?error=quickbooks_callback_error' + 'https://sim.test/home?error=quickbooks_callback_error' ) }) }) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts index 675e7e8a076..f232cb655f6 100644 --- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts @@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { normalizeQuickBooksRealmId } from '@/lib/oauth/quickbooks' import { parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' @@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() - const fallbackUrl = `${baseUrl}/workspace` + const fallbackUrl = `${baseUrl}${APP_ENTRY_PATH}` let validatedReturnUrl: URL | null = null try { diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 8447e56d48d..7df3318106e 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -12,6 +12,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' @@ -63,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`) } const { searchParams } = request.nextUrl @@ -79,28 +80,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!validateHmac(searchParams, clientSecret)) { logger.error('HMAC validation failed in Shopify OAuth callback') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_hmac_invalid`) } if (!state) { logger.error('Missing state in Shopify OAuth callback') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_state_mismatch`) } if (!code) { logger.error('No code received from Shopify') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_code`) } const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_shop`) } if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format:', { shopDomain }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_invalid_shop`) } const { draftId, returnUrl } = parseShopifyOAuthState({ @@ -128,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { status: tokenResponse.status, body: errorText, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_token_error`) } const tokenData = await tokenResponse.json() @@ -142,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!accessToken) { logger.error('No access token in response') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_token`) } await completeShopifyOAuthConnection({ @@ -157,7 +158,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (returnUrl && !isSameOrigin(returnUrl)) { throw new Error('Shopify OAuth state contains an invalid return URL') } - const redirectUrl = returnUrl ?? `${baseUrl}/workspace` + const redirectUrl = returnUrl ?? `${baseUrl}${APP_ENTRY_PATH}` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') @@ -169,7 +170,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? 'shopify_config_error' : 'shopify_callback_error' return clearShopifyOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`) ) } }) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2d45e1dca3b..bbb96651bbb 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' const logger = createLogger('TrelloCallback') @@ -48,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const returnUrl = requestedReturnUrl && isSameOrigin(requestedReturnUrl) ? requestedReturnUrl - : `${baseUrl}/workspace` + : `${baseUrl}${APP_ENTRY_PATH}` const queryState = parsed.data.query.state const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 14b5d2bd4a8..7a11c742bbc 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -10,6 +10,7 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' const logger = createLogger('BillingPortal') @@ -28,7 +29,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const context = parsedBody.data.context const organizationId = parsedBody.data.organizationId - const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated` + const returnUrl = + parsedBody.data.returnUrl || `${getBaseUrl()}${APP_ENTRY_PATH}?billing=updated` const stripe = requireStripeClient() diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index dc669e8a169..2b38862261f 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -68,7 +68,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ }, COPILOT_BILLING_PROTOCOL_HEADER: 'x-sim-billing-protocol', requireAccountBillingDecisionHeader: mockRequireAccountBillingDecisionHeader, - requireBillingAttributionHeader: mockRequireBillingAttributionHeader, + requireBillingCallbackAttribution: mockRequireBillingAttributionHeader, resolveLegacyV0BillingAttribution: mockResolveLegacyV0BillingAttribution, toBillingContext: mockToBillingContext, })) @@ -303,6 +303,33 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { expect(mockRecordCumulativeUsage).not.toHaveBeenCalled() }) + it('settles an organization charge from its immutable envelope with no workspace ID', async () => { + const orgAttribution = { ...ATTRIBUTION, workspaceId: null } + mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution) + const id = '00000000-0000-4000-8000-000000000001' + const response = await POST( + createMockRequest( + 'POST', + { ...SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY, idempotencyKey: id }, + { + 'x-api-key': 'internal', + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': id, + 'x-sim-billing-attribution': 'serialized-org-attribution', + } + ) + ) + expect(response.status).toBe(200) + expect(mockRecordCumulativeUsage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: undefined, + billingEntity: { type: 'organization', id: 'org-1' }, + }) + ) + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + }) + it('does not let markerless legacy traffic fall through to a modern attribution envelope', async () => { const res = await POST( createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 92e0d3b32d0..f9537851d57 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -14,7 +14,7 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, type CopilotBillingProtocol, requireAccountBillingDecisionHeader, - requireBillingAttributionHeader, + requireBillingCallbackAttribution, resolveLegacyV0BillingAttribution, toBillingContext, } from '@/lib/billing/core/billing-attribution' @@ -156,8 +156,17 @@ async function updateCostInner(req: NextRequest, span: Span): Promise ({ mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -41,6 +43,7 @@ const { mockSerializeBillingAttributionHeader: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), + mockAuthorizeOrganizationChat: vi.fn(), })) const ATTRIBUTION = { @@ -117,6 +120,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ deriveBillingContext: mockDeriveBillingContext, })) +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + authorizeOrganizationChatDelegation: { execute: mockAuthorizeOrganizationChat }, +})) + vi.mock('@/lib/copilot/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, })) @@ -312,6 +319,73 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() }) + it('requires the current actor and canonical private chat for organization admission', async () => { + const orgAttribution = { ...ATTRIBUTION, workspaceId: null } + mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution) + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }, + { + 'x-api-key': 'internal', + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': '00000000-0000-4000-8000-000000000001', + 'x-sim-billing-attribution': 'serialized-attribution', + } + ) + ) + expect(response.status).toBe(200) + expect(mockAuthorizeOrganizationChat).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'organization_delegated', + subjectUserId: 'user-1', + organizationId: 'org-1', + resourceScope: { chatId: 'chat-1' }, + }), + }) + expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(expect.anything(), { + actorUserId: 'user-1', + organizationId: 'org-1', + }) + expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(orgAttribution) + }) + + it('denies removed members before billing admission', async () => { + mockAuthorizeOrganizationChat.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Conversation not found') + ) + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }, + { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' } + ) + ) + expect(response.status).toBe(403) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + }) + + it('rejects markerless organization admission rather than settling it as a personal account', async () => { + const response = await POST( + request({ userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }) + ) + expect(response.status).toBe(400) + expect(mockAuthorizeOrganizationChat).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + }) + + it('rejects an organization request missing its private chat', async () => { + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1' }, + { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' } + ) + ) + expect(response.status).toBe(400) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + }) + it('uses the exact frozen attribution for attributed-v1 admission', async () => { const res = await POST( request( diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index ae9e01f4782..970220f9099 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot' @@ -13,12 +14,18 @@ import { requireBillingAttributionHeader, requireBillingRequestIdHeader, resolveLegacyV0BillingAttribution, + resolveOrganizationBillingAttribution, serializeAccountBillingDecisionHeader, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprisePlan } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/copilot/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ATTRIBUTION_HEADER, @@ -33,6 +40,7 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isHosted } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -51,7 +59,7 @@ type AdmissionBillingDecision = userId: string } | { - kind: 'legacy-workspace' + kind: 'legacy-scoped' attribution: BillingAttributionSnapshot includeAttribution: boolean } @@ -74,21 +82,38 @@ async function resolveAdmissionBillingDecision( req: NextRequest, protocol: CopilotBillingProtocol | undefined, actorUserId: string, - workspaceId: string | undefined + workspaceId: string | undefined, + organizationId: string | undefined, + chatId: string | undefined ): Promise { const hasBillingRequestId = Boolean(req.headers.get(BILLING_REQUEST_ID_HEADER)) const hasBillingAttribution = Boolean(req.headers.get(BILLING_ATTRIBUTION_HEADER)) const hasBillingAccountDecision = Boolean(req.headers.get(BILLING_ACCOUNT_DECISION_HEADER)) + if (organizationId && protocol === undefined) return invalidBillingProtocolResponse() + if (organizationId && protocol !== COPILOT_BILLING_PROTOCOL.direct) { + if (!chatId) return invalidBillingProtocolResponse() + const principal = createTrustedOrganizationCopilotPrincipal( + { + userId: actorUserId, + organizationId, + chatId, + delegationId: req.headers.get(BILLING_REQUEST_ID_HEADER) ?? generateId(), + }, + { audience: 'sim:copilot-billing', ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS } + ) + await authorizeOrganizationChatDelegation.execute({ principal }) + } + if (protocol === COPILOT_BILLING_PROTOCOL.attributed) { - if (!workspaceId || hasBillingAccountDecision) { + if ((!workspaceId && !organizationId) || hasBillingAccountDecision) { return invalidBillingProtocolResponse() } try { requireBillingRequestIdHeader(req.headers) const attribution = requireBillingAttributionHeader(req.headers, { actorUserId, - workspaceId, + ...(organizationId ? { organizationId } : { workspaceId }), }) return { kind: 'attributed', attribution } } catch { @@ -124,10 +149,18 @@ async function resolveAdmissionBillingDecision( if (hasBillingRequestId || hasBillingAttribution || hasBillingAccountDecision) { return invalidBillingProtocolResponse() } - if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId) { + if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId && !organizationId) { return invalidBillingProtocolResponse() } + if (organizationId) { + return { + kind: 'legacy-scoped', + attribution: await resolveOrganizationBillingAttribution({ actorUserId, organizationId }), + includeAttribution: true, + } + } + if (workspaceId) { const attribution = await resolveLegacyV0BillingAttribution({ actorUserId, @@ -135,7 +168,7 @@ async function resolveAdmissionBillingDecision( }) if (attribution) { return { - kind: 'legacy-workspace', + kind: 'legacy-scoped', attribution, includeAttribution: protocol === COPILOT_BILLING_PROTOCOL.legacy, } @@ -152,7 +185,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise scope: string accountBillingDecision?: AccountBillingDecision }> { - if (admission.kind === 'attributed' || admission.kind === 'legacy-workspace') { + if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') { const usage = await checkAttributedUsageLimits(admission.attribution) const enforcedUsage = usage.scope === 'member' && usage.memberUsage ? usage.memberUsage : usage.payerUsage @@ -254,7 +287,7 @@ export const POST = withRouteHandler((req: NextRequest) => ) if (!parsed.success) return parsed.response - const { userId, workspaceId } = parsed.data.body + const { userId, workspaceId, organizationId, chatId } = parsed.data.body const protocol = parsed.data.headers?.[COPILOT_BILLING_PROTOCOL_HEADER] span.setAttribute(TraceAttr.UserId, userId) @@ -267,7 +300,14 @@ export const POST = withRouteHandler((req: NextRequest) => } logger.info('[API VALIDATION] Validating usage limit', { userId }) - const admission = await resolveAdmissionBillingDecision(req, protocol, userId, workspaceId) + const admission = await resolveAdmissionBillingDecision( + req, + protocol, + userId, + workspaceId, + organizationId, + chatId + ) if (admission instanceof NextResponse) { span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) span.setAttribute(TraceAttr.HttpStatusCode, admission.status) @@ -289,9 +329,9 @@ export const POST = withRouteHandler((req: NextRequest) => scope: usage.scope, billingProtocol: protocol ?? COPILOT_BILLING_PROTOCOL.legacy, billingResolution: - admission.kind === 'legacy-workspace' ? 'mutable-request-time' : 'immutable-or-account', + admission.kind === 'legacy-scoped' ? 'mutable-request-time' : 'immutable-or-account', billingPayer: - admission.kind === 'attributed' || admission.kind === 'legacy-workspace' + admission.kind === 'attributed' || admission.kind === 'legacy-scoped' ? admission.attribution.billingEntity : (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }), }) @@ -322,7 +362,7 @@ export const POST = withRouteHandler((req: NextRequest) => responseHeaders[BILLING_ACCOUNT_DECISION_HEADER] = serializeAccountBillingDecisionHeader( usage.accountBillingDecision ) - } else if (admission.kind === 'legacy-workspace' && admission.includeAttribution) { + } else if (admission.kind === 'legacy-scoped' && admission.includeAttribution) { responseHeaders[BILLING_ATTRIBUTION_HEADER] = serializeBillingAttributionHeader( admission.attribution ) @@ -334,6 +374,9 @@ export const POST = withRouteHandler((req: NextRequest) => span.setAttribute(TraceAttr.HttpStatusCode, 200) return NextResponse.json({ isEnterprise }, { status: 200, headers: responseHeaders }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Conversation access denied' }, { status: 403 }) logger.error('Error validating usage limit', { error }) span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError) span.setAttribute(TraceAttr.HttpStatusCode, 500) diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts index f3655d0f825..dcd1c74dd33 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.test.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts @@ -5,6 +5,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockGetAccessibleChat, mockAbortActiveStream, mockAuthenticate, mockGetLatestRunForStream, @@ -16,6 +17,7 @@ const { const order: string[] = [] return { order, + mockGetAccessibleChat: vi.fn(), mockAbortActiveStream: vi.fn(async () => { order.push('abortActiveStream') return true @@ -30,6 +32,10 @@ const { } }) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) + vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticate, })) @@ -55,6 +61,7 @@ describe('POST /api/copilot/chat/abort', () => { beforeEach(() => { vi.clearAllMocks() order.length = 0 + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) mockWaitForPendingChatStream.mockResolvedValue(true) @@ -93,6 +100,21 @@ describe('POST /api/copilot/chat/abort', () => { expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1') }) + it('refuses an inaccessible organization chat before changing stream state', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await POST(abortRequest()) + expect(response.status).toBe(404) + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(mockAbortActiveStream).not.toHaveBeenCalled() + }) + + it('refuses a chat ID that does not belong to the authenticated run', async () => { + mockGetLatestRunForStream.mockResolvedValueOnce({ chatId: 'different-chat' }) + const response = await POST(abortRequest()) + expect(response.status).toBe(404) + expect(mockAbortActiveStream).not.toHaveBeenCalled() + }) + it('rejects an unauthenticated caller without touching either abort path', async () => { mockAuthenticate.mockResolvedValue({ userId: undefined, isAuthenticated: false }) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index bd90ccf1083..8aea11d6439 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -29,8 +30,11 @@ export const POST = withRouteHandler((request: NextRequest) => TraceSpan.CopilotChatAbortStream, undefined, async (rootSpan) => { - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() + const { + userId: authenticatedUserId, + isAuthenticated, + principal, + } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !authenticatedUserId) { rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Unauthorized) @@ -67,6 +71,15 @@ export const POST = withRouteHandler((request: NextRequest) => }) return null }) + if (!run || (chatId && chatId !== run.chatId)) { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) + } + const chat = run.chatId + ? await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }) + : null + if (run.chatId && !chat) { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) + } if (!chatId && run?.chatId) { chatId = run.chatId } @@ -98,6 +111,7 @@ export const POST = withRouteHandler((request: NextRequest) => userId: authenticatedUserId, chatId, workspaceId, + ...(chat?.organizationId ? { organizationId: chat.organizationId } : {}), timeoutMs: GO_EXPLICIT_ABORT_TIMEOUT_MS, }) goAbortOk = true diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 1b020709711..4a5c9ae2e73 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,10 +1,10 @@ import type { NextRequest } from 'next/server' import { copilotChatGetContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' -import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' import { GET as getChat } from '@/app/api/copilot/chat/queries' -export { maxDuration } +export const maxDuration = 3600 export const POST = handleUnifiedChatPost diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index 7c35ca8d355..ccee5a1bde7 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -5,9 +5,15 @@ import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ - mockAppendCopilotChatMessages: vi.fn(), - mockPublishStatusChanged: vi.fn(), +const { mockAppendCopilotChatMessages, mockPublishStatusChanged, mockGetAccessibleChat } = + vi.hoisted(() => ({ + mockGetAccessibleChat: vi.fn(), + mockAppendCopilotChatMessages: vi.fn(), + mockPublishStatusChanged: vi.fn(), + })) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ @@ -49,7 +55,21 @@ describe('copilot chat stop route', () => { // Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults. dbChainMockFns.limit.mockReset() resetDbChainMock() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) + }) + + it('does not persist stopped content after organization access is removed', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await POST( + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'private' }) + ) + expect(response.status).toBe(200) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) it('returns 401 when unauthenticated', async () => { diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 91f17dbcff3..ef02d470844 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStopContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { normalizeMessage, type PersistedMessage, @@ -40,6 +41,10 @@ export const POST = withRouteHandler((req: NextRequest) => return parsed.response } const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { + principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + }) + if (!chat) return NextResponse.json({ success: true }) span.setAttributes({ [TraceAttr.ChatId]: chatId, [TraceAttr.StreamId]: streamId, diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 24d7a99cfef..e93ca3121a1 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -10,13 +10,23 @@ import { MothershipStreamV1EventType, } from '@/lib/copilot/generated/mothership-stream-v1' -const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } = - vi.hoisted(() => ({ - getLatestRunForStream: vi.fn(), - readEvents: vi.fn(), - readFilePreviewSessions: vi.fn(), - checkForReplayGap: vi.fn(), - })) +const { + mockGetAccessibleChat, + getLatestRunForStream, + readEvents, + readFilePreviewSessions, + checkForReplayGap, +} = vi.hoisted(() => ({ + mockGetAccessibleChat: vi.fn(), + getLatestRunForStream: vi.fn(), + readEvents: vi.fn(), + readFilePreviewSessions: vi.fn(), + checkForReplayGap: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) vi.mock('@/lib/copilot/async-runs/repository', () => ({ getLatestRunForStream, @@ -65,6 +75,7 @@ async function readAllChunks(response: Response): Promise { describe('copilot chat stream replay route', () => { beforeEach(() => { vi.clearAllMocks() + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -74,6 +85,21 @@ describe('copilot chat stream replay route', () => { checkForReplayGap.mockResolvedValue(null) }) + it('refuses replay after organization membership is removed', async () => { + getLatestRunForStream.mockResolvedValueOnce({ + status: 'complete', + id: 'run-1', + chatId: 'chat-1', + }) + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await GET( + new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&batch=true') + ) + expect(response.status).toBe(404) + expect(readEvents).not.toHaveBeenCalled() + expect(readFilePreviewSessions).not.toHaveBeenCalled() + }) + it('returns preview sessions in batch mode', async () => { getLatestRunForStream.mockResolvedValue({ status: 'active', diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 47b1f65c79c..1a56c7f8435 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,4 +1,5 @@ import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api' +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' @@ -6,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStreamContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -115,8 +117,11 @@ function buildResumeTerminalEnvelopes(options: { } export const GET = withRouteHandler(async (request: NextRequest) => { - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() + const { + userId: authenticatedUserId, + isAuthenticated, + principal, + } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !authenticatedUserId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -169,6 +174,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { afterCursor, batchMode, authenticatedUserId, + principal, rootSpan, rootContext, }) @@ -186,6 +192,7 @@ async function handleResumeRequestBody({ afterCursor, batchMode, authenticatedUserId, + principal, rootSpan, rootContext, }: { @@ -194,6 +201,7 @@ async function handleResumeRequestBody({ afterCursor: string batchMode: boolean authenticatedUserId: string + principal?: Principal rootSpan: Span rootContext: Context }) { @@ -211,7 +219,11 @@ async function handleResumeRequestBody({ hasRun: !!run, runStatus: run?.status, }) - if (!run) { + if ( + !run || + (run.chatId && + !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }))) + ) { rootSpan.setAttribute(TraceAttr.CopilotResumeOutcome, CopilotResumeOutcome.StreamNotFound) rootSpan.end() return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) @@ -323,6 +335,13 @@ async function handleResumeRequestBody({ request.signal.addEventListener('abort', abortListener, { once: true }) const flushEvents = async () => { + if ( + run?.chatId && + !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal })) + ) { + closeController() + return + } const events = await readEvents(streamId, cursor) if (events.length > 0) { logger.debug('[Resume] Flushing events', { diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index d247d93ee99..b6a0c57c2ec 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -41,16 +41,20 @@ const turnRegistryCache = new Map< async function getTurnEgressRegistry( userId: string, workspaceId: string | undefined, - messageId: string | undefined + messageId: string | undefined, + requestMode?: string, + organizationId?: string ): Promise { - const key = `${userId}\u0000${workspaceId ?? ''}\u0000${messageId ?? ''}` + const key = `${userId}\u0000${workspaceId ?? ''}\u0000${organizationId ?? ''}\u0000${messageId ?? ''}\u0000${requestMode ?? ''}` const now = Date.now() const hit = turnRegistryCache.get(key) if (hit && hit.expiresAt > now) { hit.expiresAt = now + TURN_REGISTRY_TTL_MS return hit.registry } - const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId) + const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId, { + includeSecrets: requestMode !== 'assistant', + }) for (const [cachedKey, cached] of turnRegistryCache) { if (cached.expiresAt <= now) turnRegistryCache.delete(cachedKey) } @@ -107,10 +111,13 @@ export const POST = withRouteHandler((request: NextRequest) => userId, workflowId, workspaceId, + organizationId, chatId, messageId, parentToolCallId, userPermission, + requestMode, + assistantSearch, } = validation.data rootSpan.setAttributes({ [TraceAttr.ToolName]: toolName, @@ -121,7 +128,13 @@ export const POST = withRouteHandler((request: NextRequest) => let toolRegistry: ResolvedSecretTraceRegistry let turnRegistry: ResolvedSecretTraceRegistry try { - turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId) + turnRegistry = await getTurnEgressRegistry( + userId, + workspaceId, + messageId, + requestMode, + organizationId + ) toolRegistry = turnRegistry.forkForInputPaths([]) } catch (err) { /** @@ -167,12 +180,16 @@ export const POST = withRouteHandler((request: NextRequest) => userId, workflowId: workflowId ?? '', workspaceId, + organizationId, chatId, messageId, toolCallId, parentToolCallId, userPermission, copilotToolExecution: true, + copilotInteractionMode: 'interactive', + requestMode, + assistantSearch, resolvedSecretTraceRegistry: toolRegistry, }) const projection = inspectToolResultForCopilot(result, toolRegistry, toolName) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts index ff16fe62b7c..621463df095 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -38,9 +38,9 @@ const context = { params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }), } -function request() { +function request(query = '') { return new NextRequest( - 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1' + `http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1${query}` ) } @@ -69,6 +69,41 @@ describe('credential group OAuth start route', () => { }) }) + it('forwards only the closed Search return context to the authorized operation', async () => { + await GET(request('?returnTo=search'), context) + expect(mocks.startOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { invitationToken: 'invitation-token', optionId: 'option-1', returnTo: 'search' }, + }) + ) + mocks.startOAuth.mockClear() + const response = await GET(request('?returnTo=https://external.test'), context) + expect(response.status).toBe(400) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it.each(['ip', 'enrollment', 'unavailable', 'configuration'])( + 'preserves exact Search focus after %s failure', + async (failure) => { + if (failure === 'ip') + mocks.ipRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + if (failure === 'enrollment') + mocks.enrollmentRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + if (failure === 'unavailable') mocks.authenticate.mockResolvedValue(null) + if (failure === 'configuration') + mocks.startOAuth.mockRejectedValue(new Error('Unavailable configuration')) + const response = await GET(request('?returnTo=search'), context) + const location = new URL(response.headers.get('location')!, 'http://localhost') + expect(location.pathname).toBe('/credential-groups/enroll/invitation-token') + expect(location.searchParams.get('optionId')).toBe('option-1') + expect(location.searchParams.get('returnTo')).toBe('search') + expect(location.searchParams.get('oauth')).toBe( + failure === 'ip' || failure === 'enrollment' ? 'rate_limited' : 'unavailable' + ) + } + ) + it('returns an unavailable enrollment to its public page', async () => { mocks.authenticate.mockResolvedValue(null) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts index bc66315f68e..aa5582d7263 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -29,25 +29,27 @@ export const GET = withRouteHandler( const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) if (!parsed.success) return limited ?? parsed.response const { token, optionId } = parsed.data.params + const { returnTo } = parsed.data.query + const focus: Record = returnTo ? { optionId, returnTo } : {} if (limited) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' }) } const principal = await authenticateCredentialGroupEnrollment(token) if (!principal) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'unavailable' }) } const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( principal.enrollmentId ) if (enrollmentLimited) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' }) } try { const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({ principal, - input: { invitationToken: token, optionId }, + input: { invitationToken: token, optionId, ...(returnTo ? { returnTo } : {}) }, request, }) const response = NextResponse.redirect(authorizationUrl) @@ -59,6 +61,7 @@ export const GET = withRouteHandler( error: getErrorMessage(error), }) return createCredentialGroupEnrollmentRedirect(token, { + ...focus, oauth: error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index b2a34e897bf..3ac896cdf46 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -20,11 +20,22 @@ export function createCredentialGroupEnrollmentRedirect( }) } -export function createCredentialGroupCompletionRedirect(): NextResponse { +export type CredentialGroupOAuthFailure = + | 'denied' + | 'account_mismatch' + | 'permissions_required' + | 'configuration_changed' + | 'rate_limited' + | 'unavailable' + | 'failed' + +export function createCredentialGroupCompletionRedirect( + oauth?: CredentialGroupOAuthFailure +): NextResponse { return new NextResponse(null, { status: 303, headers: { - Location: '/credential-groups/complete', + Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`, ...NO_STORE_REDIRECT_HEADERS, }, }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 217ff6b8aa0..67d998aa631 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -3,15 +3,20 @@ import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups' -import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, CredentialGroupOAuthError, } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' -import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' +import { + type CredentialGroupOAuthFailure, + createCredentialGroupCompletionRedirect, + createCredentialGroupEnrollmentRedirect, +} from '@/app/api/credential-groups/enrollment-redirect' const logger = createLogger('CredentialGroupOAuthCallbackAPI') @@ -34,6 +39,12 @@ export async function handleCredentialGroupOAuthCallback({ try { attempt = await consumeCredentialGroupOAuthAttempt(state) } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return NextResponse.json( + { error: error.message }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } logger.error('Failed to consume credential group OAuth state', { error: getErrorMessage(error), }) @@ -49,34 +60,36 @@ export async function handleCredentialGroupOAuthCallback({ { status: 400, headers: { 'Cache-Control': 'no-store' } } ) } + const focus: Record = attempt.returnTo + ? { optionId: attempt.optionId, returnTo: attempt.returnTo } + : {} + const failureRedirect = (oauth: CredentialGroupOAuthFailure) => + attempt.completionRedirect + ? createCredentialGroupCompletionRedirect(oauth) + : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'rate_limited', - }) + return failureRedirect('rate_limited') } if (providerError) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) + return failureRedirect('denied') } if (!code) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) - } - - const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) - if (!principal) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'unavailable', - }) + return failureRedirect('failed') } try { + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) await completePublicCredentialGroupOAuth.execute({ principal, input: { attempt, code }, request, }) - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - connected: attempt.optionId, - }) + return attempt.completionRedirect + ? createCredentialGroupCompletionRedirect() + : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + ...focus, + connected: attempt.optionId, + }) } catch (error) { logger.error('Managed OAuth authorization failed', { provider, @@ -92,6 +105,6 @@ export async function handleCredentialGroupOAuthCallback({ : error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' : 'failed' - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status }) + return failureRedirect(status) } } diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 0a402c66585..7056e109ba9 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -3,6 +3,7 @@ */ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), @@ -12,7 +13,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ - authenticateCredentialGroupEnrollment: mocks.authenticate, + credentialGroupOAuthAttemptPrincipal: mocks.authenticate, })) vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ @@ -27,7 +28,10 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, })) -import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupInvitationUnavailableError, + CredentialGroupOAuthError, +} from '@/lib/credential-groups/provider-adapter' import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route' const principal = { @@ -56,7 +60,7 @@ describe('credential group OAuth callback', () => { vi.clearAllMocks() mocks.rateLimit.mockResolvedValue(null) mocks.consumeAttempt.mockResolvedValue(attempt) - mocks.authenticate.mockResolvedValue(principal) + mocks.authenticate.mockReturnValue(principal) mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' }) }) @@ -65,6 +69,7 @@ describe('credential group OAuth callback', () => { const response = await GET(callbackRequest, context) expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(mocks.authenticate).toHaveBeenCalledWith(attempt) expect(mocks.completeOAuth).toHaveBeenCalledWith({ principal, input: { attempt, code: 'code-1' }, @@ -76,6 +81,56 @@ describe('credential group OAuth callback', () => { ) }) + it('restores the exact focused option after a successful Search connection', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, optionId: 'site-two', returnTo: 'search' }) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?optionId=site-two&returnTo=search&connected=site-two' + ) + expect(mocks.completeOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: expect.objectContaining({ + attempt: expect.objectContaining({ optionId: 'site-two' }), + }), + }) + ) + }) + + it.each([ + [new CredentialGroupInvitationUnavailableError(), 'unavailable'], + [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], + [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], + [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], + [new Error('Provider failed'), 'failed'], + ])('retains Search focus after a rejected provider exchange: %s', async (error, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' }) + mocks.completeOAuth.mockRejectedValueOnce(error) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.headers.get('location')).toBe( + `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}` + ) + }) + + it.each(['denied', 'rate_limited'])( + 'retains Search focus without exchanging after %s', + async (status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' }) + if (status === 'rate_limited') + mocks.rateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + const response = await GET( + request( + status === 'denied' ? 'state=state-1&error=access_denied' : 'state=state-1&code=code-1' + ), + context + ) + expect(response.headers.get('location')).toBe( + `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}` + ) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + } + ) + it('rejects standard providers on the custom callback route', async () => { const response = await GET( new NextRequest( @@ -113,7 +168,7 @@ describe('credential group OAuth callback', () => { }) it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => { - mocks.authenticate.mockResolvedValue(null) + mocks.completeOAuth.mockRejectedValueOnce(new CredentialGroupInvitationUnavailableError()) const response = await GET(request('state=state-1&code=code-1'), context) @@ -121,7 +176,7 @@ describe('credential group OAuth callback', () => { expect(response.headers.get('location')).toBe( '/credential-groups/enroll/invitation-token?oauth=unavailable' ) - expect(mocks.completeOAuth).not.toHaveBeenCalled() + expect(mocks.completeOAuth).toHaveBeenCalledOnce() }) it('returns an unavailable enrollment redirect when the invitation is revoked during exchange', async () => { @@ -150,4 +205,59 @@ describe('credential group OAuth callback', () => { expect(mocks.authenticate).not.toHaveBeenCalled() expect(mocks.completeOAuth).not.toHaveBeenCalled() }) + + it('returns personal connections to the fixed completion page after invitation rotation', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + }) + + it.each([['error=access_denied', 'denied']])( + 'shows personal callback failure without reopening a stale invitation: %s', + async (query, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + const response = await GET(request(`state=state-1&${query}`), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + } + ) + + it.each([ + [new CredentialGroupInvitationUnavailableError(), 'unavailable'], + [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], + [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], + [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], + [new Error('Provider failed'), 'failed'], + ])('shows failed personal authorization on the completion page', async (error, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + mocks.completeOAuth.mockRejectedValueOnce(error) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`) + }) + + it('shows rate limits on the personal completion page without exchanging', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete?oauth=rate_limited') + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + it('reports a state protocol change as an explicit restart without exchanging a code', async () => { + mocks.consumeAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: expect.stringContaining('Reopen your invitation and connect again'), + }) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/credentials/personal/connect/route.ts b/apps/sim/app/api/credentials/personal/connect/route.ts new file mode 100644 index 00000000000..d7ea060700b --- /dev/null +++ b/apps/sim/app/api/credentials/personal/connect/route.ts @@ -0,0 +1,20 @@ +import { startPersonalCredentialConnectionContract } from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection' + +export const POST = defineInternalJsonRoute({ + contract: startPersonalCredentialConnectionContract, + auth: internalSessionAuth, + operation: credentialOperations.startPersonalConnection, + rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.connect' }), + errorPolicy: internalPersonalCredentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: startPersonalCredentialConnection, + present: (result) => result, +}) diff --git a/apps/sim/app/api/credentials/personal/route.ts b/apps/sim/app/api/credentials/personal/route.ts new file mode 100644 index 00000000000..61eb9d0f1cb --- /dev/null +++ b/apps/sim/app/api/credentials/personal/route.ts @@ -0,0 +1,26 @@ +import { listPersonalCredentialsContract } from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listPersonal, + rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.list' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalCredentials, + present: ({ credentials }) => ({ + credentials: credentials.map((entry) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + connectedAt: entry.connectedAt.toISOString(), + })), + }), +}) diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts index 65e2fe04550..cfc34b07b96 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts @@ -84,7 +84,7 @@ describe('Enterprise owner claim routes', () => { mocks.acceptClaim.mockResolvedValue({ success: true, claim, - redirectPath: '/workspace', + redirectPath: '/home', }) const response = await POST( diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index f0ac6468536..13aaf73db66 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -33,7 +33,9 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ - inferContextFromKey: vi.fn(() => 'knowledge-base'), + inferContextFromKey: vi.fn((key: string) => + key.startsWith('kb/') ? 'knowledge-base' : key.split('/')[0] + ), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -44,6 +46,7 @@ vi.mock('@/executor/constants', () => ({ isUuid: vi.fn(() => false), })) +import { SYSTEM_ACCESS_SCOPE } from '@/lib/knowledge/access/types' import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization' const CLOUD_KEY = 'kb/1780162789495-secret.txt' @@ -322,3 +325,70 @@ describe('workspace-scoped access (workspace files and mothership attachments)', expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() }) }) + +describe('organization connector cache access', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetFileMetadataByKey.mockResolvedValue({ + workspaceId: null, + organizationId: 'org-1', + userId: USER_ID, + deletedAt: null, + }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetFileMetadata.mockResolvedValue({ userId: USER_ID }) + dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }]) + }) + + it.each(['general', 'profile-pictures', 'knowledge-base'] as const)( + 'denies the uploader a raw download even with a forged %s context', + async (context) => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, context, false, { knowledgeAccess: 'user' }) + ).resolves.toBe(false) + expect(mockGetFileMetadata).not.toHaveBeenCalled() + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + } + ) + + it('allows the internal processor to read a live bound connector cache', async () => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(true) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('denies system reads after the cache loses its active document reference', async () => { + dbChainMockFns.limit.mockResolvedValue([]) + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + }) + + it('denies a binding claiming both organization and workspace ownership', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ + organizationId: 'org-1', + workspaceId: 'ws-1', + deletedAt: null, + }) + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) + }) + + it('does not let a raw download endpoint delete organization caches', async () => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'general', false, { + requireWrite: true, + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index dbac5ed031e..a847a5748ac 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -2,8 +2,10 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { resolveUserKnowledgeAccessScope, @@ -151,6 +153,12 @@ export async function verifyFileAccess( ): Promise { const requireWrite = options?.requireWrite ?? false try { + const keyContext = inferContextFromKey(cloudKey) + if (keyContext === 'knowledge-base') { + return requireWrite + ? verifyKBFileWriteAccess(cloudKey, userId) + : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) + } if (context === 'general') { return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) } @@ -188,7 +196,9 @@ export async function verifyFileAccess( // 4. KB files: kb/filename if (inferredContext === 'knowledge-base') { - return await verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) + return requireWrite + ? verifyKBFileWriteAccess(cloudKey, userId) + : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) } // 5. Chat files: chat/filename @@ -310,7 +320,7 @@ async function verifyPublicAssetWriteAccess( try { if (context === 'workspace-logos') { const binding = await getFileMetadataByKey(cloudKey, 'workspace-logos') - if (!binding?.workspaceId) { + if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) { logger.warn('workspace-logos delete denied: no ownership binding', { userId, cloudKey }) return false } @@ -496,7 +506,7 @@ type ResolvedKnowledgeFileAccess = KnowledgeAccessScope | SystemAccessScope async function hasActiveKbDocumentForKey( cloudKey: string, - workspaceId: string, + scope: ResourceScope, access: ResolvedKnowledgeFileAccess ): Promise { const rows = await db @@ -505,12 +515,15 @@ async function hasActiveKbDocumentForKey( .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) .where( and( - eq(knowledgeBase.workspaceId, workspaceId), + resourceScopeCondition(knowledgeBase, scope), eq(document.storageKey, cloudKey), eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), isNull(knowledgeBase.deletedAt), + access.kind === 'system' + ? undefined + : or(isNull(document.connectorId), isNotNull(document.contentHash)), knowledgeAccessCondition(access) ) ) @@ -572,6 +585,19 @@ async function verifyKBFileAccess( logger.warn('KB file access denied for deleted file binding', { userId, cloudKey }) return false } + if (binding.organizationId) { + if ( + binding.workspaceId || + typeof knowledgeAccess !== 'object' || + knowledgeAccess.kind !== 'system' + ) + return false + return hasActiveKbDocumentForKey( + cloudKey, + { kind: 'organization', organizationId: binding.organizationId }, + knowledgeAccess + ) + } if (!binding.workspaceId) { logger.warn('KB file binding missing workspace owner', { userId, cloudKey }) return false @@ -588,7 +614,13 @@ async function verifyKBFileAccess( } const access = await resolveKnowledgeFileAccess(knowledgeAccess, userId, binding.workspaceId) - if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId, access))) { + if ( + !(await hasActiveKbDocumentForKey( + cloudKey, + { kind: 'workspace', workspaceId: binding.workspaceId }, + access + )) + ) { logger.warn('KB file access denied: no readable document references the file', { userId, cloudKey, @@ -619,7 +651,7 @@ async function verifyKBFileAccess( export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise { try { const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base') - if (!binding?.workspaceId) { + if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) { logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey }) return false } diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index d6008ad1748..458c1658863 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -254,6 +254,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') case 'system': throw new UploadSessionError('forbidden', 'System principals cannot create uploads') + case 'organization_delegated': case 'credential_group_enrollment': throw new UploadSessionError( 'forbidden', diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts index ab89571ab7e..af895bf5a0d 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts @@ -24,8 +24,6 @@ export const PATCH = defineInternalJsonRoute({ connectorId: params.connectorId, knowledgeBaseId: params.id, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, credentialId: body.credentialId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 28199775a22..10eeff6ea5a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -50,8 +50,6 @@ export const POST = defineInternalJsonRoute({ sourceConfig: body.sourceConfig, syncIntervalMinutes: body.syncIntervalMinutes, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts new file mode 100644 index 00000000000..f9b396d2cda --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + flattenMockConditions, + hasMockCondition, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockConnectorRows, mockDispatch, mockClaim, mockWhere } = vi.hoisted( + () => ({ + mockVerifyCronAuth: vi.fn(() => null), + mockConnectorRows: vi.fn(), + mockDispatch: vi.fn(), + mockClaim: vi.fn(), + mockWhere: vi.fn(), + }) +) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/knowledge/connectors/directory-queue', () => ({ + dispatchDirectorySync: mockDispatch, +})) +vi.mock('@sim/db', () => ({ + db: { + update: () => ({ set: () => ({ where: () => ({ returning: () => mockClaim() }) }) }), + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: (condition: unknown) => { + mockWhere(condition) + return { orderBy: () => ({ limit: () => mockConnectorRows() }) } + }, + }), + }), + }), + }, +})) + +import { GET } from '@/app/api/knowledge/connectors/directory-sync/route' + +function connector(overrides: Record = {}) { + return { id: 'connector-1', nextDirectorySyncAt: new Date(0), ...overrides } +} + +async function run() { + const response = await GET(createMockRequest('GET')) + return response.json() +} + +describe('connector directory sync scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronAuth.mockReturnValue(null) + mockDispatch.mockResolvedValue(undefined) + mockClaim.mockResolvedValue([{ id: 'connector-1' }]) + }) + + /** + * Every eligible connector is offered under one tick time; the tenant-level + * freshness check in the refresh, not the scheduler, decides which walk. + */ + it('dispatches a refresh for every admin-mode connector under the same tick', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + + await expect(run()).resolves.toMatchObject({ considered: 2, dispatched: 2, failed: 0 }) + expect(mockDispatch).toHaveBeenCalledTimes(2) + const [, first] = mockDispatch.mock.calls[0] + const [, second] = mockDispatch.mock.calls[1] + expect(first.tickAt).toBe(second.tickAt) + }) + + it('includes either canonical owner while retaining mirrored-source eligibility', async () => { + mockConnectorRows.mockResolvedValue([connector({ id: 'org-source' })]) + await run() + const condition = mockWhere.mock.calls[0][0] + const ownerChoice = flattenMockConditions(condition).find((entry) => entry.type === 'or') + expect(ownerChoice).toBeDefined() + expect(ownerChoice?.conditions).toHaveLength(2) + const [workspaceOwner, organizationOwner] = Array.isArray(ownerChoice?.conditions) + ? ownerChoice.conditions + : [] + expect( + hasMockCondition( + workspaceOwner, + (node) => node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.workspaceId + ) + ).toBe(true) + expect( + hasMockCondition( + workspaceOwner, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.organizationId + ) + ).toBe(true) + expect( + hasMockCondition( + organizationOwner, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.workspaceId + ) + ).toBe(true) + expect( + hasMockCondition( + organizationOwner, + (node) => + node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.organizationId + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt + ) + ).toBe(true) + expect(mockDispatch).toHaveBeenCalledExactlyOnceWith('org-source', expect.anything()) + }) + + it('contains a dispatch failure to the connector that caused it', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + mockDispatch.mockRejectedValueOnce(new Error('queue unreachable')) + + await expect(run()).resolves.toMatchObject({ dispatched: 1, failed: 1 }) + }) + + it('does not enqueue a connector another scheduler claimed or paused', async () => { + mockConnectorRows.mockResolvedValue([connector()]) + mockClaim.mockResolvedValueOnce([]) + await expect(run()).resolves.toMatchObject({ considered: 1, dispatched: 0, failed: 0 }) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('refuses an unauthenticated tick', async () => { + mockVerifyCronAuth.mockReturnValue(new Response('nope', { status: 401 })) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + expect(mockConnectorRows).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts new file mode 100644 index 00000000000..9867a9823cd --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts @@ -0,0 +1,89 @@ +import { db } from '@sim/db' +import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EXTERNAL_GROUP_SYNC_INTERVAL_MS } from '@/lib/knowledge/access/external-groups' +import { MIRRORING_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' +import { dispatchDirectorySync } from '@/lib/knowledge/connectors/directory-queue' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ConnectorDirectorySyncSchedulerAPI') + +/** Connectors offered per tick, and how many dispatches are in flight at once. */ +const MAX_DIRECTORIES_PER_TICK = 200 +const DISPATCH_CONCURRENCY = 8 + +/** Offers the oldest due directories first; successful claims advance across bounded ticks. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickAt = new Date() + logger.info('Connector directory sync scheduler triggered') + + const authError = verifyCronAuth(request, 'Connector directory sync scheduler') + if (authError) return authError + + const connectors = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + lte(knowledgeConnector.nextDirectorySyncAt, tickAt), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt), + or( + and(isNotNull(knowledgeBase.workspaceId), isNull(knowledgeBase.organizationId)), + and(isNull(knowledgeBase.workspaceId), isNotNull(knowledgeBase.organizationId)) + ) + ) + ) + .orderBy(asc(knowledgeConnector.nextDirectorySyncAt), asc(knowledgeConnector.id)) + .limit(MAX_DIRECTORIES_PER_TICK) + + let dispatched = 0 + let failed = 0 + await mapWithConcurrency(connectors, DISPATCH_CONCURRENCY, async ({ id: connectorId }) => { + try { + const claimed = await db + .update(knowledgeConnector) + .set({ + nextDirectorySyncAt: new Date(tickAt.getTime() + EXTERNAL_GROUP_SYNC_INTERVAL_MS), + }) + .where( + and( + eq(knowledgeConnector.id, connectorId), + lte(knowledgeConnector.nextDirectorySyncAt, tickAt), + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + if (!claimed.length) return + await dispatchDirectorySync(connectorId, { requestId, tickAt }) + dispatched += 1 + } catch (error) { + failed += 1 + logger.error('Failed to dispatch a directory refresh', { + connectorId, + error: getErrorMessage(error), + }) + } + }) + + const summary = { considered: connectors.length, dispatched, failed } + logger.info('Connector directory sync scheduler finished', summary) + return Response.json({ success: true, ...summary }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts new file mode 100644 index 00000000000..c045b61d8c9 --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts @@ -0,0 +1,132 @@ +/** @vitest-environment node */ +import { + createMockRequest, + dbChainMockFns, + hasMockCondition, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + dispatch: vi.fn(), + workspaceBilling: vi.fn(), + organizationBilling: vi.fn(), + sweep: vi.fn(), +})) +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.auth })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveSystemBillingAttribution: mocks.workspaceBilling, + resolveSystemOrganizationBillingAttribution: mocks.organizationBilling, +})) +vi.mock('@/lib/knowledge/connectors/member-queue', () => ({ + dispatchMemberSync: mocks.dispatch, + QUEUEABLE_MEMBER_SYNC_STATUSES: ['idle', 'error'], +})) +vi.mock('@/lib/knowledge/connectors/member-observations', () => ({ + sweepStaleMemberObservations: mocks.sweep, +})) + +import { GET } from '@/app/api/knowledge/connectors/member-sync/route' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.auth.mockReturnValue(null) + mocks.dispatch.mockResolvedValue(undefined) + mocks.sweep.mockResolvedValue({ members: 0 }) + mocks.workspaceBilling.mockResolvedValue({ workspaceId: 'workspace-a' }) + mocks.organizationBilling.mockResolvedValue({ workspaceId: null, organizationId: 'org-a' }) +}) + +describe('member sync scheduler owner routing', () => { + it('does not read or dispatch without cron authentication', async () => { + mocks.auth.mockReturnValue(new Response('Unauthorized', { status: 401 })) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(401) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) + + it('projects org ownership and dispatches with its actual system payer', async () => { + const nextMemberSyncAt = new Date('2026-09-01T00:00:00Z') + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-a', nextMemberSyncAt }, + ]) + await GET(createMockRequest('GET')) + expect(dbChainMockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: schemaMock.knowledgeBase.workspaceId, + organizationId: schemaMock.knowledgeBase.organizationId, + }) + ) + expect(mocks.organizationBilling).toHaveBeenCalledExactlyOnceWith('org-a') + expect(mocks.workspaceBilling).not.toHaveBeenCalled() + expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith('org-source', { + billingAttribution: { workspaceId: null, organizationId: 'org-a' }, + expectedNextMemberSyncAt: nextMemberSyncAt, + requestId: expect.any(String), + requireRunnable: true, + }) + const where = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + where, + (node) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.accessMode && + node.right === 'members' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt + ) + ).toBe(true) + }) + + it('preserves workspace dispatch and refuses absent or ambiguous ownership', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'missing', workspaceId: null, organizationId: null }, + { id: 'ambiguous', workspaceId: 'workspace-a', organizationId: 'org-a' }, + { id: 'workspace-source', workspaceId: 'workspace-a', organizationId: null }, + ]) + await GET(createMockRequest('GET')) + expect(mocks.organizationBilling).not.toHaveBeenCalled() + expect(mocks.workspaceBilling).toHaveBeenCalledExactlyOnceWith('workspace-a') + expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith( + 'workspace-source', + expect.objectContaining({ + billingAttribution: { workspaceId: 'workspace-a' }, + requireRunnable: true, + }) + ) + }) + + it('does not enqueue an org source when its payer cannot be resolved', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-a' }, + ]) + mocks.organizationBilling.mockRejectedValue(new Error('Organization payer unavailable')) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(200) + expect(mocks.dispatch).not.toHaveBeenCalled() + expect(mocks.workspaceBilling).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts index 076344f81dd..10088e0cb14 100644 --- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts @@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + resolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,6 +24,7 @@ import { MAX_CONSECUTIVE_FAILURES, MEMBER_SYNC_STALE_LOCK_TTL_MS, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -162,13 +167,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: knowledgeConnector.id, nextMemberSyncAt: knowledgeConnector.nextMemberSyncAt, workspaceId: knowledgeBase.workspaceId, + organizationId: knowledgeBase.organizationId, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.status, ['active', 'error']), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), lte(knowledgeConnector.nextMemberSyncAt, now), isNull(knowledgeConnector.archivedAt), @@ -191,10 +197,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => { try { - if (!connector.workspaceId) { - throw new Error(`Connector ${connector.id} is missing workspace billing context`) - } - const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId) + const scope = resourceScopeFromOwner(connector) + const billingAttribution = + scope.kind === 'organization' + ? await resolveSystemOrganizationBillingAttribution(scope.organizationId) + : await resolveSystemBillingAttribution(scope.workspaceId) await dispatchMemberSync(connector.id, { billingAttribution, expectedNextMemberSyncAt: connector.nextMemberSyncAt ?? undefined, diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index 2c8a6c2982b..1dd99647867 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -27,18 +27,25 @@ import { MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' -const { mockVerifyCronAuth, mockDispatchSync, mockResolveSystemBillingAttribution } = vi.hoisted( - () => ({ - mockVerifyCronAuth: vi.fn().mockReturnValue(null), - mockDispatchSync: vi.fn().mockResolvedValue(undefined), - mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }), - }) -) +const { + mockVerifyCronAuth, + mockDispatchSync, + mockResolveSystemBillingAttribution, + mockResolveSystemOrganizationBillingAttribution, +} = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockDispatchSync: vi.fn().mockResolvedValue(undefined), + mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }), + mockResolveSystemOrganizationBillingAttribution: vi + .fn() + .mockResolvedValue({ workspaceId: null, organizationId: 'org-1' }), +})) vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution: mockResolveSystemOrganizationBillingAttribution, })) import { GET } from '@/app/api/knowledge/connectors/sync/route' @@ -131,6 +138,10 @@ beforeEach(() => { mockVerifyCronAuth.mockReturnValue(null) mockDispatchSync.mockResolvedValue(undefined) mockResolveSystemBillingAttribution.mockResolvedValue({ workspaceId: 'ws-1' }) + mockResolveSystemOrganizationBillingAttribution.mockResolvedValue({ + workspaceId: null, + organizationId: 'org-1', + }) vi.useFakeTimers() vi.setSystemTime(NOW) }) @@ -550,7 +561,7 @@ describe('connector sync scheduler authentication and dispatch', () => { ) }) - it('skips a connector missing workspace billing context without failing the tick', async () => { + it('skips a connector missing resource billing context without failing the tick', async () => { queueTableRows(schemaMock.knowledgeConnector, [ { id: 'due-1', workspaceId: null }, { id: 'due-2', workspaceId: 'ws-2' }, @@ -563,6 +574,42 @@ describe('connector sync scheduler authentication and dispatch', () => { expect(mockDispatchSync).toHaveBeenCalledWith('due-2', expect.anything()) }) + it('dispatches org sources with their canonical organization payer', async () => { + const nextSyncAt = new Date('2026-09-01T00:00:00Z') + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-1', nextSyncAt }, + ]) + await GET(cronRequest()) + expect(dbChainMockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: schemaMock.knowledgeBase.workspaceId, + organizationId: schemaMock.knowledgeBase.organizationId, + }) + ) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1') + expect(mockDispatchSync).toHaveBeenCalledExactlyOnceWith('org-source', { + billingAttribution: { workspaceId: null, organizationId: 'org-1' }, + expectedNextSyncAt: nextSyncAt, + requestId: expect.any(String), + requireRunnable: true, + }) + }) + + it('does not infer a payer for ambiguous ownership or failed org billing', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'ambiguous', workspaceId: 'ws-1', organizationId: 'org-1' }, + { id: 'org-source', workspaceId: null, organizationId: 'org-1' }, + ]) + mockResolveSystemOrganizationBillingAttribution.mockRejectedValue( + new Error('Owner unavailable') + ) + await GET(cronRequest()) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1') + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + it('reports a tick with nothing due', async () => { const response = await GET(cronRequest()) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 7d3d5afcbd6..e28e8561358 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -4,10 +4,15 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + resolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' import { dispatchSync } from '@/lib/knowledge/connectors/queue' import { CONNECTOR_AUTO_DISABLED_ERROR, @@ -16,6 +21,7 @@ import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -298,13 +304,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: knowledgeConnector.id, nextSyncAt: knowledgeConnector.nextSyncAt, workspaceId: knowledgeBase.workspaceId, + organizationId: knowledgeBase.organizationId, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( - inArray(knowledgeConnector.status, ['active', 'error']), - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), lte(knowledgeConnector.nextSyncAt, now), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt), @@ -326,10 +333,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => { try { - if (!connector.workspaceId) { - throw new Error(`Connector ${connector.id} is missing workspace billing context`) - } - const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId) + const scope = resourceScopeFromOwner(connector) + const billingAttribution = + scope.kind === 'organization' + ? await resolveSystemOrganizationBillingAttribution(scope.organizationId) + : await resolveSystemBillingAttribution(scope.workspaceId) await dispatchSync(connector.id, { billingAttribution, expectedNextSyncAt: connector.nextSyncAt ?? undefined, diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts new file mode 100644 index 00000000000..e02b67e4203 --- /dev/null +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock('@/lib/knowledge/application/workspace-search', () => ({ + searchScopedKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search }, +})) + +import { POST } from '@/app/api/knowledge/search/route' + +describe('workspace search route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' }, + session: { id: 'session-1' }, + }) + mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] }) + }) + + it('passes the authenticated request cancellation signal through the existing operation', async () => { + const controller = new AbortController() + const request = new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: 'workspace-1', + filters: { source: 'slack', documentIds: ['doc-1'] }, + query: 'Orion', + }), + signal: controller.signal, + }) + const response = await POST(request) + expect(response.status).toBe(200) + const call = mocks.search.mock.calls[0][0] + expect(call.principal).toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + expect(call.input).not.toHaveProperty('knowledgeBaseIds') + expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] }) + expect(call.input.signal).toBe(request.signal) + controller.abort() + expect(call.input.signal.aborted).toBe(true) + await expect(response.json()).resolves.toEqual({ + success: true, + data: { query: 'Orion', results: [] }, + }) + }) + + it('authenticates before parsing and never enters search for an anonymous request', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const response = await POST( + new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + body: '{', + headers: { 'content-type': 'application/json' }, + }) + ) + expect(response.status).toBe(401) + expect(mocks.search).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 96d78f3d1d7..cc9ae79fb8b 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -6,7 +6,7 @@ import { } from '@/lib/api/server/routes' import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { searchKnowledge } from '@/lib/knowledge/application/search' +import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search' import { sourceAuthor } from '@/lib/knowledge/search/author' export const POST = defineInternalJsonRoute({ @@ -14,16 +14,20 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: knowledgeOperations.search, rateLimit: internalRateLimits.none({ - reason: 'A person typing queries; the embedding call is metered against their workspace', + reason: + 'A person typing queries; the embedding call is metered against the canonical search owner', }), errorPolicy: internalKnowledgeErrorPolicies.search, - mapInput: ({ body }) => ({ + mapInput: ({ body }, { request }) => ({ workspaceId: body.workspaceId, - knowledgeBaseIds: body.knowledgeBaseIds, + organizationId: body.organizationId, + filters: body.filters, query: body.query, topK: body.topK, + surface: 'dashboard' as const, + signal: request.signal, }), - useCase: searchKnowledge, + useCase: searchScopedKnowledge, present: ({ results, knowledgeBases }, { input }) => { const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name])) return { diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index fd09d744f64..e5184169e8f 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -18,6 +18,14 @@ import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + /** * Spy on the real documents/utils namespace instead of vi.mock: the shared * `@/lib/knowledge/embeddings` module may be cached bound to the real module, @@ -196,6 +204,27 @@ describe('Knowledge Search Utils', () => { }) describe('handleTagAndVectorSearch', () => { + it('returns only bounded ranked rows without first materializing every matching tag ID', async () => { + resetDbChainMock() + queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) + + const results = await handleTagAndVectorSearch({ + knowledgeBaseIds: ['kb-1', 'kb-2'], + access: WORKSPACE_ACCESS_SCOPE, + topK: 2, + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' }, + ], + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, + distanceThreshold: 0.8, + }) + + expect(results.map((row) => row.id)).toEqual(['first', 'second']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + }) + it('should throw error when no filters provided', async () => { const params = { knowledgeBaseIds: ['kb-123'], diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index c13ebd49c16..9f2319ee6d3 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -14,11 +14,7 @@ export const POST = defineInternalJsonRoute({ operation: knowledgeOperations.simSearchConnect, rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ body }) => ({ - workspaceId: body.workspaceId, - connectorType: body.connectorType, - sourceConfig: body.sourceConfig, - }), + mapInput: ({ body }) => body, useCase: connectSimSearchConnector, present: (result) => ({ success: true as const, data: result }), }) diff --git a/apps/sim/app/api/knowledge/sim-search/index/route.ts b/apps/sim/app/api/knowledge/sim-search/index/route.ts new file mode 100644 index 00000000000..3b891520185 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/index/route.ts @@ -0,0 +1,20 @@ +import { readSearchIndexContract } from '@/lib/api/contracts/knowledge/connectors' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readSearchIndex } from '@/lib/knowledge/application/sim-search' + +export const GET = defineInternalJsonRoute({ + contract: readSearchIndexContract, + auth: internalSessionAuth, + operation: knowledgeOperations.readSearchIndex, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.index' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: readSearchIndex, + present: (data) => ({ success: true, data: { knowledgeBaseId: data.knowledgeBaseId } }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/integrations/route.ts b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts new file mode 100644 index 00000000000..439b5b6b082 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts @@ -0,0 +1,40 @@ +import { + listSearchIntegrationsContract, + updateSearchIntegrationContract, +} from '@/lib/api/contracts/knowledge/search-integrations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + approveSearchIntegration, + listSearchIntegrations, +} from '@/lib/knowledge/application/search-integrations' + +export const GET = defineInternalJsonRoute({ + contract: listSearchIntegrationsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listSearchIntegrations, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.list' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: listSearchIntegrations, + present: (data) => ({ success: true as const, data }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateSearchIntegrationContract, + auth: internalSessionAuth, + operation: knowledgeOperations.approveSearchIntegration, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.approve' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ body }) => body, + useCase: approveSearchIntegration, + present: ({ connectorType, approved }) => ({ + success: true as const, + data: { connectorType, approved }, + }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/prepare/route.ts b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts new file mode 100644 index 00000000000..ffad32fe0c5 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts @@ -0,0 +1,20 @@ +import { prepareSearchSourceContract } from '@/lib/api/contracts/knowledge/connectors' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { prepareSearchSource } from '@/lib/knowledge/application/sim-search' + +export const POST = defineInternalJsonRoute({ + contract: prepareSearchSourceContract, + auth: internalSessionAuth, + operation: knowledgeOperations.prepareSearchSource, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.sources.prepare' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ body }) => body, + useCase: prepareSearchSource, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts new file mode 100644 index 00000000000..a90e2cc09d0 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts @@ -0,0 +1,155 @@ +/** @vitest-environment node */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn(), connect: vi.fn() })) +vi.mock('@/lib/knowledge/application/sim-search', () => ({ + connectSimSearchConnector: { + operation: { id: 'knowledge.simSearch.connect' }, + execute: mocks.connect, + }, +})) +vi.mock('@/lib/knowledge/application/search-sources', () => ({ + listSearchSources: { operation: { id: 'knowledge.search.sources.list' }, execute: mocks.execute }, +})) +vi.mock('@/lib/knowledge/application/search', () => ({ + KnowledgeSearchProvenanceUnavailableError: class extends Error {}, +})) +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + KnowledgeDocumentUnsupportedMediaTypeError: class extends Error {}, +})) + +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' +import { POST as connectSource } from '@/app/api/knowledge/sim-search/connect/route' +import { GET } from '@/app/api/knowledge/sim-search/sources/route' + +const WORKSPACE_ID = '7d28e5e2-fb03-4118-9c52-4ab77ccff369' +const source = { + knowledgeBaseId: 'search-index', + connectorId: 'source', + connectorType: 'google_drive', + sourceDescription: 'Handbook', + accessMode: 'admin', + availability: 'available', + enabled: true, + isSyncing: false, + lastSyncAt: null, + hasSyncError: false, + viewerDocumentCount: 0, + viewerEmailVerified: true, + connectionRequired: false, + viewerMembership: null, +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'reader' }, + session: { id: 'session' }, + }) + mocks.execute.mockResolvedValue({ sources: [source] }) +}) + +describe('GET Search sources', () => { + it('preserves the explicit organization in source listing and member enrollment', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?organizationId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: { organizationId: WORKSPACE_ID }, + }) + ) + + mocks.connect.mockResolvedValue({ + knowledgeBaseId: 'index', + connectorId: 'source', + url: 'http://localhost/credential-groups/enroll/token', + }) + const body = { organizationId: WORKSPACE_ID, connectorType: 'gmail' } + const connected = await connectSource(createMockRequest('POST', body)) + expect(connected.status).toBe(200) + expect(mocks.connect).toHaveBeenCalledWith(expect.objectContaining({ input: body })) + }) + + it('authenticates before parsing the workspace query', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('refuses a missing workspace before entering the use case', async () => { + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('passes the authenticated subject into the registered operation and projects only the contract fields', async () => { + mocks.execute.mockResolvedValue({ + sources: [ + { + ...source, + credentialId: 'secret', + sourceConfig: { token: 'secret' }, + lastSyncError: 'private failure', + }, + ], + }) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body).toMatchObject({ success: true, data: [source] }) + expect(body.data[0]).toEqual(source) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'reader', sessionId: 'session' }, + input: { workspaceId: WORKSPACE_ID }, + }) + ) + }) + + it('preserves authorization rejection and conceals source data', async () => { + mocks.execute.mockRejectedValue(new NoWorkspaceAccessError()) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(404) + expect(await response.json()).not.toHaveProperty('data') + }) + + it('does not publish infrastructure errors or mistake failures for an empty list', async () => { + mocks.execute.mockRejectedValue(new Error('database private connection string')) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error).toBe('Internal server error') + expect(body).not.toHaveProperty('data') + }) +}) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.ts new file mode 100644 index 00000000000..fe64846acdd --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.ts @@ -0,0 +1,23 @@ +import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { listSearchSources } from '@/lib/knowledge/application/search-sources' + +export const GET = defineInternalJsonRoute({ + contract: listSearchSourcesContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listSearchSources, + rateLimit: internalRateLimits.none({ + reason: 'Workspace source summaries for the Search page and indexing status polling', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: listSearchSources, + present: ({ sources }) => ({ success: true as const, data: sources }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 2fca7aa4ecc..47a3671dca8 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -19,6 +19,14 @@ import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import * as workspacesUtilsModule from '@/lib/workspaces/utils' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + const envSnapshot = { ...env } afterAll(() => { diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 8dfa1b88a23..d1af1e73161 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -10,6 +10,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const { mockAuthenticateEnrollment, @@ -30,7 +31,7 @@ vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools: mockDiscoverServerTools }, })) vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ - authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment, + credentialGroupOAuthAttemptPrincipal: mockAuthenticateEnrollment, })) vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth }, @@ -68,6 +69,8 @@ describe('MCP OAuth callback route', () => { mockDiscoverServerTools.mockResolvedValue(undefined) mockConsumeManagedAttempt.mockResolvedValue({ state: 'mcp_cg_state-1', + workspaceId: 'workspace-1', + email: 'invitee@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', mcpServerId: 'server-1', @@ -75,7 +78,7 @@ describe('MCP OAuth callback route', () => { invitationToken: 'invitation-token', createdAt: Date.now(), }) - mockAuthenticateEnrollment.mockResolvedValue({ + mockAuthenticateEnrollment.mockReturnValue({ kind: 'credential_group_enrollment', workspaceId: 'workspace-1', credentialGroupId: 'group-1', @@ -159,7 +162,13 @@ describe('MCP OAuth callback route', () => { expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback') expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1') - expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token') + expect(mockAuthenticateEnrollment).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + email: 'invitee@example.com', + invitationToken: 'invitation-token', + }) + ) expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ @@ -187,4 +196,13 @@ describe('MCP OAuth callback route', () => { expect(mockConsumeManagedAttempt).not.toHaveBeenCalled() expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() }) + it('reports a state protocol change without exchanging a code or loading an enrollment', async () => { + mockConsumeManagedAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET( + new NextRequest('http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_old&code=code') + ) + expect(await response.text()).toContain('Reopen your invitation and connect again') + expect(mockAuthenticateEnrollment).not.toHaveBeenCalled() + expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index f76addcbb80..dd31f4a24b2 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -9,12 +9,13 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment' import { consumeCredentialGroupMcpOAuthAttempt, isCredentialGroupMcpOAuthState, } from '@/lib/credential-groups/mcp-oauth-state' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, @@ -84,7 +85,15 @@ async function completeManagedMcpCallback(params: { code?: string error?: string }): Promise { - const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + let attempt + try { + attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return htmlClose(error.message, false, 'invalid_state', undefined, params.state) + } + throw error + } if (!attempt) { return htmlClose('Invalid or expired authorization state.', false, 'invalid_state') } @@ -97,12 +106,7 @@ async function completeManagedMcpCallback(params: { }) } try { - const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) - if (!principal) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'unavailable', - }) - } + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) const result = await completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: params.code }, @@ -199,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) .limit(1) ) - if (!server || !server.url) { + if (!server || !server.url || !server.workspaceId) { return respond('Server no longer exists.', false, 'server_gone', serverId) } if (server.workspaceId !== row.workspaceId) { @@ -211,6 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } const serverUrl = server.url + const serverWorkspaceId = server.workspaceId try { assertSafeOauthServerUrl(serverUrl) } catch { @@ -262,7 +267,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // forceRefresh: skip any stale cache from before re-auth. await timedStep('discoverServerTools', 60_000, () => - mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, 'force') + mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force') ) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) diff --git a/apps/sim/app/api/mcp/search/[workspaceId]/route.ts b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts new file mode 100644 index 00000000000..c6b7d737e3e --- /dev/null +++ b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts @@ -0,0 +1,9 @@ +import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createKnowledgeMcpHandlers('workspace') + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts new file mode 100644 index 00000000000..0f3758aaaf6 --- /dev/null +++ b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts @@ -0,0 +1,9 @@ +import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createKnowledgeMcpHandlers('organization') + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 5a95ce3523d..be62590983a 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -330,6 +330,7 @@ describe('MCP Serve Route', () => { workflowId: 'wf-1', userId: 'user-1', triggerType: 'mcp', + principal: PERSONAL_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: true, deploymentVersionId: 'deployment-1', includeFileBase64: false, @@ -578,6 +579,7 @@ describe('MCP Serve Route', () => { expect(mockExecuteWorkflowService).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', + principal: WORKSPACE_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: false, }) ) diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 6351971fd8c..deff41844d0 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -5,11 +5,11 @@ import { } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' -export { maxDuration } +export const maxDuration = 3600 // Unified chat route surface. export const GET = withRouteHandler((request: NextRequest) => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 4f407ffea71..740456f750b 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/copilot/chat/fork-chat-files' import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' import { rewriteMessageFileRefs, rewriteResourceFileRefs, @@ -32,6 +33,7 @@ import { removeChatResources } from '@/lib/copilot/resources/persistence' import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -58,7 +60,7 @@ const logger = createLogger('ForkChatAPI') export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -76,6 +78,7 @@ export const POST = withRouteHandler( userId: copilotChats.userId, type: copilotChats.type, workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, title: copilotChats.title, model: copilotChats.model, resources: copilotChats.resources, @@ -90,6 +93,13 @@ export const POST = withRouteHandler( return createNotFoundResponse('Chat not found') } + if (parent.organizationId) { + if (!principal) return createUnauthorizedResponse() + await authorizeOrganizationChat.execute({ + principal, + input: { organizationId: parent.organizationId }, + }) + } if (parent.workspaceId) { await assertActiveWorkspaceAccess(parent.workspaceId, userId) } @@ -137,6 +147,7 @@ export const POST = withRouteHandler( id: newId, userId, workspaceId: parent.workspaceId, + organizationId: parent.organizationId, type: parent.type, title, model: parent.model, @@ -277,6 +288,9 @@ export const POST = withRouteHandler( ...(failed > 0 ? { failedFileCopies: failed } : {}), }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts index b59bcceac05..b98f6824e1f 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' import { chatPubSub } from '@/lib/copilot/chat-status' import { authenticateCopilotRequestSessionOnly, @@ -12,6 +13,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -31,7 +33,7 @@ const logger = createLogger('RestoreMothershipChatAPI') export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -41,7 +43,10 @@ export const POST = withRouteHandler( const { chatId } = parsed.data.params const [chat] = await db - .select({ workspaceId: copilotChats.workspaceId }) + .select({ + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + }) .from(copilotChats) .where( and( @@ -56,6 +61,13 @@ export const POST = withRouteHandler( if (!chat) { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } + if (chat.organizationId) { + if (!principal) return createUnauthorizedResponse() + await authorizeOrganizationChat.execute({ + principal, + input: { organizationId: chat.organizationId }, + }) + } if (chat.workspaceId) { await assertActiveWorkspaceAccess(chat.workspaceId, userId) } @@ -76,6 +88,7 @@ export const POST = withRouteHandler( ) .returning({ workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, }) if (!restoredChat) { @@ -100,6 +113,9 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 9fa07c1e067..6d321e169f3 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -36,7 +36,7 @@ const logger = createLogger('MothershipChatAPI') export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -45,7 +45,7 @@ export const GET = withRouteHandler( if (!paramsResult.success) return paramsResult.response const { chatId } = paramsResult.data.params - const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal }) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } @@ -154,7 +154,7 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -163,6 +163,10 @@ export const PATCH = withRouteHandler( if (!parsed.success) return parsed.response const { chatId } = parsed.data.params const { title, isUnread, pinned } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) + if (!chat || chat.type !== 'mothership') { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } const updates: Record = {} @@ -250,7 +254,7 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -259,7 +263,7 @@ export const DELETE = withRouteHandler( if (!parsed.success) return parsed.response const { chatId } = parsed.data.params - const chat = await getAccessibleCopilotChatAuth(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: true }) } diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index 1ff8c0a60fd..5a67be8f8a6 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -5,13 +5,17 @@ import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockParseRequest } = vi.hoisted(() => ({ +const { mockParseRequest, mockGetAccessibleChat } = vi.hoisted(() => ({ mockParseRequest: vi.fn(), + mockGetAccessibleChat: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} })) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) import { POST } from '@/app/api/mothership/chats/read/route' @@ -29,7 +33,9 @@ describe('POST /api/mothership/chats/read', () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, }) + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1', userId: 'user-1' }) mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } }) }) @@ -40,6 +46,9 @@ describe('POST /api/mothership/chats/read', () => { it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => { const res = await POST(createRequest()) expect(res.status).toBe(200) + expect(mockGetAccessibleChat).toHaveBeenCalledWith('chat-1', 'user-1', { + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) const whereArg = dbChainMockFns.where.mock.calls[0][0] as { @@ -58,6 +67,13 @@ describe('POST /api/mothership/chats/read', () => { ) }) + it('does not update a chat the caller can no longer access', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const res = await POST(createRequest()) + expect(res.status).toBe(200) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('does not touch the database when unauthenticated', async () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: null, diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index beffb8c821d..1c2cc149f72 100644 --- a/apps/sim/app/api/mothership/chats/read/route.ts +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -16,7 +17,7 @@ const logger = createLogger('MarkTaskReadAPI') export const POST = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -24,6 +25,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(markMothershipChatReadContract, request, {}) if (!parsed.success) return parsed.response const { chatId } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) + if (!chat) return NextResponse.json({ success: true }) await db .update(copilotChats) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index acdbfddb2a9..bc1828f2579 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -8,6 +8,10 @@ import { } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { + createOrganizationChat, + listOrganizationChats, +} from '@/lib/copilot/chat/organization-chats' import { chatPubSub } from '@/lib/copilot/chat-status' import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { @@ -16,6 +20,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -31,21 +36,34 @@ const logger = createLogger('MothershipChatsAPI') */ export const GET = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } const queryResult = await parseRequest(listMothershipChatsContract, request, {}) if (!queryResult.success) return queryResult.response - const { workspaceId, scope } = queryResult.data.query + const { workspaceId, organizationId, scope } = queryResult.data.query + + if (organizationId) { + if (!principal) return createUnauthorizedResponse() + const data = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope }, + }) + return NextResponse.json({ success: true, data }) + } + if (!workspaceId) throw new Error('Conversation owner is required') await assertActiveWorkspaceAccess(workspaceId, userId) const data = await listMothershipChats(userId, workspaceId, scope) return NextResponse.json({ success: true, data }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return createForbiddenResponse('Organization access denied') if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } @@ -60,15 +78,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { */ export const POST = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } const validation = await parseRequest(createMothershipChatContract, request, {}) if (!validation.success) return validation.response - const { workspaceId } = validation.data.body + const { workspaceId, organizationId } = validation.data.body + + if (organizationId) { + if (!principal) return createUnauthorizedResponse() + const chat = await createOrganizationChat.execute({ principal, input: { organizationId } }) + return NextResponse.json({ success: true, id: chat.id }) + } + if (!workspaceId) throw new Error('Conversation owner is required') await assertActiveWorkspaceAccess(workspaceId, userId) const now = new Date() @@ -98,6 +123,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: chat.id }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return createForbiddenResponse('Organization access denied') if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 4c2c1093142..2e1cd9fd38c 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -237,7 +237,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workspaceAccess, secretMountPolicy, }), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildIntegrationToolSchemas(userId, undefined, workspaceId), mothershipToolsPromise, computeWorkspaceEntitlements(workspaceId, userId), processContextsServer( diff --git a/apps/sim/app/api/organization-credentials/[id]/route.ts b/apps/sim/app/api/organization-credentials/[id]/route.ts new file mode 100644 index 00000000000..c1b7f569d07 --- /dev/null +++ b/apps/sim/app/api/organization-credentials/[id]/route.ts @@ -0,0 +1,23 @@ +import { updateOrganizationCredentialContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + organizationCredentialOperations, + updateOrganizationCredential, +} from '@/lib/credentials/application/organization-credentials' +import { toOrganizationCredential } from '@/lib/credentials/application/presentation' + +export const PATCH = defineInternalJsonRoute({ + contract: updateOrganizationCredentialContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.update, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential update behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body, params }) => ({ ...body, credentialId: params.id }), + useCase: updateOrganizationCredential, + present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }), +}) diff --git a/apps/sim/app/api/organization-credentials/draft/route.ts b/apps/sim/app/api/organization-credentials/draft/route.ts new file mode 100644 index 00000000000..467335d73a6 --- /dev/null +++ b/apps/sim/app/api/organization-credentials/draft/route.ts @@ -0,0 +1,22 @@ +import { createOrganizationCredentialDraftContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + organizationCredentialOperations, + saveOrganizationCredentialDraft, +} from '@/lib/credentials/application/organization-credentials' + +export const POST = defineInternalJsonRoute({ + contract: createOrganizationCredentialDraftContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth draft behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body }) => body, + useCase: saveOrganizationCredentialDraft, + present: (result) => result, +}) diff --git a/apps/sim/app/api/organization-credentials/oauth/route.ts b/apps/sim/app/api/organization-credentials/oauth/route.ts new file mode 100644 index 00000000000..220f8767f4c --- /dev/null +++ b/apps/sim/app/api/organization-credentials/oauth/route.ts @@ -0,0 +1,30 @@ +import { listOrganizationOAuthCredentialsContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + listOrganizationCredentials, + organizationCredentialOperations, +} from '@/lib/credentials/application/organization-credentials' +import type { OAuthProvider } from '@/lib/oauth/types' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationOAuthCredentialsContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth credential listing behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => ({ ...query, type: 'oauth' as const }), + useCase: listOrganizationCredentials, + present: ({ credentials }) => ({ + credentials: credentials.map((row) => ({ + id: row.id, + name: row.displayName, + provider: row.providerId as OAuthProvider, + type: 'oauth' as const, + })), + }), +}) diff --git a/apps/sim/app/api/organization-credentials/route.ts b/apps/sim/app/api/organization-credentials/route.ts new file mode 100644 index 00000000000..58cab694ecc --- /dev/null +++ b/apps/sim/app/api/organization-credentials/route.ts @@ -0,0 +1,38 @@ +import { + createOrganizationCredentialContract, + listOrganizationCredentialsContract, +} from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + createOrganizationCredential, + listOrganizationCredentials, + organizationCredentialOperations, +} from '@/lib/credentials/application/organization-credentials' +import { toOrganizationCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationCredentialsContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential listing behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => query, + useCase: listOrganizationCredentials, + present: ({ credentials }) => ({ credentials: credentials.map(toOrganizationCredential) }), +}) +export const POST = defineInternalJsonRoute({ + contract: createOrganizationCredentialContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential creation behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body }) => body, + useCase: createOrganizationCredential, + present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts new file mode 100644 index 00000000000..acda6a7d872 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts @@ -0,0 +1,25 @@ +import { updateOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountOperations, + updateOrganizationAccountsSettings, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const PATCH = defineInternalJsonRoute({ + contract: updateOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.update, + rateLimit: internalRateLimits.none({ reason: 'Administrator account configuration mutation' }), + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update connected accounts'), + mapInput: ({ params, body }) => ({ + organizationId: params.id, + credentialGroupId: params.groupId, + update: body, + }), + useCase: updateOrganizationAccountsSettings, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts new file mode 100644 index 00000000000..146a97011f1 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts @@ -0,0 +1,77 @@ +/** @vitest-environment node */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/credential-groups/application/slack-managed-users', () => ({ + startSlackCredentialGroupConfiguration: { + get operation() { + return credentialGroupOperations.startSlackConfiguration + }, + execute: mocks.execute, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route' + +const body = { + appId: 'A123', + teamId: 'T123', + clientId: 'fixture-client-id', + clientSecret: 'fixture-client-secret', +} +const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) } +function request(input: unknown = body) { + return createMockRequest( + 'POST', + input, + undefined, + 'http://localhost:3000/api/organizations/org-a/connected-accounts/group-a/slack-managed-users' + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } }) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://slack.com/oauth/v2/authorize', + state: 'opaque-state', + }) +}) + +describe('organization Slack setup route', () => { + it('authenticates before parsing setup secrets', async () => { + mocks.session.mockResolvedValue(null) + const response = await POST(request({}), context) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('maps the canonical route id to organization ownership without a workspace alias', async () => { + const response = await POST(request(), context) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', sessionId: 'session', userId: 'actor' }, + input: { ...body, organizationId: 'org-a', credentialGroupId: 'group-a' }, + }) + ) + }) + + it('rejects a client-supplied workspace owner', async () => { + const response = await POST(request({ ...body, workspaceId: 'workspace-a' }), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('preserves refusal when current organization authority is insufficient', async () => { + mocks.execute.mockRejectedValue( + new OrchestrationError('forbidden', 'Organization admin required') + ) + const response = await POST(request(), context) + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts new file mode 100644 index 00000000000..32f1f8dcba4 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts @@ -0,0 +1,34 @@ +import { startOrganizationSlackConfigurationContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: startOrganizationSlackConfigurationContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.startSlackConfiguration, + rateLimit: internalRateLimits.none({ + reason: 'Slack applies provider authorization limits and setup requires an organization admin', + }), + errorPolicy: extendInternalErrorPolicy( + createCredentialGroupInternalErrorPolicy('Failed to configure Slack'), + (error) => + error instanceof SlackManagedUsersError + ? internalErrorResponse(400, { error: error.message }) + : null + ), + mapInput: ({ params, body }) => ({ + ...body, + organizationId: params.id, + credentialGroupId: params.groupId, + }), + useCase: startSlackCredentialGroupConfiguration, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts new file mode 100644 index 00000000000..1b20890214e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts @@ -0,0 +1,26 @@ +import { startOrganizationAccountConnectionContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountOperations, + startOrganizationAccountConnection, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: startOrganizationAccountConnectionContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.connect, + rateLimit: internalRateLimits.none({ + reason: 'Bounded current-member self-enrollment; no email delivery', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to connect account'), + mapInput: ({ params, body }) => ({ + organizationId: params.id, + optionId: body.optionId, + }), + useCase: startOrganizationAccountConnection, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts new file mode 100644 index 00000000000..ef9d7e49571 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts @@ -0,0 +1,40 @@ +import { + configureOrganizationMcpContract, + getOrganizationDatabricksSetupContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + configureOrganizationMcp, + configureOrganizationMcpOperation, +} from '@/lib/credential-groups/application/configure-organization-mcp' +import { + getOrganizationDatabricksSetup, + getOrganizationDatabricksSetupOperation, +} from '@/lib/credential-groups/application/organization-databricks-setup' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationDatabricksSetupContract, + auth: internalSessionAuth, + operation: getOrganizationDatabricksSetupOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationDatabricksSetup, + present: ({ server }) => ({ server }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: configureOrganizationMcpContract, + auth: internalSessionAuth, + operation: configureOrganizationMcpOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: configureOrganizationMcp, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts new file mode 100644 index 00000000000..d79a286c00f --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts @@ -0,0 +1,22 @@ +import { updateOrganizationAccountIndexingContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + updateOrganizationAccountIndexing, + updateOrganizationAccountIndexingOperation, +} from '@/lib/credential-groups/application/organization-account-indexing' + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountIndexingContract, + auth: internalSessionAuth, + operation: updateOrganizationAccountIndexingOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-account-indexing' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountIndexing, + present: ({ enabled, knowledgeBaseIds }) => ({ enabled, knowledgeBaseIds }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts new file mode 100644 index 00000000000..54055821f39 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts @@ -0,0 +1,22 @@ +import { removeOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + removeOrganizationAccountMcpProvider, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: removeOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.removeMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, connectorId: params.connectorId }), + useCase: removeOrganizationAccountMcpProvider, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts new file mode 100644 index 00000000000..101a8e6c3f7 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts @@ -0,0 +1,22 @@ +import { addOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + addOrganizationAccountMcpProvider, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: addOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.addMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: addOrganizationAccountMcpProvider, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..838defa8935 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts @@ -0,0 +1,22 @@ +import { resendOrganizationAccountInvitationContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + resendOrganizationAccountInvitation, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: resendOrganizationAccountInvitationContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.resend, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: resendOrganizationAccountInvitation, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts new file mode 100644 index 00000000000..936ad572ccb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts @@ -0,0 +1,22 @@ +import { revokeOrganizationAccountEnrollmentContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + revokeOrganizationAccountEnrollment, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeOrganizationAccountEnrollmentContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.revoke, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: revokeOrganizationAccountEnrollment, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts new file mode 100644 index 00000000000..61e8cd3fd0e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts @@ -0,0 +1,36 @@ +import { + inviteOrganizationAccountPeopleContract, + listOrganizationAccountPeopleContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + inviteOrganizationAccountPeople, + listOrganizationAccountPeople, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.people, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }), + useCase: listOrganizationAccountPeople, +}) + +export const POST = defineInternalJsonRoute({ + contract: inviteOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.invite, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: inviteOrganizationAccountPeople, + present: ({ results, sentCount, failedCount }) => ({ results, sentCount, failedCount }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts new file mode 100644 index 00000000000..afa8338b32b --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts @@ -0,0 +1,43 @@ +import { + ensureOrganizationAccountsContract, + getOrganizationAccountsContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + ensureOrganizationAccounts, + getOrganizationAccountsSettings, + organizationAccountOperations, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const errorPolicy = createCredentialGroupInternalErrorPolicy( + 'Failed to load connected accounts', + 'Organization not found' +) +export const GET = defineInternalJsonRoute({ + contract: getOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization account metadata read', + }), + errorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationAccountsSettings, +}) +export const POST = defineInternalJsonRoute({ + contract: ensureOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.ensure, + rateLimit: internalRateLimits.none({ + reason: 'Idempotent administrator account-container setup', + }), + errorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: ensureOrganizationAccounts, + present: ({ credentialGroup }) => ({ credentialGroup }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts new file mode 100644 index 00000000000..8ae3315a131 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts @@ -0,0 +1,40 @@ +import { + getOrganizationAccountWorkspaceAccessContract, + updateOrganizationAccountWorkspaceAccessContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getOrganizationAccountWorkspaceAccess, + organizationAccountAccessOperations, + updateOrganizationAccountWorkspaceAccess, +} from '@/lib/credential-groups/application/organization-access' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Bounded administrator workspace access settings read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationAccountWorkspaceAccess, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Administrator policy revision protects bounded workspace access updates', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountWorkspaceAccess, + present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }), +}) diff --git a/apps/sim/app/api/settings/allowed-integrations/route.test.ts b/apps/sim/app/api/settings/allowed-integrations/route.test.ts new file mode 100644 index 00000000000..6028ed121d7 --- /dev/null +++ b/apps/sim/app/api/settings/allowed-integrations/route.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getIntegrationAvailability: vi.fn(), + getOAuthServiceAvailability: vi.fn(), + getAllOAuthServices: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: () => null })) +vi.mock('@/lib/integrations/availability.server', () => ({ + getIntegrationAvailability: mocks.getIntegrationAvailability, + getOAuthServiceAvailability: mocks.getOAuthServiceAvailability, +})) +vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: mocks.getAllOAuthServices })) + +import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common' +import { GET } from '@/app/api/settings/allowed-integrations/route' + +describe('allowed integrations response', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.getIntegrationAvailability.mockReturnValue([ + { type: 'github_v2', state: 'ready', oauthAvailable: false, missingFields: [] }, + ]) + mocks.getAllOAuthServices.mockReturnValue([ + { providerId: 'github-repositories', authType: 'oauth' }, + ]) + mocks.getOAuthServiceAvailability.mockReturnValue([ + { providerId: 'github-repositories', available: false }, + ]) + }) + + it('authenticates before projecting deployment capabilities', async () => { + mocks.getSession.mockResolvedValue(null) + const response = await GET( + createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/settings/allowed-integrations' + ), + {} + ) + expect(response.status).toBe(401) + expect(mocks.getIntegrationAvailability).not.toHaveBeenCalled() + expect(mocks.getOAuthServiceAvailability).not.toHaveBeenCalled() + expect(mocks.getAllOAuthServices).not.toHaveBeenCalled() + }) + + it('returns block and OAuth service readiness as distinct contract fields', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/settings/allowed-integrations' + ), + {} + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(getAllowedIntegrationsContract.response.schema.safeParse(body).success).toBe(true) + expect(body).toEqual({ + allowedIntegrations: null, + integrationAvailability: [{ type: 'github_v2', state: 'ready', oauthAvailable: false }], + oauthServiceAvailability: [{ providerId: 'github-repositories', available: false }], + }) + expect(mocks.getOAuthServiceAvailability).toHaveBeenCalledWith( + mocks.getAllOAuthServices.mock.results[0].value + ) + }) +}) diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts index c5acc2582ca..ba7d83a103b 100644 --- a/apps/sim/app/api/settings/allowed-integrations/route.ts +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -2,7 +2,11 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getIntegrationAvailability } from '@/lib/integrations/availability.server' +import { + getIntegrationAvailability, + getOAuthServiceAvailability, +} from '@/lib/integrations/availability.server' +import { getAllOAuthServices } from '@/lib/oauth/utils' export const GET = withRouteHandler(async () => { const session = await getSession() @@ -15,5 +19,6 @@ export const GET = withRouteHandler(async () => { integrationAvailability: getIntegrationAvailability().map( ({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable }) ), + oauthServiceAvailability: getOAuthServiceAvailability(getAllOAuthServices()), }) }) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts new file mode 100644 index 00000000000..c9d66c778e7 --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts @@ -0,0 +1,18 @@ +import { reconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const POST = defineInternalJsonRoute({ + contract: reconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: reconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: reconnectPersonalOrganizationAccount, +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts new file mode 100644 index 00000000000..56076c3e7ed --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts @@ -0,0 +1,19 @@ +import { disconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { disconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const DELETE = defineInternalJsonRoute({ + contract: disconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: disconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: disconnectPersonalOrganizationAccount, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/route.ts b/apps/sim/app/api/users/me/organization-accounts/route.ts new file mode 100644 index 00000000000..64d1129abeb --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { listPersonalOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listPersonalOrganizationAccounts } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalOrganizationAccountsContract, + auth: internalSessionAuth, + operation: listPersonalOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalOrganizationAccounts, +}) diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts index c3237cd200a..57b0cb0d912 100644 --- a/apps/sim/app/api/v1/admin/dashboard/actor.ts +++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts @@ -1,16 +1,18 @@ import { db } from '@sim/db' -import { user } from '@sim/db/schema' -import { eq, or } from 'drizzle-orm' +import { foldedEmail, user } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { AdminMutationActor } from '@/lib/admin/dashboard' export async function getAdminAuditActor(request: NextRequest): Promise { - const email = request.headers.get('x-admin-email')?.trim().toLowerCase() + const rawEmail = request.headers.get('x-admin-email') + const email = rawEmail ? normalizeEmail(rawEmail) : '' if (!email) return { id: null, name: 'Admin API', email: null } const [admin] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) - .where(or(eq(user.email, email), eq(user.normalizedEmail, email))) + .where(eq(foldedEmail(user.email), email)) .limit(1) return admin ?? { id: null, name: 'Admin Panel', email } } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index 216d88bbd71..eefad3d2827 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -1,21 +1,34 @@ /** * @vitest-environment node */ -import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { recordAudit, recordAuditBatch } from '@sim/audit' +import { + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( - () => ({ - mockDetachOrganizationWorkspacesTx: vi.fn(), - mockDelete: vi.fn(), - mockAuthenticateAdminRequest: vi.fn(), - }) -) +const { + mockDetachOrganizationWorkspacesTx, + mockEnqueueResourceCleanup, + mockAuthenticateAdminRequest, +} = vi.hoisted(() => ({ + mockDetachOrganizationWorkspacesTx: vi.fn(), + mockEnqueueResourceCleanup: vi.fn(), + mockAuthenticateAdminRequest: vi.fn(), +})) vi.mock('@/lib/workspaces/organization-workspaces', () => ({ detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx, })) +vi.mock('@/lib/organizations/resource-cleanup', () => ({ + enqueueOrganizationResourceCleanup: mockEnqueueResourceCleanup, +})) + vi.mock('@/app/api/v1/admin/auth', () => ({ authenticateAdminRequest: mockAuthenticateAdminRequest, })) @@ -61,7 +74,7 @@ describe('admin organization DELETE', () => { /** Returned rather than written, so the caller can emit them post-commit. */ auditEntries: [], }) - mockDelete.mockClear() + mockEnqueueResourceCleanup.mockResolvedValue(undefined) }) afterAll(resetDbChainMock) @@ -115,7 +128,7 @@ describe('admin organization DELETE', () => { expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) }) - it('detaches workspaces before deleting the organization', async () => { + it('detaches workspaces and enqueues resource cleanup before deleting in the same transaction', async () => { queueOrganization() queueTableRows(schemaMock.subscription, []) queueTableRows(schemaMock.member, [{ value: 3 }]) @@ -130,6 +143,15 @@ describe('admin organization DELETE', () => { * through so the detach and the delete commit together. */ expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) + const tx = mockDetachOrganizationWorkspacesTx.mock.calls[0][0] + expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith(tx, ORG_ID) + expect(mockDetachOrganizationWorkspacesTx.mock.invocationCallOrder[0]).toBeLessThan( + mockEnqueueResourceCleanup.mock.invocationCallOrder[0] + ) + expect(mockEnqueueResourceCleanup.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.delete.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) const body = await response.json() expect(body.data).toMatchObject({ @@ -140,4 +162,39 @@ describe('admin organization DELETE', () => { workspacesDetached: 2, }) }) + + it('aborts the transaction before cascade and audit when durable cleanup cannot be queued', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + mockEnqueueResourceCleanup.mockRejectedValueOnce(new Error('outbox unavailable')) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(500) + expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledTimes(1) + expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith( + mockDetachOrganizationWorkspacesTx.mock.calls[0][0], + ORG_ID + ) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + expect(recordAuditBatch).not.toHaveBeenCalled() + }) + + it('does not emit success audit after a cascade failure', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + dbChainMockFns.delete.mockImplementationOnce(() => { + throw new Error('cascade unavailable') + }) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(500) + expect(mockEnqueueResourceCleanup).toHaveBeenCalledTimes(1) + expect(recordAudit).not.toHaveBeenCalled() + expect(recordAuditBatch).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 9849dc47761..15ca0aca873 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -58,6 +58,7 @@ import { TERMINAL_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enqueueOrganizationResourceCleanup } from '@/lib/organizations/resource-cleanup' import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -299,6 +300,7 @@ export const DELETE = withRouteHandler( */ const { detachedWorkspaceIds, auditEntries } = await db.transaction(async (tx) => { const detached = await detachOrganizationWorkspacesTx(tx, organizationId) + await enqueueOrganizationResourceCleanup(tx, organizationId) await tx.delete(organization).where(eq(organization.id, organizationId)) return detached }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index d63e5f2ae4f..56d10d72095 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,8 +75,8 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, - // Read at module scope by `lib/uploads/utils/file-utils`, which the route now - // reaches transitively through the knowledge orchestration module. + /** Shared upload/connector limits are read by the knowledge orchestration imports. */ + MAX_FILE_SIZE: 100 * 1024 * 1024, SUPPORTED_ARCHIVE_EXTENSIONS: [], })) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 0740600766f..2ea89aebe20 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -335,7 +335,7 @@ export const POST = withRouteHandler( const [workspaceContext, integrationTools, entitlements, billingAttribution] = await Promise.all([ generateWorkspaceContext(workspaceId, userId, { workspaceAccess, secretMountPolicy }), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildIntegrationToolSchemas(userId, undefined, workspaceId), computeWorkspaceEntitlements(workspaceId, userId), // Hosted execution refuses to run without an attribution snapshot; // the executor path receives it as a header, this path resolves it diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts index fe52256bca6..54187076a46 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts @@ -325,9 +325,10 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunk }) describe('chunk operation policy', () => { - it('denies workspace API keys on every chunk operation', () => { + it('allows ACL-filtered chunk listing while retaining the other chunk policies', () => { + expect(knowledgeOperations.listChunks.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listChunks.principalKinds).toContain('workspace_api_key') for (const operation of [ - knowledgeOperations.listChunks, knowledgeOperations.readChunk, knowledgeOperations.createChunk, knowledgeOperations.updateChunk, diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts index e18e32b78e3..75f47e6f119 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts @@ -113,6 +113,13 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({ + createStorageAdapter: () => ({ + consumeTokensAtomically: async () => ({ allowed: true, retryAfterMs: 0 }), + getCooldownUntil: async () => null, + setCooldownUntil: async () => undefined, + }), +})) vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt })) @@ -267,7 +274,8 @@ describe('Knowledge search provenance through the V2 route and reranker HTTP bou expect(mocks.generateEmbedding).toHaveBeenCalledWith( requestInput.query, expect.anything(), - 'workspace-1' + 'workspace-1', + undefined ) } ) diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 562c995d3aa..1688466dcc6 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -87,6 +87,7 @@ describe('POST /api/v2/knowledge/search', () => { expect(mockSearch).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { + surface: 'api', workspaceId: WORKSPACE_ID, knowledgeBaseIds: ['kb-1'], query: 'hello', diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 4ede73f7b7a..28a122f0a5b 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -28,6 +28,7 @@ export const POST = defineV2JsonRoute({ : [body.knowledgeBaseIds], query: body.query, topK: body.topK, + surface: 'api' as const, tagFilters: body.tagFilters, searchMode: body.searchMode, rerankerEnabled: body.rerankerEnabled, diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 16743d93a6a..bf926e6a871 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -130,7 +130,7 @@ async function updateUserStatsForWand( await recordUsage({ userId: billingAttribution.actorUserId, - workspaceId: billingAttribution.workspaceId, + workspaceId: billingAttribution.workspaceId ?? undefined, ...toBillingContext(billingAttribution), entries: [ { diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index b4acb132c12..a955c51a990 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant' import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' +import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' @@ -35,6 +36,7 @@ const handlers = { ...invitationMigrationOutboxHandlers, ...directGrantOutboxHandlers, ...knowledgeDocumentProcessingOutboxHandlers, + ...organizationResourceCleanupOutboxHandlers, ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, ...workflowDeploymentOutboxHandlers, diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts index d47add6d93a..647fe51daa1 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -1,5 +1,4 @@ import { - deleteCredentialGroupContract, getCredentialGroupContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -9,7 +8,6 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { - deleteCredentialGroupSettings, getCredentialGroupSettings, updateCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -49,17 +47,3 @@ export const PATCH = defineInternalJsonRoute({ useCase: updateCredentialGroupSettings, present: ({ credentialGroup }) => ({ credentialGroup }), }) - -export const DELETE = defineInternalJsonRoute({ - contract: deleteCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.delete, - rateLimit, - errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'), - mapInput: ({ params }) => ({ - assertedWorkspaceId: params.id, - credentialGroupId: params.groupId, - }), - useCase: deleteCredentialGroupSettings, - present: () => ({ success: true as const }), -}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts index 21a9e594ede..fb02424e456 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -33,6 +33,7 @@ export const POST = defineInternalJsonRoute({ slackBotCredentialId: body.slackBotCredentialId, clientId: body.clientId, clientSecret: body.clientSecret, + requiredScopes: body.requiredScopes, }), useCase: startSlackCredentialGroupConfiguration, }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts new file mode 100644 index 00000000000..4f6d7ba70bb --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ ensure: vi.fn(), getSession: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ + ensureWorkspaceAccounts: { + operation: { id: 'credential_groups.workspace.ensure' }, + execute: mocks.ensure, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/workspaces/[id]/credential-groups/ensure/route' + +const workspaceId = '11111111-1111-4111-8111-111111111111' +const credentialGroup = { + id: '22222222-2222-4222-8222-222222222222', + workspaceId, + name: 'Connected accounts', + description: null, + options: [], + mcpServers: [], + status: 'active', + createdAt: '2026-09-04T00:00:00.000Z', + updatedAt: '2026-09-04T00:00:00.000Z', +} +const context = { params: Promise.resolve({ id: workspaceId }) } +const request = () => + new NextRequest(`http://localhost:3000/api/workspaces/${workspaceId}/credential-groups/ensure`, { + method: 'POST', + }) + +describe('workspace connected accounts setup route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.ensure.mockResolvedValue({ credentialGroup, created: true }) + }) + + it('authenticates before validating workspace parameters', async () => { + mocks.getSession.mockResolvedValue(null) + const response = await POST(request(), { params: Promise.resolve({ id: '' }) }) + expect(response.status).toBe(401) + expect(mocks.ensure).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'returns the same account shape when newly created is %s', + async (created) => { + mocks.ensure.mockResolvedValue({ credentialGroup, created }) + const input = request() + const response = await POST(input, context) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroup }) + expect(mocks.ensure).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { workspaceId }, + request: input, + }) + } + ) + + it('preserves the application authorization refusal', async () => { + mocks.ensure.mockRejectedValue(new OrchestrationError('forbidden', 'Admin access required')) + const response = await POST(request(), context) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts new file mode 100644 index 00000000000..f4bc337bbd2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts @@ -0,0 +1,23 @@ +import { ensureWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { ensureWorkspaceAccounts } from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: ensureWorkspaceAccountsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.ensureWorkspaceAccounts, + rateLimit: internalRateLimits.none({ reason: 'Idempotent, admin-only workspace account setup' }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to set up connected accounts', + 'Workspace not found' + ), + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: ensureWorkspaceAccounts, + present: ({ credentialGroup }) => ({ credentialGroup }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 6259951e256..0016c90d4a2 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -6,7 +6,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - create: vi.fn(), getSession: vi.fn(), list: vi.fn(), })) @@ -14,30 +13,20 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ - createCredentialGroupSettings: { - operation: { id: 'credential_groups.create' }, - execute: mocks.create, - }, - listCredentialGroupSettings: { - operation: { id: 'credential_groups.settings.list' }, + getWorkspaceAccountsSettings: { + operation: { id: 'credential_groups.workspace.read' }, execute: mocks.list, }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' -import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' +import { GET } from '@/app/api/workspaces/[id]/credential-groups/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { - return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { - method, - ...(body - ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } - : {}), - }) +function createRequest(): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`) } describe('credential groups collection route', () => { @@ -47,24 +36,15 @@ describe('credential groups collection route', () => { user: { id: 'user-1' }, session: { id: 'session-1' }, }) - mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] }) - }) - - it('authenticates before parsing the request body', async () => { - mocks.getSession.mockResolvedValue(null) - - const response = await POST(createRequest('POST', {}), context) - - expect(response.status).toBe(401) - expect(mocks.create).not.toHaveBeenCalled() + mocks.list.mockResolvedValue({ credentialGroup: null, availableProviders: ['gmail'] }) }) it('enters the application use case with the authenticated session principal', async () => { - const request = createRequest('GET') + const request = createRequest() const response = await GET(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] }) + expect(await response.json()).toEqual({ credentialGroup: null, availableProviders: ['gmail'] }) expect(mocks.list).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID }, @@ -77,28 +57,9 @@ describe('credential groups collection route', () => { new OrchestrationError('not_found', 'Credential Groups are not available') ) - const response = await GET(createRequest('GET'), context) + const response = await GET(createRequest(), context) expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) }) - - it('fails fast when managed Gmail OAuth is not configured', async () => { - mocks.create.mockRejectedValue( - new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') - ) - - const response = await POST( - createRequest('POST', { - name: 'Support inboxes', - options: [{ provider: 'gmail', label: 'Gmail', required: true }], - }), - context - ) - - expect(response.status).toBe(503) - expect(await response.json()).toEqual({ - error: 'Managed Gmail authorization is not configured', - }) - }) }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts index c776f985698..27b7c06beea 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -1,45 +1,24 @@ -import { - createCredentialGroupContract, - listCredentialGroupsContract, -} from '@/lib/api/contracts/credential-groups' +import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { - createCredentialGroupSettings, - listCredentialGroupSettings, -} from '@/lib/credential-groups/application/manage-groups' +import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' export const GET = defineInternalJsonRoute({ - contract: listCredentialGroupsContract, + contract: getWorkspaceAccountsContract, auth: internalSessionAuth, - operation: credentialGroupOperations.listSettings, + operation: credentialGroupOperations.workspaceSettings, rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group list behavior', + reason: 'Workspace account settings do not require additional admission limits', }), errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to list credential groups', + 'Failed to load connected accounts', 'Workspace not found' ), mapInput: ({ params }) => ({ workspaceId: params.id }), - useCase: listCredentialGroupSettings, -}) - -export const POST = defineInternalJsonRoute({ - contract: createCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.create, - rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group create behavior', - }), - errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to create credential group', - 'Workspace not found' - ), - mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }), - useCase: createCredentialGroupSettings, + useCase: getWorkspaceAccountsSettings, }) diff --git a/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts new file mode 100644 index 00000000000..16ec78bb6c8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { getWorkspaceOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceOrganizationAccountsContract, + auth: internalSessionAuth, + operation: getWorkspaceOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Read-only workspace organization account status' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceOrganizationAccounts, +}) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 5e261f4b326..7fce432cca5 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -78,11 +78,10 @@ export const PATCH = withRouteHandler( try { const body = parsed.data.body - const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body + const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body if ( name === undefined && - color === undefined && logoUrl === undefined && billedAccountUserId === undefined && allowPersonalApiKeys === undefined @@ -106,10 +105,6 @@ export const PATCH = withRouteHandler( updateData.name = name } - if (color !== undefined) { - updateData.color = color - } - if (logoUrl !== undefined) { updateData.logoUrl = logoUrl } @@ -198,7 +193,6 @@ export const PATCH = withRouteHandler( metadata: { changes: { ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), - ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), ...(logoUrl !== undefined && { logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, }), diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.test.ts b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts new file mode 100644 index 00000000000..21a928e499f --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/invitations/application/send-invitation-batch', () => { + const operation = { + id: 'invitations.send_batch', + capability: 'invitations.send', + principalKinds: ['session'], + } + return { + invitationOperations: { sendBatch: operation }, + sendInvitationBatch: { operation, execute: mocks.execute }, + } +}) + +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { POST } from '@/app/api/workspaces/invitations/batch/route' + +function request(body: unknown) { + return createMockRequest( + 'POST', + body, + undefined, + 'http://localhost:3000/api/workspaces/invitations/batch' + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } }) + mocks.execute.mockResolvedValue({ + success: true, + successful: ['person@example.com'], + added: [], + failed: [], + invitations: [], + }) +}) + +describe('invitation batch route', () => { + it('authenticates before parsing or calling the operation', async () => { + mocks.session.mockResolvedValue(null) + const response = await POST(request({}), { params: Promise.resolve({}) }) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a canonical session principal and explicit organization-only input', async () => { + const body = { + organizationId: 'org-target', + workspaceIds: [], + emails: ['person@example.com'], + membership: 'member', + } + const response = await POST(request(body), { params: Promise.resolve({}) }) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', sessionId: 'session', userId: 'actor' }, + input: body, + }) + ) + }) + + it('rejects empty unscoped workspace lists before use-case execution', async () => { + const response = await POST(request({ workspaceIds: [], emails: ['person@example.com'] }), { + params: Promise.resolve({}), + }) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('preserves authorization refusal status and error shape', async () => { + mocks.execute.mockRejectedValue( + new WorkspaceInvitationError({ + message: 'Only organization owners and admins can invite members.', + status: 403, + }) + ) + const response = await POST( + request({ organizationId: 'org', workspaceIds: [], emails: ['person@example.com'] }), + { params: Promise.resolve({}) } + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Only organization owners and admins can invite members.', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index b10dcdee454..905fb499ae4 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -1,134 +1,43 @@ -import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' -import { type NextRequest, NextResponse } from 'next/server' import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { ForbiddenOperationError } from '@/lib/core/application' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createWorkspaceInvitation, - prepareWorkspaceInvitationContext, - WorkspaceInvitationError, - type WorkspaceInvitationResult, -} from '@/lib/invitations/workspace-invitations' + defineInternalJsonRoute, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + invitationOperations, + sendInvitationBatch, +} from '@/lib/invitations/application/send-invitation-batch' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceInvitationBatchAPI') - -interface BatchInvitationFailure { - email: string - error: string -} - -function batchErrorResponse(error: unknown) { - if (error instanceof WorkspaceInvitationError) { - return NextResponse.json( - { - error: error.message, - ...(error.email ? { email: error.email } : {}), - ...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}), - }, - { status: error.status } - ) - } - - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - - logger.error('Error creating workspace invitation batch:', error) - return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 }) -} - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(batchWorkspaceInvitationsContract, req, {}) - if (!parsed.success) return parsed.response - const { body } = parsed.data - - const context = await prepareWorkspaceInvitationContext({ - workspaceIds: body.workspaceIds, - inviterId: session.user.id, - inviterName: session.user.name || session.user.email || 'A user', - inviterEmail: session.user.email, - }) - - const successful: string[] = [] - const added: string[] = [] - const failed: BatchInvitationFailure[] = [] - const invitations: WorkspaceInvitationResult[] = [] - const seenEmails = new Set() - - for (const rawEmail of body.emails) { - const normalizedEmail = normalizeEmail(rawEmail) - if (seenEmails.has(normalizedEmail)) { - failed.push({ - email: normalizedEmail, - error: `${normalizedEmail} appears more than once in this invitation batch`, - }) - continue - } - seenEmails.add(normalizedEmail) - - try { - const invitation = await createWorkspaceInvitation({ - context, - email: rawEmail, - permission: body.permission, - membership: body.membership, - request: req, - }) - if (invitation.instantAdd) { - // Only report an actual insertion; an `unchanged` outcome means the - // user already had access (rare race) and is a silent no-op. - if (invitation.outcome === 'added') added.push(invitation.email) - } else { - successful.push(invitation.email) - } - invitations.push(invitation) - } catch (error) { - if (error instanceof WorkspaceInvitationError) { - failed.push({ email: error.email ?? normalizedEmail, error: error.message }) - continue - } - /** A directory-managed address is refused with its reason, like any other per-email refusal. */ - if (error instanceof ForbiddenOperationError) { - failed.push({ email: normalizedEmail, error: error.message }) - continue - } - - /** - * One bad address must not discard the invitations that already - * succeeded, so unexpected failures are reported per email rather than - * aborting the batch. - */ - logger.error('Unexpected workspace invitation batch item failure:', { - email: normalizedEmail, - error, - }) - failed.push({ - email: normalizedEmail, - error: 'Failed to create invitation', +export const POST = defineInternalJsonRoute({ + contract: batchWorkspaceInvitationsContract, + auth: internalSessionAuth, + operation: invitationOperations.sendBatch, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing bounded invitation batch behavior.', + }), + errorPolicy: { + project(error) { + if (error instanceof WorkspaceInvitationError) { + return internalErrorResponse(error.status, { + error: error.message, + ...(error.email ? { email: error.email } : {}), + ...(error.upgradeRequired !== undefined + ? { upgradeRequired: error.upgradeRequired } + : {}), }) } - } - - return NextResponse.json({ - success: failed.length === 0, - successful, - added, - failed, - invitations, - }) - } catch (error) { - return batchErrorResponse(error) - } + if (error instanceof InvitationsNotAllowedError) + return internalErrorResponse(403, { error: error.message }) + return null + }, + unhandled: () => internalErrorResponse(500, { error: 'Failed to create invitation batch' }), + }, + mapInput: ({ body }) => body, + useCase: sendInvitationBatch, }) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index ccbef3c9db2..2aa469b8ffa 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -119,7 +119,9 @@ describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) mockGetSession.mockResolvedValue({ + session: { id: 'session-1' }, user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, }) mockGetWorkspaceWithOwner.mockResolvedValue({ @@ -171,6 +173,7 @@ describe('POST /api/workspaces/invitations/batch', () => { afterAll(() => { resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) }) it('keeps unexpected database details out of per-email failures', async () => { @@ -190,7 +193,7 @@ describe('POST /api/workspaces/invitations/batch', () => { expect(response.status).toBe(200) expect(data.success).toBe(false) expect(data.failed).toEqual([ - { email: 'new@example.com', error: 'Failed to create invitation' }, + { email: 'new@example.com', error: 'Failed to create invitation. Please try again.' }, ]) expect(mockSendInvitationEmail).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 50033c1d51c..c5b398fa3f1 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -123,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const parsed = await parseRequest(createWorkspaceContract, req, {}) if (!parsed.success) return parsed.response - const { name, color, skipDefaultWorkflow } = parsed.data.body + const { name, skipDefaultWorkflow } = parsed.data.body const activeOrganizationId = getActiveOrganizationId(session) const creationPolicy = await getWorkspaceCreationPolicy({ userId: session.user.id, @@ -153,7 +153,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { userId: session.user.id, name, skipDefaultWorkflow, - explicitColor: color, organizationId: creationPolicy.organizationId, workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, @@ -188,7 +187,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { description: `Created workspace "${newWorkspace.name}"`, metadata: { name: newWorkspace.name, - color: newWorkspace.color, workspaceMode: newWorkspace.workspaceMode, organizationId: newWorkspace.organizationId, }, diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 4841914b303..6191d134b6c 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -6,12 +6,31 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, } -export default function CredentialGroupCompletePage() { +const OAUTH_FAILURE_MESSAGES = { + denied: 'Authorization was canceled. Return to the chat to try again.', + account_mismatch: 'Choose the account matching your Sim email address.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'The connection settings changed. Return to the chat to try again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + unavailable: 'This connection is unavailable. Return to the chat to try again.', + failed: 'Account authorization did not complete. Return to the chat to try again.', +} as const + +export default async function CredentialGroupCompletePage({ + searchParams, +}: { + searchParams: Promise<{ oauth?: string | string[] }> +}) { + const { oauth } = await searchParams + const error = + typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth) + ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES] + : undefined return ( ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx index ab2fcffbfba..24c77bee3cb 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -1,15 +1,15 @@ 'use client' -import { chipVariants } from '@sim/emcn' +import { type ChipLinkProps, chipVariants } from '@sim/emcn' -interface OAuthConnectLinkProps { +interface OAuthConnectLinkProps extends Pick { href: string reconnect?: boolean } -export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) { +export function OAuthConnectLink({ href, reconnect = false, variant }: OAuthConnectLinkProps) { return ( - + {reconnect ? 'Reconnect' : 'Connect'} ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx new file mode 100644 index 00000000000..7b5d08d9439 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -0,0 +1,257 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react' +import { authMockFns } from '@sim/testing' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + read: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('next/headers', () => ({ headers: async () => new Headers() })) +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + readPublicCredentialGroupEnrollment: { execute: mocks.read }, +})) +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, +})) +vi.mock('@/lib/credential-groups/providers', () => ({ + CREDENTIAL_GROUP_PROVIDER_IDS: ['confluence', 'slack'], + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['confluence'], + getCredentialGroupProviderService: (provider: string) => ({ + providerId: provider, + name: provider === 'confluence' ? 'Confluence' : 'Slack', + icon: () => null, + }), +})) +vi.mock('@/lib/credential-groups/managed-mcp-connector-icons', () => ({ + getManagedMcpConnectorIcon: () => () => null, +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthHeader: ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+ ), + SupportFooter: () => null, +})) +vi.mock('@/app/(landing)/components', () => ({ + LogoShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/credential-groups/enroll/[token]/oauth-toast', () => ({ + CredentialGroupOAuthToast: ({ message }: { message: string }) => ( +
{message}
+ ), +})) + +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import CredentialGroupEnrollmentPage from '@/app/credential-groups/enroll/[token]/page' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'canonical-workspace', + credentialGroupId: 'accounts', + enrollmentId: 'enrollment', + email: 'member@example.test', + invitationTokenHash: 'hash', +} as const +let enrollment: PublicCredentialGroupEnrollment + +async function render(searchParams: Record = {}) { + const page = await CredentialGroupEnrollmentPage({ + params: Promise.resolve({ token: 'invitation' }), + searchParams: Promise.resolve(searchParams), + }) + document.body.innerHTML = renderToStaticMarkup(page) +} + +function oauthLinks() { + return Array.from(document.querySelectorAll('a')).filter((link) => + link.getAttribute('href')?.includes('/oauth/') + ) +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'member', email: 'member@example.test', emailVerified: true }, + session: { id: 'session-1' }, + }) + mocks.authenticate.mockResolvedValue(principal) + mocks.rateLimit.mockResolvedValue(null) + enrollment = { + inviterName: 'Admin', + workspaceName: 'Company', + credentialGroupName: 'Accounts', + status: 'in_progress', + options: [ + { + id: 'site-one', + label: 'First Confluence site', + provider: 'confluence', + status: 'active', + required: false, + connections: [], + }, + { + id: 'site-two', + label: 'Second Confluence site', + provider: 'confluence', + status: 'active', + required: false, + connections: [], + }, + { + id: 'slack', + label: 'Slack', + provider: 'slack', + status: 'active', + required: true, + connections: [], + }, + ], + mcpServers: [ + { + id: 'mcp-one', + name: 'Unrelated MCP', + description: null, + managedConnectorId: 'linear', + connection: null, + }, + ], + } + mocks.read.mockImplementation(async () => ({ enrollment })) +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('focused Search enrollment', () => { + it('retains the generic invitation choices and Submit without Search context', async () => { + await render() + expect(oauthLinks()).toHaveLength(3) + expect(document.body.textContent).toContain('Unrelated MCP') + expect(document.querySelector('form')?.getAttribute('action')).toBe( + '/api/credential-groups/enroll/invitation/complete' + ) + expect(document.querySelector('button')?.textContent).toBe('Submit') + expect(document.body.textContent).not.toContain('Return to Search') + expect(document.body.textContent).not.toContain('Setup guide') + expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} }) + }) + + it('shows only the exact requested option and derives the return workspace from the principal', async () => { + await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' }) + expect(document.querySelector('h1')?.textContent).toBe('Connect your Confluence account') + expect(document.body.textContent).toContain('Second Confluence site') + expect(document.body.textContent).not.toContain('First Confluence site') + expect(document.body.textContent).not.toContain('Slack') + expect(document.body.textContent).not.toContain('Unrelated MCP') + expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=search', + ]) + expect(document.querySelector('form')).toBeNull() + expect( + Array.from(document.querySelectorAll('a')) + .find((link) => link.textContent === 'Return to Search') + ?.getAttribute('href') + ).toBe('/workspace/canonical-workspace/search') + const guide = Array.from(document.querySelectorAll('a')).find( + (link) => link.textContent === 'Setup guide' + ) + expect(guide?.getAttribute('href')).toBe('https://docs.sim.ai/search/confluence') + expect(guide?.getAttribute('target')).toBe('_blank') + expect(guide?.getAttribute('rel')).toBe('noopener noreferrer') + expect(mocks.read).toHaveBeenCalledWith({ principal, input: { optionId: 'site-two' } }) + }) + + it.each(['missing', '', 'site-two', ['site-one', 'site-two']])( + 'does not substitute a different account when focus is unusable: %s', + async (optionId) => { + enrollment.options[1]!.status = 'disabled' + await render({ + returnTo: 'search', + optionId: Array.isArray(optionId) ? [...optionId] : optionId, + }) + expect(document.body.textContent).toContain('Ask a workspace admin') + expect(oauthLinks()).toHaveLength(0) + expect(document.querySelector('form')).toBeNull() + expect(document.body.textContent).toContain('Return to Search') + } + ) + + it('reports provider configuration failures with a clear path back to Search', async () => { + mocks.read.mockRejectedValue( + new CredentialGroupProviderConfigurationError('Slack configuration missing') + ) + await render({ returnTo: 'search', optionId: 'slack' }) + expect(document.body.textContent).toContain('Connection unavailable') + expect(document.body.textContent).toContain('Ask a workspace admin') + expect(document.querySelector('a')?.getAttribute('href')).toBe( + '/workspace/canonical-workspace/search' + ) + }) + + it('shows Connected from current credential state without requiring generic completion', async () => { + enrollment.options[1]!.connections = [ + { + email: principal.email, + displayName: null, + avatarUrl: null, + status: 'connected', + grantedAt: '2026-09-05T12:00:00Z', + }, + ] + await render({ returnTo: 'search', optionId: 'site-two' }) + expect(document.body.textContent).toContain(`${principal.email} · Connected`) + expect(document.querySelector('h1')?.textContent).toBe('Confluence connected') + expect(oauthLinks()).toHaveLength(0) + expect(document.querySelector('form')).toBeNull() + expect(document.body.textContent).toContain('Return to Search') + }) + + it('does not treat a success query marker as a connected account', async () => { + await render({ + returnTo: 'search', + optionId: 'site-two', + connected: 'site-two', + mcp: 'connected', + mcpServerId: 'mcp-one', + }) + expect(oauthLinks()[0]?.textContent).toBe('Connect') + expect(document.body.textContent).toContain('Not connected') + expect(document.querySelector('[role="status"]')).toBeNull() + }) + + it('keeps the same focused Reconnect action after canceled authorization', async () => { + enrollment.options[1]!.connections = [ + { + email: principal.email, + displayName: null, + avatarUrl: null, + status: 'needs_reauth', + grantedAt: '2026-09-05T12:00:00Z', + }, + ] + await render({ returnTo: 'search', optionId: 'site-two', oauth: 'denied' }) + expect(oauthLinks()).toHaveLength(1) + expect(oauthLinks()[0]?.textContent).toBe('Reconnect') + expect(oauthLinks()[0]?.getAttribute('href')).toContain('/site-two?returnTo=search') + }) + + it('does not resolve enrollment metadata or trust a return workspace after authentication fails', async () => { + mocks.authenticate.mockResolvedValue(null) + await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' }) + expect(document.body.textContent).toContain('Invitation unavailable') + expect(mocks.read).not.toHaveBeenCalled() + expect(document.querySelector('a')).toBeNull() + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 25a2a5ba955..790319a44f2 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -1,14 +1,22 @@ import { type ReactNode, Suspense } from 'react' -import { Chip } from '@sim/emcn' +import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' +import type { ResourceOwner } from '@/lib/core/resource-scope' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' -import { SupportFooter } from '@/app/(auth)/components' +import { organizationRoutes } from '@/lib/navigation/paths' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { AuthHeader, SupportFooter } from '@/app/(auth)/components' import { LogoShell } from '@/app/(landing)/components' import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' @@ -44,18 +52,42 @@ function PageShell({ children }: PageShellProps) { ) } -function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) { +interface UnavailableInvitationProps { + rateLimited?: boolean + message?: string +} + +function UnavailableInvitation({ rateLimited = false, message }: UnavailableInvitationProps) { return (
-

- {rateLimited ? 'Too many requests' : 'Invitation unavailable'} -

-

- {rateLimited - ? 'This link has been opened too many times. Wait a few minutes and try again.' - : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'} -

+ +
+
+ ) +} + +interface UnavailableSearchConnectionProps { + owner: ResourceOwner +} + +function UnavailableSearchConnection({ owner }: UnavailableSearchConnectionProps) { + return ( + + +
+ Return to Search
) @@ -92,19 +124,45 @@ export default async function CredentialGroupEnrollmentPage({ const { token } = await params if (!token || token.length > 128) return - + const resolvedSearchParams = await searchParams + const session = await getSession() + if (!session?.user) { + const callback = new URLSearchParams() + for (const key of ['returnTo', 'optionId']) { + const value = getSearchParam(resolvedSearchParams, key) + if (value) callback.set(key, value) + } + const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` + redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`) + } + if (!session.user.emailVerified) + return ( + + ) const principal = await authenticateCredentialGroupEnrollment(token) if (!principal) return + const returnToSearch = resolvedSearchParams.returnTo === 'search' + const requestedOptionId = resolvedSearchParams.optionId + const focusedOptionId = + typeof requestedOptionId === 'string' && requestedOptionId.length <= 128 + ? requestedOptionId + : '' const enrollmentResult = await readPublicCredentialGroupEnrollment - .execute({ principal, input: {} }) + .execute({ principal, input: returnToSearch ? { optionId: focusedOptionId } : {} }) .catch((error: unknown) => { + if (error instanceof CredentialGroupEnrollmentError) + return { enrollment: null, enrollmentError: error.message } if (asOrchestrationError(error)?.code === 'not_found') return null + if (returnToSearch && error instanceof CredentialGroupProviderConfigurationError) + return { enrollment: null } throw error }) if (!enrollmentResult) return + if ('enrollmentError' in enrollmentResult) + return const { enrollment } = enrollmentResult + if (!enrollment) return - const resolvedSearchParams = await searchParams const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') const connectedMcpServerId = @@ -112,29 +170,44 @@ export default async function CredentialGroupEnrollmentPage({ ? getSearchParam(resolvedSearchParams, 'mcpServerId') : undefined const oauthMessage = - oauthStatus && oauthStatus in OAUTH_MESSAGES + oauthStatus && Object.hasOwn(OAUTH_MESSAGES, oauthStatus) ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] : null const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const focusedOption = returnToSearch + ? activeOptions.find((option) => option.id === focusedOptionId) + : undefined + if (returnToSearch && !focusedOption) return + const visibleOptions = focusedOption ? [focusedOption] : activeOptions + const focusedConnected = focusedOption?.connections[0]?.status === 'connected' + const focusedProviderId = focusedOption + ? getCredentialGroupProviderService(focusedOption.provider).providerId + : undefined + const docsUrl = focusedProviderId + ? SEARCH_CONNECTORS.find((connector) => connector.providerIds.includes(focusedProviderId))?.meta + .searchDocsUrl + : undefined const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) : undefined const connectedMcpServer = connectedMcpServerId ? enrollment.mcpServers.find((server) => server.id === connectedMcpServerId) : undefined - const notification = connectedMcpServerId - ? { - message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`, - variant: 'success' as const, - } - : connectedOptionId + const notification = + !returnToSearch && connectedMcpServerId ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`, variant: 'success' as const, } - : oauthMessage - ? { message: oauthMessage, variant: 'error' as const } - : null + : connectedOptionId && + (!returnToSearch || (connectedOptionId === focusedOption?.id && focusedConnected)) + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null return ( {notification && ( @@ -142,28 +215,25 @@ export default async function CredentialGroupEnrollmentPage({ )} -
-

- 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 +241,82 @@ export default async function CredentialGroupEnrollmentPage({ key={option.id} icon={} title={option.label} - description={connection?.email ?? 'Not connected'} - trailing={ - - } - /> - ) - })} - {enrollment.mcpServers.map((server) => { - const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId) - return ( - } - title={server.name} description={ - server.connection?.status === 'connected' - ? 'Connected' - : server.connection - ? 'Reconnect required' - : server.description || 'Not connected' + returnToSearch && connection?.status === 'connected' + ? `${connection.email} · Connected` + : (connection?.email ?? 'Not connected') } trailing={ - + returnToSearch && connection?.status === 'connected' ? undefined : ( + + ) } /> ) })} + {!returnToSearch && + enrollment.mcpServers.map((server) => { + const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId) + return ( + } + title={server.name} + description={ + server.connection?.status === 'connected' + ? 'Connected' + : server.connection + ? 'Reconnect required' + : server.description || 'Not connected' + } + trailing={ + + } + /> + ) + })}
- - - {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 14425f998c3..ab09b550b13 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -108,19 +108,22 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } } catch (e) {} + // The organization surface (/o/...) shares the workspace chrome and + // needs the same variables set before first paint. try { var path = window.location.pathname; - if (path.indexOf('/workspace/') === -1) { + if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0) { return; } } catch (e) { return; } - // Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts: - // the upper bound can never fall below the 238px minimum, so a narrow - // window yields a width >= MIN instead of a sub-minimum sliver. - var defaultSidebarWidth = 238; + // Sidebar width. Mirror getMaxSidebarWidth() in stores/sidebar/store.ts: + // 30% of the viewport capped at 400px, and never below the 256px + // minimum, so a narrow window yields a width >= MIN instead of a + // sub-minimum sliver. + var defaultSidebarWidth = 256; try { // Collapse comes from the cookie (independent of localStorage // parsing); the persisted width is read defensively below. Match the @@ -146,10 +149,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // collapsed, because the desktop hover-peek renders the sidebar at // its restore width while --sidebar-width still reads collapsed. var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(238, window.innerWidth * 0.3); + var maxSidebarWidth = Math.max(256, Math.min(400, window.innerWidth * 0.3)); var expandedWidth = typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 238), maxSidebarWidth) + ? Math.min(Math.max(width, 256), maxSidebarWidth) : defaultSidebarWidth; document.documentElement.style.setProperty( '--sidebar-expanded-width', diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts index 23e600614a0..a0e5f077e0c 100644 --- a/apps/sim/app/manifest.ts +++ b/apps/sim/app/manifest.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from 'next' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' @@ -43,7 +44,7 @@ export default function manifest(): MetadataRoute.Manifest { name: 'Create Workflow', short_name: 'New', description: 'Create a new AI workflow', - url: '/workspace', + url: WORKSPACES_PATH, }, ], lang: 'en-US', diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx new file mode 100644 index 00000000000..d05f2568244 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' + +export const metadata: Metadata = { title: 'Chat' } + +export default async function OrganizationChatPage({ + params, +}: { + params: Promise<{ organizationId: string; chatId: string }> +}) { + const { organizationId, chatId } = await params + const session = await getSession() + if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { + principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + }) + if (!chat || chat.type !== 'mothership' || chat.organizationId !== organizationId) notFound() + return +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx new file mode 100644 index 00000000000..6a4bb7647f1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx @@ -0,0 +1,27 @@ +import { ChipLink } from '@sim/emcn' +import { CircleAlert } from '@sim/emcn/icons' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' + +export function OrganizationAccessDenied() { + return ( +
+ +
+
+ +
+
+

Organization access denied

+

+ You are not a member of this organization. Ask an organization admin to add you, or head + back to your workspaces. +

+
+ + View your workspaces + +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts new file mode 100644 index 00000000000..ffdb2e714bd --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts @@ -0,0 +1,6 @@ +export { + OrganizationPage, + type OrganizationPageTab, + PAGE_COLUMN_CLASS, +} from '@/app/o/[organizationId]/components/organization-page/organization-page' +export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx new file mode 100644 index 00000000000..f3f43caf421 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx @@ -0,0 +1,174 @@ +'use client' + +import { type ReactNode, useRef, useState } from 'react' +import { + Button, + Chip, + ChipInput, + cn, + scrollFadeAttributes, + scrollFadeClass, + scrollFadeXClass, + useScrollEdges, +} from '@sim/emcn' +import { Search, X } from '@sim/emcn/icons' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' + +/** The home surface's reading column, so every organization page shares its width. */ +export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6' + +export interface OrganizationPageTab { + id: string + label: string +} + +interface OrganizationPageProps { + title: string + description?: string + /** Header tabs; the first is the default. Omit for a page with one view. */ + tabs?: readonly OrganizationPageTab[] + /** The page's primary action, a chip. Omit for a page without one. */ + action?: ReactNode + children?: ReactNode +} + +/** + * The shell every organization page renders into: the top bar the workspace pages + * wear, then a fixed page header — title, description, tabs, search, and the + * optional action — over a scroll region that fades at both edges the way the + * sidebar does. Pages supply their content and logic; nothing else. + * + * The shell paints at once and never waits on data: a page renders each piece — + * a tab, a list, a count — the moment it is known and nothing before, with no + * skeleton standing in for it. Pass `tabs` only once they are known; the row + * simply gains them. + */ +export function OrganizationPage({ + title, + description, + tabs, + action, + children, +}: OrganizationPageProps) { + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) + const tabsRef = useRef(null) + const tabEdges = useScrollEdges(tabsRef, { axis: 'x' }) + + const { tab, search, setTab, setSearch } = useOrganizationPageFilters() + const defaultTab = tabs?.[0]?.id + const activeTab = tab ?? defaultTab + + /** + * The field stays open while it holds text, across tab switches and reloads, + * since the text lives in the URL; this only remembers an empty field the + * viewer opened and has not dismissed. + */ + const [searchOpened, setSearchOpened] = useState(false) + const searchOpen = searchOpened || search.length > 0 + + const closeSearch = () => { + setSearch('') + setSearchOpened(false) + } + + return ( +
+ {/* Reserved even while empty so the page header sits where the workspace's does. */} +
+
+
+ +
+
+

{title}

+ {description &&

{description}

} +
+
+ {/* The row yields to the controls beside it and scrolls sideways under a fade + once it can no longer fit; the scrollbar itself never shows. */} +
+ {tabs?.map((item) => { + const active = item.id === activeTab + return ( + setTab(item.id === defaultTab ? null : item.id)} + className='min-w-[44px] shrink-0 text-center' + > + {item.label} + + ) + })} +
+
+ {searchOpen ? ( + setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') closeSearch() + }} + endAdornment={ + + } + /> + ) : ( + setSearchOpened(true)} /> + )} + {action} +
+
+
+ +
+
+ {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..042bca94ad8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -0,0 +1,183 @@ +'use client' + +import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' +import Link from 'next/link' +import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' + +/** Stands in for a chip row while the list loads, so it carries no margin either. */ +function ChatRowSkeleton() { + return ( +
+ +
+ ) +} + +interface ChatRowProps { + chat: OrganizationChat + isCurrentRoute: boolean + isMenuOpen: boolean + onContextMenu: (e: React.MouseEvent, href: string) => void + onMoreClick: (e: React.MouseEvent, href: string) => void +} + +function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) { + /** + * The trailing slot fits one glyph, and the dot wins over the pin: it reports + * transient state (a run in progress, or an unread reply elsewhere), while pinning + * is persistent and already conveyed by the row sorting to the top of the list. + */ + const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread)) + + return ( + onContextMenu(e, chat.href)} + > + +
+ {showStatusDot && ( +
+ + ) +} + +interface ChatsSectionProps { + chats: OrganizationChat[] + isLoading: boolean + isCollapsed: boolean + pathname: string | null + /** Href of the row whose options menu is open, so it stays highlighted meanwhile. */ + menuOpenHref: string | null + onContextMenu: (e: React.MouseEvent, href: string) => void + onMoreClick: (e: React.MouseEvent, href: string) => void +} + +/** + * The organization's chats, the section beneath Workspaces, spaced from it by the + * section gap exactly as the workspace sidebar spaces its own sections. Expanded, a + * collapsible list of every chat — no paging, the scroll region carries the length; + * collapsed, a hover flyout off the rail glyph. + */ +export function ChatsSection({ + chats, + isLoading, + isCollapsed, + pathname, + menuOpenHref, + onContextMenu, + onMoreClick, +}: ChatsSectionProps) { + const hover = useHoverMenu() + + return ( + + {isCollapsed ? ( +
+ } + hover={hover} + ariaLabel='Chats' + > + {isLoading ? ( + + + Loading... + + ) : chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => { + const isCurrentRoute = pathname === chat.href + return ( + + onContextMenu(e, chat.href)}> + + + + ) + }) + )} + +
+ ) : ( +
+ {isLoading ? ( + + ) : ( + <> + {chats.length === 0 && ( +
+ No chats yet +
+ )} + {chats.map((chat) => ( + + ))} + + )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts new file mode 100644 index 00000000000..a2ee28fffc9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts @@ -0,0 +1 @@ +export { ChatsSection } from './chats-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts new file mode 100644 index 00000000000..525cf120d1d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts @@ -0,0 +1,5 @@ +export { ChatsSection } from './chats-section' +export { OrganizationFooter } from './organization-footer' +export { OrganizationHeader } from './organization-header' +export { WorkspacesRailFlyout } from './workspaces-rail-flyout' +export { WorkspacesSection } from './workspaces-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts new file mode 100644 index 00000000000..95078897371 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts @@ -0,0 +1 @@ +export { OrganizationFooter } from './organization-footer' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx new file mode 100644 index 00000000000..3abcc00a6f2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx @@ -0,0 +1,240 @@ +'use client' + +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { + Chip, + chipContentLabelClass, + chipPrimaryFillTokens, + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + Skeleton, +} from '@sim/emcn' +import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons' +import Link from 'next/link' +import { SlackIcon } from '@/components/icons' +import { getDesktopUpdates } from '@/lib/desktop' +import { organizationRoutes } from '@/lib/navigation/paths' +import { getUserColor } from '@/lib/workspaces/colors' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_RAIL_CHIP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useUserProfile } from '@/hooks/queries/user-profile' +import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' + +function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean { + return state.status === 'available' || state.status === 'downloading' || state.status === 'ready' +} + +function desktopUpdateActionLabel(state: DesktopUpdateState): string { + if (state.status === 'downloading') { + return state.percent === undefined + ? 'Downloading update…' + : `Downloading update ${state.percent}%` + } + return state.status === 'ready' ? 'Restart to update' : 'Update' +} + +/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */ +function DesktopUpdateIcon({ className }: { className?: string }) { + return ( +
+ {/* Download's default viewBox is asymmetric around its paths. Center the + artwork itself, not merely its SVG box, inside the avatar-sized circle. */} + +
+ ) +} + +interface OrganizationFooterProps { + /** + * True while the scroll region above still hides rows beyond its bottom edge — + * the same test the divider under the pinned nav applies at the top. The bar's + * top rule is drawn only then, so a list that fits meets the footer with no line. + */ + showDivider: boolean + isCollapsed: boolean + showCollapsedTooltips: boolean + onOpenDocs: () => void + onJoinSlack: () => void +} + +/** + * Pinned bottom bar of the organization sidebar: the viewer's avatar and name, + * which open their account settings, plus a help menu. Same two elements and the + * same layout as the workspace footer — expanded they share one row with help hard + * right, collapsed they stack as icon chips with help on top. + * + * Collapsed reverses the flex direction instead of reordering the DOM, which keeps + * both elements (and the help menu's trigger) alive across a toggle. + */ +export function OrganizationFooter({ + showDivider, + isCollapsed, + showCollapsedTooltips, + onOpenDocs, + onJoinSlack, +}: OrganizationFooterProps) { + const { organization } = useOrganizationContext() + const { data: profile } = useUserProfile() + const updateState = useDesktopUpdateState() + + const name = profile ? profile.name?.trim() || profile.email : '' + const updateAvailable = hasAvailableDesktopUpdate(updateState) + + const handleUpdateSelect = () => { + const updates = getDesktopUpdates() + if (updateState.status === 'ready') { + updates?.install() + } else if (updateState.status === 'available') { + updates?.check() + } + } + + /** + * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a + * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`, + * which would blank the avatar exactly where it is the only thing left to see. + */ + const avatar = !profile ? ( + + ) : profile.image ? ( + + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ ) + + /** + * Expanded, the chip hugs its content (`max-w-full` so a long name truncates + * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and + * `min-w-0` lets the hidden label give up its box so the chip never overflows it. + * The name is the button's accessible name — no `aria-label`, which would + * override the visible text. + */ + const profileMenu = ( + + + + + + + + + + + + + + + + ) + + /** + * One node across both states; only `fullWidth` changes, so the same Radix menu + * survives the transition. `shrink-0` keeps the chip off the avatar while the rail + * is briefly narrower than the row — the aside's clip hides it until there is room. + */ + const helpMenu = ( + + + + + + + {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */} + + {updateAvailable && ( + <> + + + {desktopUpdateActionLabel(updateState)} + + + + )} + + + Docs + + + + Join Slack + + + + ) + + return ( +
+ {/* Expanded, claims the row's free width so the help button lands hard right. + `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the + chip's 30px rather than a line box padded by the strut's half-leading. */} +
{profileMenu}
+ {helpMenu} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts new file mode 100644 index 00000000000..7040e44e82d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts @@ -0,0 +1 @@ +export { OrganizationHeader } from './organization-header' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx new file mode 100644 index 00000000000..c007e5d395d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -0,0 +1,117 @@ +'use client' + +import { + Chip, + ChipChevronDown, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@sim/emcn' +import { PanelLeft, Settings } from '@sim/emcn/icons' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { getOrganizationSettingsHref } from '@/components/settings/navigation' +import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' +import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' +import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' + +function getOrganizationInitial(name: string): string { + return (name.trim()[0] || 'O').toUpperCase() +} + +interface OrganizationHeaderProps { + organization: OrganizationSurfaceOrganization + isCollapsed: boolean + /** Expands the rail; the collapsed header is itself the expand control. */ + onExpandSidebar: () => void +} + +/** + * The top-left organization chip. Expanded, it names the organization and opens + * its card — the mark at tile size, the name, how many people belong, and the way + * into its settings; collapsed, it becomes the rail's expand control, swapping + * the mark for a panel glyph on hover exactly as the workspace header does. The + * mark is the organization's uploaded logo or its initial on the neutral tile. + */ +export function OrganizationHeader({ + organization, + isCollapsed, + onExpandSidebar, +}: OrganizationHeaderProps) { + const initial = getOrganizationInitial(organization.name) + + if (isCollapsed) { + return ( +
+ + + +
+ } + /> +
+ ) + } + + const { memberCount } = organization + + return ( +
+ + + } + rightAdornment={} + > + {organization.name} + + + + {/* The item rows' `px-2` and the rail chips' icon-to-label gap, so the card sits on the menu's own grid. */} +
+ +
+ + {organization.name} + + + {memberCount} {memberCount === 1 ? 'member' : 'members'} + +
+
+ + + + Settings + + +
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts new file mode 100644 index 00000000000..fe0024ab47c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts @@ -0,0 +1 @@ +export { WorkspacesRailFlyout } from './workspaces-rail-flyout' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx new file mode 100644 index 00000000000..b6729ecbf07 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const workspacesState = vi.hoisted(() => ({ + workspaces: [] as { id: string; name: string }[], + isLoading: false, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({ + useOrganizationWorkspaces: () => workspacesState, +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu', + () => ({ + CollapsedResourceFlyout: ({ + entries, + isLoading, + emptyLabel, + }: { + entries: { id: string; name: string; href: string }[] + isLoading: boolean + emptyLabel: string + }) => + isLoading ? ( + Loading... + ) : entries.length === 0 ? ( + {emptyLabel} + ) : ( + entries.map((entry) => ( + + {entry.name} + + )) + ), + }) +) + +import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + workspacesState.workspaces = [] + workspacesState.isLoading = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +async function render() { + await act(async () => { + root.render() + }) +} + +describe('WorkspacesRailFlyout', () => { + it('lists every workspace as a link into it', async () => { + workspacesState.workspaces = [ + { id: 'ws-1', name: 'Design' }, + { id: 'ws-2', name: 'Ops' }, + ] + await render() + + const links = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href')) + expect(links).toEqual(['/workspace/ws-1', '/workspace/ws-2']) + expect(container.textContent).toContain('Design') + }) + + it('shows the empty label when the organization has no workspaces', async () => { + await render() + expect(container.textContent).toContain('No workspaces yet') + }) + + it('shows the loading row while the list resolves', async () => { + workspacesState.isLoading = true + await render() + expect(container.textContent).toContain('Loading...') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx new file mode 100644 index 00000000000..b36e478cf14 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders' +import { CollapsedResourceFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu' + +interface WorkspacesRailFlyoutProps { + organizationId: string +} + +/** + * Rail flyout body for the Workspaces tab: a jump list of the organization's + * workspaces, one row each, the way the workspace sidebar's Tables and Files tabs + * list theirs. Mounts only while the rail menu is open, so the workspace query + * runs only when someone hovers the chip. + */ +export function WorkspacesRailFlyout({ organizationId }: WorkspacesRailFlyoutProps) { + const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId) + + const entries: FlyoutEntry[] = workspaces.map((workspace) => ({ + kind: 'item', + id: workspace.id, + name: workspace.name, + pinned: false, + href: `/workspace/${workspace.id}`, + })) + + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts new file mode 100644 index 00000000000..48d480cdd44 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts @@ -0,0 +1 @@ +export { WorkspacesSection } from './workspaces-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx new file mode 100644 index 00000000000..7505e56a100 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx @@ -0,0 +1,139 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + isOpen: false, + workspaces: [] as { id: string; name: string; logoUrl: null }[], + isLoading: false, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ + useHoverMenu: () => ({ + isOpen: state.isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() }, + }), +})) +vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({ + useOrganizationWorkspaces: () => ({ workspaces: state.workspaces, isLoading: state.isLoading }), +})) + +import { WorkspacesSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + state.isOpen = false + state.isLoading = false + state.workspaces = Array.from({ length: 8 }, (_, index) => ({ + id: `ws-${index + 1}`, + name: `Workspace ${index + 1}`, + logoUrl: null, + })) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(props: Partial[0]> = {}) { + await act(async () => { + root.render( + {}} + {...props} + /> + ) + }) +} + +function rows() { + return container.querySelectorAll('a[href^="/workspace/"]') +} + +function pager() { + return Array.from(container.querySelectorAll('button')).find((button) => + /See (more|less)/.test(button.textContent ?? '') + ) +} + +describe('WorkspacesSection', () => { + it('shows the first page and pages the rest in like the sidebar chats', async () => { + await render() + expect(rows()).toHaveLength(5) + expect(pager()?.textContent).toBe('See more') + + await act(async () => pager()?.click()) + expect(rows()).toHaveLength(8) + expect(pager()?.textContent).toBe('See less') + + await act(async () => pager()?.click()) + expect(rows()).toHaveLength(5) + }) + + it('offers no pager when the list fits the first page', async () => { + state.workspaces = state.workspaces.slice(0, 3) + await render() + expect(rows()).toHaveLength(3) + expect(pager()).toBeUndefined() + }) + + it('marks the workspace on the current route active', async () => { + await render({ pathname: '/workspace/ws-2' }) + expect(container.querySelector('a[href="/workspace/ws-2"]')?.className).toContain( + 'surface-active' + ) + expect(container.querySelector('a[href="/workspace/ws-1"]')?.className).not.toContain( + 'surface-active' + ) + }) + + it('shows the empty state only once the list has resolved', async () => { + state.workspaces = [] + state.isLoading = true + await render() + expect(container.textContent).not.toContain('No workspaces yet') + + state.isLoading = false + await render() + expect(container.textContent).toContain('No workspaces yet') + }) + + it('renders the flyout while collapsed', async () => { + state.isOpen = true + await render({ isCollapsed: true }) + expect(container.querySelector('[aria-label="Workspaces"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx new file mode 100644 index 00000000000..5e64b48fa75 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useState } from 'react' +import { chipVariants, cn, OverflowText } from '@sim/emcn' +import { Workspaces } from '@sim/emcn/icons' +import Link from 'next/link' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' +import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout' +import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' + +/** Rows shown at first, and added per "See more" — the workspace sidebar's Chats paging. */ +const PAGE_SIZE = 5 + +interface WorkspacesSectionProps { + organizationId: string + isCollapsed: boolean + pathname: string | null + onContextMenu: (e: React.MouseEvent, href: string) => void +} + +/** + * The organization's workspaces the viewer belongs to: the first section of the + * scroll region, so it carries no section gap — the divider padding above it is + * the whole distance, exactly as the workspace sidebar spaces its own Chats. + * Expanded, five rail chips and a muted "See more" that pages the rest in, the way + * the workspace sidebar pages its Chats; collapsed, a hover flyout off the rail glyph. + */ +export function WorkspacesSection({ + organizationId, + isCollapsed, + pathname, + onContextMenu, +}: WorkspacesSectionProps) { + const hover = useHoverMenu() + const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId) + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const hasMore = workspaces.length > visibleCount + + return ( + + {isCollapsed ? ( +
+ } + hover={hover} + ariaLabel='Workspaces' + > + + +
+ ) : ( +
+ {!isLoading && workspaces.length === 0 && ( +
+ No workspaces yet +
+ )} + {workspaces.slice(0, visibleCount).map((workspace) => { + const href = `/workspace/${workspace.id}` + return ( + onContextMenu(e, href)} + > + + + + ) + })} + {workspaces.length > PAGE_SIZE && ( + + )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts new file mode 100644 index 00000000000..c96914ad41e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts @@ -0,0 +1,4 @@ +export { useCollapsedTooltips } from './use-collapsed-tooltips' +export type { OrganizationChat } from './use-organization-chats' +export { useOrganizationChats } from './use-organization-chats' +export { useOrganizationWorkspaces } from './use-organization-workspaces' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts new file mode 100644 index 00000000000..b63dc15c591 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' + +/** How long the rail takes to settle after collapsing before row tooltips arm. */ +const COLLAPSED_TOOLTIP_DELAY_MS = 200 + +/** + * Whether collapsed-rail tooltips should render. Arming is delayed past the rail's + * width animation so a tooltip never flashes beside a label that is still fading + * out; disarming is immediate so the expanded rail never shows one. + */ +export function useCollapsedTooltips(isCollapsed: boolean): boolean { + const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) + + useEffect(() => { + if (isCollapsed) { + const timer = setTimeout(() => setShowCollapsedTooltips(true), COLLAPSED_TOOLTIP_DELAY_MS) + return () => clearTimeout(timer) + } + setShowCollapsedTooltips(false) + }, [isCollapsed]) + + return isCollapsed && showCollapsedTooltips +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts new file mode 100644 index 00000000000..0bff17ebe9e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts @@ -0,0 +1,26 @@ +import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats' + +export interface OrganizationChat { + id: string + name: string + href: string + /** A run is in progress. */ + isActive?: boolean + /** Has a reply the viewer has not opened. */ + isUnread?: boolean + isPinned?: boolean +} + +/** Lists only the current member's private organization conversations. */ +export function useOrganizationChats(organizationId: string) { + const query = useOrganizationMothershipChats(organizationId) + const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({ + id: chat.id, + name: chat.name, + href: `/o/${organizationId}/chat/${chat.id}`, + isActive: chat.isActive, + isUnread: chat.isUnread, + isPinned: chat.isPinned, + })) + return { chats, isLoading: query.isPending } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts new file mode 100644 index 00000000000..6c54a696a36 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts @@ -0,0 +1,14 @@ +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +/** + * The organization's workspaces the viewer belongs to, for the sidebar's + * Workspaces section. Read from the viewer's workspace list — the same query the + * workspace switcher uses — narrowed to those the organization owns. + */ +export function useOrganizationWorkspaces(organizationId: string) { + const { data = [], isLoading } = useWorkspacesQuery() + + const workspaces = data.filter((workspace) => workspace.organizationId === organizationId) + + return { workspaces, isLoading } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts new file mode 100644 index 00000000000..9963f275118 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts @@ -0,0 +1 @@ +export { OrganizationSidebar } from './organization-sidebar' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts new file mode 100644 index 00000000000..9b3b0a6a6c6 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts @@ -0,0 +1,33 @@ +import { Home, Integration, Search } from '@sim/emcn/icons' +import { organizationRoutes } from '@/lib/navigation/paths' +import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' + +type OrganizationNavRoute = 'home' | 'search' | 'integrations' + +interface OrganizationNavEntry { + id: string + label: string + icon: SidebarNavItemData['icon'] + route: OrganizationNavRoute +} + +/** + * The pinned block at the top of the organization sidebar, in display order. + * Hrefs are resolved per organization by {@link buildOrganizationNavItems}. + */ +const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [ + { id: 'home', label: 'Home', icon: Home, route: 'home' }, + { id: 'search', label: 'Search', icon: Search, route: 'search' }, + { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' }, +] + +export function buildOrganizationNavItems( + organizationId: string, + searchAvailable: boolean +): SidebarNavItemData[] { + const routes = organizationRoutes(organizationId) + return ORGANIZATION_NAV_ENTRIES.filter(() => searchAvailable).map(({ route, ...entry }) => ({ + ...entry, + href: routes[route], + })) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx new file mode 100644 index 00000000000..8be529333e1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -0,0 +1,340 @@ +'use client' + +import { type ComponentProps, memo, useCallback, useRef, useState } from 'react' +import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { PanelLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { usePathname } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { isMacPlatform } from '@/lib/core/utils/platform' +import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { organizationRoutes } from '@/lib/navigation/paths' +import { captureEvent } from '@/lib/posthog/client' +import { + ChatsSection, + OrganizationFooter, + OrganizationHeader, + WorkspacesSection, +} from '@/app/o/[organizationId]/components/organization-sidebar/components' +import { + useCollapsedTooltips, + useOrganizationChats, +} from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar' +import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' +import { + isNavItemActive, + NavItemContextMenu, + SidebarNavChip, + SidebarTooltip, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useContextMenu } from '@/hooks/use-context-menu' +import { useSidebarStore } from '@/stores/sidebar/store' + +const logger = createLogger('OrganizationSidebar') + +/** + * Opts a control out of the desktop shell's window-drag region. The header row is + * draggable chrome, so anything clickable inside it has to say so or the click is + * swallowed by the drag handler. + */ +const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' + +interface OrganizationChatsProps + extends Omit, 'chats' | 'isLoading'> { + organizationId: string +} + +function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) { + const { chats, isLoading } = useOrganizationChats(organizationId) + return +} + +/** + * The organization surface's rail: the same chrome as the workspace sidebar — + * header row, pinned nav block, a divided scroll region of sections, and the + * pinned footer — hosted by the same `WorkspaceChrome`, so collapse, resize, and + * the desktop hover-peek all behave identically. Collapse and peek state come from + * the chrome through {@link useSidebarChrome}. + */ +export const OrganizationSidebar = memo(function OrganizationSidebar() { + const { isCollapsed: railCollapsed, isPeeking } = useSidebarChrome() + /** The peek card always renders the expanded layout, whatever the rail's state. */ + const isCollapsed = railCollapsed && !isPeeking + + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + + const pathname = usePathname() + const posthog = usePostHog() + const { organization, searchAccess } = useOrganizationContext() + const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) + const { handlePointerDown } = useSidebarResize() + const showCollapsedTooltips = useCollapsedTooltips(isCollapsed) + const scrollEdges = useScrollEdges(scrollContainerRef, { + contentRef: scrollContentRef, + enabled: !isCollapsed, + }) + + const isMac = isMacPlatform() + const navItems = buildOrganizationNavItems(organization.id, searchAccess.memberScoped) + const settingsPath = organizationRoutes(organization.id).settings + const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) + + /** + * One menu serves every href-bearing row (nav items, workspaces, chats): the + * actions — open in a new tab, copy the link — only need the destination. + */ + const [menuHref, setMenuHref] = useState(null) + const { + isOpen: isHrefMenuOpen, + position: hrefMenuPosition, + menuRef: hrefMenuRef, + handleContextMenu: openHrefMenu, + closeMenu: closeHrefMenu, + } = useContextMenu() + + const handleHrefContextMenu = useCallback( + (e: React.MouseEvent, href: string) => { + setMenuHref(href) + openHrefMenu(e) + }, + [openHrefMenu] + ) + + /** Anchors the menu to the row's options button rather than the pointer. */ + const handleChatMoreClick = useCallback( + (e: React.MouseEvent, href: string) => { + if (isHrefMenuOpen) { + closeHrefMenu() + return + } + const rect = e.currentTarget.getBoundingClientRect() + setMenuHref(href) + openHrefMenu({ + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: rect.right, + clientY: rect.top, + } as React.MouseEvent) + }, + [isHrefMenuOpen, closeHrefMenu, openHrefMenu] + ) + + const handleHrefMenuClose = () => { + closeHrefMenu() + setMenuHref(null) + } + + const handleOpenInNewTab = () => { + if (menuHref) window.open(menuHref, '_blank', 'noopener,noreferrer') + } + + const handleCopyLink = async () => { + if (!menuHref) return + try { + await navigator.clipboard.writeText(`${window.location.origin}${menuHref}`) + } catch (error) { + logger.error('Failed to copy link to clipboard', { error }) + } + } + + const handleOpenDocs = () => { + window.open(DOCS_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) + } + + const handleOpenSlackCommunity = () => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + } + + const handleEdgeKeyDown = (e: React.KeyboardEvent) => { + if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + toggleCollapsed() + } + } + + useRegisterGlobalCommands(() => + createCommands([ + { + id: 'toggle-sidebar', + handler: () => { + toggleCollapsed() + }, + }, + ]) + ) + + return ( +
+ + + {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
+ )} +
+ ) +}) diff --git a/apps/sim/app/o/[organizationId]/error.tsx b/apps/sim/app/o/[organizationId]/error.tsx new file mode 100644 index 00000000000..42dca68dcb2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/error.tsx @@ -0,0 +1,18 @@ +'use client' + +import { + type ErrorBoundaryProps, + ErrorState, +} from '@/app/workspace/[workspaceId]/components/error/error' + +export default function OrganizationError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx new file mode 100644 index 00000000000..e0992209015 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -0,0 +1,100 @@ +'use client' + +import { Button, cn } from '@sim/emcn' +import { ArrowUp } from '@sim/emcn/icons' +import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' + +const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors' +const SEND_BUTTON_ACTIVE = + 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' +const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' + +interface ComposerProps { + value: string + /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */ + isInitialView: boolean + isSending: boolean + onChange: (value: string) => void + onSubmit: () => void + onStop: () => void +} + +/** + * The organization home composer: a question to the Assistant. Wears the + * workspace chat input's chrome — the framed field and the send control — and + * carries only the controls that are wired for the organization. + */ +export function Composer({ + value, + isInitialView, + isSending, + onChange, + onSubmit, + onStop, +}: ComposerProps) { + const canSubmit = value.trim().length > 0 + const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) + const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + + return ( +
+
+